M4-T07: add admin_cli disable-totp and reissue-totp escape commands
This commit is contained in:
@@ -224,7 +224,7 @@ Phase B(TOTP,可选配)
|
|||||||
- **Reviewer checklist**: `totp_required` 时确未发 cookie;恢复码消费后不可复用;通用错误不泄露细节。
|
- **Reviewer checklist**: `totp_required` 时确未发 cookie;恢复码消费后不可复用;通用错误不泄露细节。
|
||||||
|
|
||||||
### M4-T07 — CLI disable-totp / reissue-totp
|
### M4-T07 — CLI disable-totp / reissue-totp
|
||||||
- **Status**: `todo` · **Depends**: M4-T04(逻辑上配合 T03 的 CLI 骨架)
|
- **Status**: `done` · **Depends**: M4-T04(逻辑上配合 T03 的 CLI 骨架)
|
||||||
- **Files**: `modify scripts/admin_cli.py`;`modify tests/test_admin_cli.py`
|
- **Files**: `modify scripts/admin_cli.py`;`modify tests/test_admin_cli.py`
|
||||||
- **Steps**: `disable-totp <username>`:`totp_enabled=false` + 清 secret + 删恢复码(**不需任何恢复码即可执行**);`reissue-totp <username>`:生成新 secret(可选打印新 URI)。
|
- **Steps**: `disable-totp <username>`:`totp_enabled=false` + 清 secret + 删恢复码(**不需任何恢复码即可执行**);`reissue-totp <username>`:生成新 secret(可选打印新 URI)。
|
||||||
- **Out of scope / 不要碰**: 不碰用户数据表。
|
- **Out of scope / 不要碰**: 不碰用户数据表。
|
||||||
|
|||||||
+141
-4
@@ -18,6 +18,22 @@ unlock [--all | --ip <ip> | --username <u>]
|
|||||||
--username clear the row for a specific username (scope='user')
|
--username clear the row for a specific username (scope='user')
|
||||||
Prints the number of rows deleted.
|
Prints the number of rows deleted.
|
||||||
|
|
||||||
|
disable-totp <username>
|
||||||
|
Disable TOTP for the given user without requiring any existing credential
|
||||||
|
(recovery codes, current TOTP code, or password). Sets totp_enabled=False,
|
||||||
|
clears totp_secret, and deletes ALL recovery codes for that user.
|
||||||
|
This is the final escape hatch when all recovery codes are lost.
|
||||||
|
|
||||||
|
reissue-totp <username>
|
||||||
|
Generate a fresh TOTP secret for the given user and print the new
|
||||||
|
otpauth:// URI so the user can re-enroll in their Authenticator app.
|
||||||
|
totp_enabled is LEFT UNCHANGED (caller must re-run enable flow if needed).
|
||||||
|
Existing recovery codes are NOT deleted. Recovery codes are independent
|
||||||
|
random values with no cryptographic binding to the TOTP secret, so they
|
||||||
|
remain valid after reissue and continue to serve as backup login credentials.
|
||||||
|
Silently removing them would unexpectedly strip the user of that backup;
|
||||||
|
use disable-totp for a full cleanup (secret + recovery codes).
|
||||||
|
|
||||||
list-admin
|
list-admin
|
||||||
Print all users with their auth status columns (id, username,
|
Print all users with their auth status columns (id, username,
|
||||||
is_active, force_password_change).
|
is_active, force_password_change).
|
||||||
@@ -25,9 +41,9 @@ list-admin
|
|||||||
Design contract
|
Design contract
|
||||||
---------------
|
---------------
|
||||||
- Direct DB access via ``get_session_local()`` — no HTTP, no network.
|
- Direct DB access via ``get_session_local()`` — no HTTP, no network.
|
||||||
- Only touches ``auth_users`` (password_hash column) and
|
- Only touches ``auth_users`` (totp_secret/totp_enabled/password_hash columns)
|
||||||
``auth_login_throttle`` (delete rows). Never touches any user-data
|
and ``auth_login_throttle`` / ``auth_recovery_code`` (delete rows).
|
||||||
table (location, poo, public_ip, app_config, etc.).
|
Never touches any user-data table (location, poo, public_ip, app_config, etc.).
|
||||||
- Non-zero exit code on user-facing errors (missing user, bad args, …).
|
- Non-zero exit code on user-facing errors (missing user, bad args, …).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -44,11 +60,13 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|||||||
if str(PROJECT_ROOT) not in sys.path:
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
import pyotp
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.db import get_session_local
|
from app.db import get_session_local
|
||||||
from app.models.auth import AuthUser
|
from app.models.auth import AuthUser, RecoveryCode
|
||||||
from app.models.auth_throttle import LoginThrottle
|
from app.models.auth_throttle import LoginThrottle
|
||||||
from app.services.auth import hash_password, verify_password # noqa: F401 (used below)
|
from app.services.auth import hash_password, verify_password # noqa: F401 (used below)
|
||||||
|
|
||||||
@@ -148,6 +166,103 @@ def cmd_unlock(args: argparse.Namespace) -> None:
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_disable_totp(args: argparse.Namespace) -> None:
|
||||||
|
"""Disable TOTP for <username> — no credential required (escape hatch).
|
||||||
|
|
||||||
|
Sets totp_enabled=False, clears totp_secret, and deletes ALL recovery
|
||||||
|
codes for this user. This is the final escape route when all recovery
|
||||||
|
codes have been lost: whoever holds server CLI access can always turn off
|
||||||
|
TOTP, restoring plain-password login.
|
||||||
|
"""
|
||||||
|
username: str = args.username
|
||||||
|
|
||||||
|
session = _get_session()
|
||||||
|
try:
|
||||||
|
user = _find_user(session, username)
|
||||||
|
if user is None:
|
||||||
|
_die(f"User '{username}' not found.")
|
||||||
|
|
||||||
|
assert user.id is not None
|
||||||
|
# Delete all recovery codes for this user.
|
||||||
|
deleted = session.execute(
|
||||||
|
delete(RecoveryCode).where(RecoveryCode.user_id == user.id)
|
||||||
|
)
|
||||||
|
rc_count = deleted.rowcount
|
||||||
|
|
||||||
|
# Clear TOTP state.
|
||||||
|
user.totp_enabled = False
|
||||||
|
user.totp_secret = None
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
print(f"TOTP disabled for '{username}'. {rc_count} recovery code(s) deleted.")
|
||||||
|
print("The user can now log in with password only.")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_reissue_totp(args: argparse.Namespace) -> None:
|
||||||
|
"""Generate a fresh TOTP secret for <username> and print the otpauth URI.
|
||||||
|
|
||||||
|
Design choices:
|
||||||
|
- ``totp_enabled`` is LEFT UNCHANGED. If the user currently has TOTP
|
||||||
|
enabled, the new secret takes effect immediately on the next login
|
||||||
|
(old code invalid, new code accepted) — no web enable step required.
|
||||||
|
If TOTP was disabled, the secret is updated but login remains
|
||||||
|
password-only until the web setup/enable flow is completed.
|
||||||
|
- Existing recovery codes are NOT deleted here. Recovery codes are
|
||||||
|
independent random values stored as Argon2 hashes in
|
||||||
|
``auth_recovery_code.code_hash``; login verification compares only those
|
||||||
|
hashes and has no dependency on ``totp_secret``. Reissuing the secret
|
||||||
|
therefore leaves all existing recovery codes fully valid — they remain
|
||||||
|
the user's backup login path. Silently deleting them would
|
||||||
|
unexpectedly strip that backup; ``disable-totp`` is the explicit full
|
||||||
|
cleanup path (secret + all recovery codes).
|
||||||
|
- This command's purpose is to hand the new secret URI to the admin so
|
||||||
|
they can re-enroll their Authenticator — useful when the device is lost
|
||||||
|
but the account credentials are still intact.
|
||||||
|
"""
|
||||||
|
username: str = args.username
|
||||||
|
|
||||||
|
session = _get_session()
|
||||||
|
try:
|
||||||
|
user = _find_user(session, username)
|
||||||
|
if user is None:
|
||||||
|
_die(f"User '{username}' not found.")
|
||||||
|
|
||||||
|
# Generate a new base-32 secret using pyotp (same library as the web TOTP flow).
|
||||||
|
new_secret = pyotp.random_base32()
|
||||||
|
|
||||||
|
# Build the provisioning URI using the same issuer logic as the web service.
|
||||||
|
settings = get_settings()
|
||||||
|
issuer = settings.effective_totp_issuer
|
||||||
|
otpauth_uri = pyotp.TOTP(new_secret).provisioning_uri(
|
||||||
|
name=user.username,
|
||||||
|
issuer_name=issuer,
|
||||||
|
)
|
||||||
|
|
||||||
|
user.totp_secret = new_secret
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
print(f"New TOTP secret issued for '{username}'.")
|
||||||
|
print(f"Secret : {new_secret}")
|
||||||
|
print(f"URI : {otpauth_uri}")
|
||||||
|
print()
|
||||||
|
if user.totp_enabled:
|
||||||
|
print(
|
||||||
|
"NOTE: TOTP is still ENABLED. Scan the URI above in your Authenticator app;"
|
||||||
|
" the new code is active immediately on next login — the old code is now invalid."
|
||||||
|
" To fully disable TOTP instead, use 'disable-totp'."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"NOTE: TOTP is currently DISABLED for this user — login remains password-only."
|
||||||
|
" The new secret has been saved, but will only take effect once TOTP is"
|
||||||
|
" enabled via the web settings page."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def cmd_list_admin(args: argparse.Namespace) -> None: # noqa: ARG001
|
def cmd_list_admin(args: argparse.Namespace) -> None: # noqa: ARG001
|
||||||
"""List all admin users with their status columns."""
|
"""List all admin users with their status columns."""
|
||||||
session = _get_session()
|
session = _get_session()
|
||||||
@@ -225,6 +340,28 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
ul.set_defaults(func=cmd_unlock)
|
ul.set_defaults(func=cmd_unlock)
|
||||||
|
|
||||||
|
# -- disable-totp --
|
||||||
|
dt = subparsers.add_parser(
|
||||||
|
"disable-totp",
|
||||||
|
help=(
|
||||||
|
"Disable TOTP for a user (escape hatch — no credential required). "
|
||||||
|
"Clears totp_secret, sets totp_enabled=False, deletes all recovery codes."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dt.add_argument("username", help="Username whose TOTP to disable.")
|
||||||
|
dt.set_defaults(func=cmd_disable_totp)
|
||||||
|
|
||||||
|
# -- reissue-totp --
|
||||||
|
rt = subparsers.add_parser(
|
||||||
|
"reissue-totp",
|
||||||
|
help=(
|
||||||
|
"Generate a new TOTP secret for a user and print the otpauth:// URI. "
|
||||||
|
"totp_enabled is left unchanged; re-enroll via the web UI."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rt.add_argument("username", help="Username for whom to reissue the TOTP secret.")
|
||||||
|
rt.set_defaults(func=cmd_reissue_totp)
|
||||||
|
|
||||||
# -- list-admin --
|
# -- list-admin --
|
||||||
la = subparsers.add_parser(
|
la = subparsers.add_parser(
|
||||||
"list-admin",
|
"list-admin",
|
||||||
|
|||||||
+203
-2
@@ -1,8 +1,10 @@
|
|||||||
"""Tests for M4-T03: scripts/admin_cli.py — CLI escape hatch.
|
"""Tests for M4-T03 + M4-T07: scripts/admin_cli.py — CLI escape hatch.
|
||||||
|
|
||||||
Covers:
|
Covers:
|
||||||
- reset-password: new password verifies, old password fails.
|
- reset-password: new password verifies, old password fails.
|
||||||
- unlock: clears specified/all throttle rows; prints count; idempotent.
|
- unlock: clears specified/all throttle rows; prints count; idempotent.
|
||||||
|
- disable-totp: clears TOTP state + recovery codes without requiring any credential.
|
||||||
|
- reissue-totp: writes new secret; prints otpauth URI; secret changes.
|
||||||
- list-admin: prints user table without crashing.
|
- list-admin: prints user table without crashing.
|
||||||
- Non-existent user → friendly error + SystemExit with non-zero code.
|
- Non-existent user → friendly error + SystemExit with non-zero code.
|
||||||
- Data-safety: CLI must never reference user-data tables in delete/drop.
|
- Data-safety: CLI must never reference user-data tables in delete/drop.
|
||||||
@@ -26,13 +28,15 @@ from sqlalchemy.orm import Session, sessionmaker
|
|||||||
|
|
||||||
from app.db import reset_db_caches
|
from app.db import reset_db_caches
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models.auth import AuthUser
|
from app.models.auth import AuthUser, RecoveryCode
|
||||||
from app.models.auth_throttle import LoginThrottle
|
from app.models.auth_throttle import LoginThrottle
|
||||||
from app.services.auth import hash_password, verify_password
|
from app.services.auth import hash_password, verify_password
|
||||||
from scripts.admin_cli import (
|
from scripts.admin_cli import (
|
||||||
build_parser,
|
build_parser,
|
||||||
|
cmd_disable_totp,
|
||||||
cmd_list_admin,
|
cmd_list_admin,
|
||||||
cmd_reset_password,
|
cmd_reset_password,
|
||||||
|
cmd_reissue_totp,
|
||||||
cmd_unlock,
|
cmd_unlock,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,6 +133,25 @@ def _throttle_count(session: Session) -> int:
|
|||||||
return session.query(LoginThrottle).count()
|
return session.query(LoginThrottle).count()
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_totp_for_user(session: Session, username: str, secret: str = "JBSWY3DPEHPK3PXP") -> None:
|
||||||
|
"""Helper: set TOTP enabled + secret for a user and add some recovery codes."""
|
||||||
|
user = session.scalar(select(AuthUser).where(AuthUser.username == username))
|
||||||
|
assert user is not None, f"User '{username}' not found in _enable_totp_for_user"
|
||||||
|
user.totp_secret = secret
|
||||||
|
user.totp_enabled = True
|
||||||
|
# Add two fake recovery codes so we can verify they get deleted.
|
||||||
|
session.add(RecoveryCode(user_id=user.id, code_hash=hash_password("aaaa-bbbb")))
|
||||||
|
session.add(RecoveryCode(user_id=user.id, code_hash=hash_password("cccc-dddd")))
|
||||||
|
session.commit()
|
||||||
|
session.expire_all()
|
||||||
|
|
||||||
|
|
||||||
|
def _recovery_code_count(session: Session, username: str) -> int:
|
||||||
|
user = session.scalar(select(AuthUser).where(AuthUser.username == username))
|
||||||
|
assert user is not None
|
||||||
|
return session.query(RecoveryCode).filter(RecoveryCode.user_id == user.id).count()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# reset-password tests
|
# reset-password tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -342,6 +365,182 @@ class TestListAdmin:
|
|||||||
reset_db_caches()
|
reset_db_caches()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# disable-totp tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisableTotp:
|
||||||
|
"""disable-totp sub-command (M4-T07)."""
|
||||||
|
|
||||||
|
def test_disable_totp_clears_state(self, cli_db: str, cli_session: Session):
|
||||||
|
"""After disable-totp: totp_enabled=False, totp_secret=None, recovery codes gone."""
|
||||||
|
_enable_totp_for_user(cli_session, "admin")
|
||||||
|
|
||||||
|
# Verify preconditions.
|
||||||
|
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||||
|
assert user is not None
|
||||||
|
assert user.totp_enabled is True
|
||||||
|
assert user.totp_secret is not None
|
||||||
|
assert _recovery_code_count(cli_session, "admin") == 2
|
||||||
|
|
||||||
|
# Run disable-totp — NO credential supplied (escape hatch semantics).
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["disable-totp", "admin"])
|
||||||
|
cmd_disable_totp(args)
|
||||||
|
|
||||||
|
# Verify state.
|
||||||
|
cli_session.expire_all()
|
||||||
|
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||||
|
assert user is not None
|
||||||
|
assert user.totp_enabled is False
|
||||||
|
assert user.totp_secret is None
|
||||||
|
assert _recovery_code_count(cli_session, "admin") == 0
|
||||||
|
|
||||||
|
def test_disable_totp_no_credential_required(self, cli_db: str, cli_session: Session):
|
||||||
|
"""disable-totp must succeed without any recovery code or password argument."""
|
||||||
|
_enable_totp_for_user(cli_session, "admin")
|
||||||
|
|
||||||
|
# The argparse spec for disable-totp takes only username — no credential flags.
|
||||||
|
# Parsing must succeed with just the username.
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["disable-totp", "admin"])
|
||||||
|
# No exception means no credential was needed.
|
||||||
|
cmd_disable_totp(args) # must not raise
|
||||||
|
|
||||||
|
def test_disable_totp_already_disabled_is_noop(self, cli_db: str, cli_session: Session):
|
||||||
|
"""disable-totp on a user who never had TOTP is a harmless no-op."""
|
||||||
|
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||||
|
assert user is not None
|
||||||
|
assert user.totp_enabled is False
|
||||||
|
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["disable-totp", "admin"])
|
||||||
|
cmd_disable_totp(args) # must not raise
|
||||||
|
|
||||||
|
cli_session.expire_all()
|
||||||
|
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||||
|
assert user is not None
|
||||||
|
assert user.totp_enabled is False
|
||||||
|
assert user.totp_secret is None
|
||||||
|
|
||||||
|
def test_disable_totp_nonexistent_user_exits_nonzero(self, cli_db: str):
|
||||||
|
"""disable-totp for an unknown user must SystemExit with code != 0."""
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["disable-totp", "ghost-user"])
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
cmd_disable_totp(args)
|
||||||
|
assert exc_info.value.code != 0
|
||||||
|
|
||||||
|
def test_disable_totp_prints_confirmation(
|
||||||
|
self, cli_db: str, cli_session: Session, capsys
|
||||||
|
):
|
||||||
|
"""disable-totp must print a confirmation message mentioning the user."""
|
||||||
|
_enable_totp_for_user(cli_session, "admin")
|
||||||
|
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["disable-totp", "admin"])
|
||||||
|
cmd_disable_totp(args)
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "admin" in captured.out
|
||||||
|
|
||||||
|
def test_disable_totp_does_not_touch_password(
|
||||||
|
self, cli_db: str, cli_session: Session
|
||||||
|
):
|
||||||
|
"""disable-totp must not alter the user's password_hash."""
|
||||||
|
_enable_totp_for_user(cli_session, "admin")
|
||||||
|
|
||||||
|
user_before = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||||
|
assert user_before is not None
|
||||||
|
pw_hash_before = user_before.password_hash
|
||||||
|
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["disable-totp", "admin"])
|
||||||
|
cmd_disable_totp(args)
|
||||||
|
|
||||||
|
cli_session.expire_all()
|
||||||
|
user_after = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||||
|
assert user_after is not None
|
||||||
|
assert user_after.password_hash == pw_hash_before
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# reissue-totp tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestReissueTotp:
|
||||||
|
"""reissue-totp sub-command (M4-T07)."""
|
||||||
|
|
||||||
|
def test_reissue_totp_changes_secret(self, cli_db: str, cli_session: Session):
|
||||||
|
"""After reissue-totp, totp_secret must be a new non-empty value."""
|
||||||
|
old_secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
_enable_totp_for_user(cli_session, "admin", secret=old_secret)
|
||||||
|
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["reissue-totp", "admin"])
|
||||||
|
cmd_reissue_totp(args)
|
||||||
|
|
||||||
|
cli_session.expire_all()
|
||||||
|
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||||
|
assert user is not None
|
||||||
|
assert user.totp_secret is not None
|
||||||
|
assert user.totp_secret != old_secret, "Secret must be regenerated"
|
||||||
|
|
||||||
|
def test_reissue_totp_prints_uri(self, cli_db: str, cli_session: Session, capsys):
|
||||||
|
"""reissue-totp must print an otpauth:// URI and the secret."""
|
||||||
|
_enable_totp_for_user(cli_session, "admin")
|
||||||
|
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["reissue-totp", "admin"])
|
||||||
|
cmd_reissue_totp(args)
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "otpauth://" in captured.out
|
||||||
|
assert "Secret" in captured.out
|
||||||
|
|
||||||
|
def test_reissue_totp_totp_enabled_unchanged(self, cli_db: str, cli_session: Session):
|
||||||
|
"""reissue-totp must leave totp_enabled in its current state (True stays True)."""
|
||||||
|
_enable_totp_for_user(cli_session, "admin")
|
||||||
|
|
||||||
|
user_before = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||||
|
assert user_before is not None
|
||||||
|
assert user_before.totp_enabled is True
|
||||||
|
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["reissue-totp", "admin"])
|
||||||
|
cmd_reissue_totp(args)
|
||||||
|
|
||||||
|
cli_session.expire_all()
|
||||||
|
user_after = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||||
|
assert user_after is not None
|
||||||
|
assert user_after.totp_enabled is True, "totp_enabled must remain unchanged"
|
||||||
|
|
||||||
|
def test_reissue_totp_disabled_user_stays_disabled(
|
||||||
|
self, cli_db: str, cli_session: Session
|
||||||
|
):
|
||||||
|
"""reissue-totp on a user with totp_enabled=False leaves it False."""
|
||||||
|
# Don't enable TOTP — user is in disabled state.
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["reissue-totp", "admin"])
|
||||||
|
cmd_reissue_totp(args)
|
||||||
|
|
||||||
|
cli_session.expire_all()
|
||||||
|
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||||
|
assert user is not None
|
||||||
|
assert user.totp_enabled is False, "totp_enabled must remain False"
|
||||||
|
assert user.totp_secret is not None, "A new secret should have been written"
|
||||||
|
|
||||||
|
def test_reissue_totp_nonexistent_user_exits_nonzero(self, cli_db: str):
|
||||||
|
"""reissue-totp for an unknown user must SystemExit with code != 0."""
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(["reissue-totp", "ghost-user"])
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
cmd_reissue_totp(args)
|
||||||
|
assert exc_info.value.code != 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Argument-parser edge-cases
|
# Argument-parser edge-cases
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -387,4 +586,6 @@ def test_help_flag_exits_zero():
|
|||||||
assert result.returncode == 0
|
assert result.returncode == 0
|
||||||
assert "reset-password" in result.stdout
|
assert "reset-password" in result.stdout
|
||||||
assert "unlock" in result.stdout
|
assert "unlock" in result.stdout
|
||||||
|
assert "disable-totp" in result.stdout
|
||||||
|
assert "reissue-totp" in result.stdout
|
||||||
assert "list-admin" in result.stdout
|
assert "list-admin" in result.stdout
|
||||||
|
|||||||
Reference in New Issue
Block a user