M4-T07: add admin_cli disable-totp and reissue-totp escape commands

This commit is contained in:
2026-06-21 22:27:07 +02:00
parent 81bd32f613
commit 51da8c5716
3 changed files with 345 additions and 7 deletions
+203 -2
View File
@@ -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:
- reset-password: new password verifies, old password fails.
- 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.
- Non-existent user → friendly error + SystemExit with non-zero code.
- 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.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.services.auth import hash_password, verify_password
from scripts.admin_cli import (
build_parser,
cmd_disable_totp,
cmd_list_admin,
cmd_reset_password,
cmd_reissue_totp,
cmd_unlock,
)
@@ -129,6 +133,25 @@ def _throttle_count(session: Session) -> int:
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
# ---------------------------------------------------------------------------
@@ -342,6 +365,182 @@ class TestListAdmin:
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
# ---------------------------------------------------------------------------
@@ -387,4 +586,6 @@ def test_help_flag_exits_zero():
assert result.returncode == 0
assert "reset-password" 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