"""Tests for M4-T04: AuthUser TOTP fields and auth_recovery_code migration.""" from __future__ import annotations from datetime import datetime, timezone from pathlib import Path import pytest from alembic import command from alembic.config import Config from sqlalchemy import create_engine, inspect, text from sqlalchemy.orm import Session from app.models.auth import AuthUser, RecoveryCode def _make_app_alembic_config(database_url: str) -> Config: config = Config("alembic_app.ini") config.set_main_option("sqlalchemy.url", database_url) return config # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def totp_db(tmp_path: Path): """Temporary SQLite DB upgraded to the current Alembic head.""" db_path = tmp_path / "totp_test.db" db_url = f"sqlite:///{db_path}" alembic_cfg = _make_app_alembic_config(db_url) command.upgrade(alembic_cfg, "head") engine = create_engine(db_url, connect_args={"check_same_thread": False}) yield engine engine.dispose() # --------------------------------------------------------------------------- # Migration shape tests # --------------------------------------------------------------------------- def test_auth_users_has_totp_columns(totp_db): """After upgrade to head, auth_users must have totp_secret and totp_enabled columns.""" inspector = inspect(totp_db) columns = {col["name"]: col for col in inspector.get_columns("auth_users")} assert "totp_secret" in columns, "totp_secret column missing from auth_users" assert "totp_enabled" in columns, "totp_enabled column missing from auth_users" # totp_secret must be nullable assert columns["totp_secret"]["nullable"], "totp_secret should be nullable" # totp_enabled must be NOT NULL assert not columns["totp_enabled"]["nullable"], "totp_enabled should be NOT NULL" def test_auth_recovery_code_table_exists(totp_db): """auth_recovery_code table must exist after upgrade to head.""" inspector = inspect(totp_db) assert "auth_recovery_code" in inspector.get_table_names() def test_auth_recovery_code_has_expected_columns(totp_db): """auth_recovery_code must have id, user_id, code_hash, used_at with correct nullability.""" inspector = inspect(totp_db) columns = {col["name"]: col for col in inspector.get_columns("auth_recovery_code")} expected_non_nullable = {"id", "user_id", "code_hash"} expected_nullable = {"used_at"} for col_name in expected_non_nullable: assert col_name in columns, f"Missing column: {col_name}" assert not columns[col_name]["nullable"], f"Column {col_name} should be NOT NULL" for col_name in expected_nullable: assert col_name in columns, f"Missing column: {col_name}" assert columns[col_name]["nullable"], f"Column {col_name} should be nullable" def test_auth_recovery_code_has_fk_to_auth_users(totp_db): """auth_recovery_code.user_id must have a FK constraint pointing to auth_users.id.""" inspector = inspect(totp_db) fks = inspector.get_foreign_keys("auth_recovery_code") assert len(fks) == 1, f"Expected 1 FK on auth_recovery_code, got {len(fks)}" fk = fks[0] assert fk["referred_table"] == "auth_users" assert "user_id" in fk["constrained_columns"] assert "id" in fk["referred_columns"] def test_auth_recovery_code_user_id_index_exists(totp_db): """ix_auth_recovery_code_user_id index must exist.""" inspector = inspect(totp_db) indexes = inspector.get_indexes("auth_recovery_code") index_names = {idx["name"] for idx in indexes} assert "ix_auth_recovery_code_user_id" in index_names # --------------------------------------------------------------------------- # Default value tests # --------------------------------------------------------------------------- def test_totp_enabled_defaults_to_false(totp_db): """A newly inserted AuthUser must have totp_enabled=False and totp_secret=None.""" now = datetime.now(tz=timezone.utc) with Session(totp_db) as session: user = AuthUser( username="testuser", password_hash="$argon2id$v=19$m=65536,t=3,p=4$fakehash", is_active=True, force_password_change=False, created_at=now, ) session.add(user) session.commit() user_id = user.id with Session(totp_db) as session: fetched = session.get(AuthUser, user_id) assert fetched is not None assert fetched.totp_enabled is False, "totp_enabled should default to False" assert fetched.totp_secret is None, "totp_secret should default to None" def test_totp_secret_can_be_set(totp_db): """totp_secret can be set to a base32 string and retrieved correctly.""" now = datetime.now(tz=timezone.utc) secret = "JBSWY3DPEHPK3PXP" with Session(totp_db) as session: user = AuthUser( username="totp_user", password_hash="$argon2id$v=19$m=65536,t=3,p=4$fakehash", is_active=True, force_password_change=False, created_at=now, totp_secret=secret, totp_enabled=True, ) session.add(user) session.commit() user_id = user.id with Session(totp_db) as session: fetched = session.get(AuthUser, user_id) assert fetched is not None assert fetched.totp_secret == secret assert fetched.totp_enabled is True def test_recovery_code_insert_and_used_at_nullable(totp_db): """A RecoveryCode can be inserted with used_at=None and later marked consumed.""" now = datetime.now(tz=timezone.utc) with Session(totp_db) as session: user = AuthUser( username="rc_user", password_hash="$argon2id$v=19$m=65536,t=3,p=4$fakehash", is_active=True, force_password_change=False, created_at=now, ) session.add(user) session.flush() code = RecoveryCode( user_id=user.id, code_hash="$argon2id$v=19$m=65536,t=3,p=4$rcfakehash", used_at=None, ) session.add(code) session.commit() code_id = code.id with Session(totp_db) as session: fetched = session.get(RecoveryCode, code_id) assert fetched is not None assert fetched.used_at is None # Mark as consumed fetched.used_at = now session.commit() with Session(totp_db) as session: fetched = session.get(RecoveryCode, code_id) assert fetched is not None assert fetched.used_at is not None # --------------------------------------------------------------------------- # Existing user migration test (not-break-login guarantee) # --------------------------------------------------------------------------- def test_existing_user_defaults_to_totp_disabled_after_upgrade(tmp_path: Path): """An existing user row inserted before the TOTP migration must have totp_enabled=0 after upgrade. Simulates the real-world scenario: a pre-existing row in auth_users gets migrated to the TOTP revision and the server_default ensures totp_enabled=false (0) for it. """ db_path = tmp_path / "existing_user_test.db" db_url = f"sqlite:///{db_path}" alembic_cfg = _make_app_alembic_config(db_url) # Upgrade to the TOTP revision's predecessor (the throttle revision). command.upgrade(alembic_cfg, "20260621_07_auth_login_throttle") # Insert a user row directly (simulates a user that existed before the TOTP migration). engine = create_engine(db_url, connect_args={"check_same_thread": False}) with engine.connect() as conn: conn.execute( text( "INSERT INTO auth_users (username, password_hash, is_active, force_password_change, created_at)" " VALUES ('legacy_admin', 'hash', 1, 0, '2026-01-01T00:00:00+00:00')" ) ) conn.commit() row = conn.execute( text("SELECT id FROM auth_users WHERE username = 'legacy_admin'") ).fetchone() legacy_user_id = row[0] engine.dispose() # Now upgrade to head (applies the TOTP migration). command.upgrade(alembic_cfg, "head") engine = create_engine(db_url, connect_args={"check_same_thread": False}) with engine.connect() as conn: row = conn.execute( text( "SELECT totp_enabled, totp_secret FROM auth_users WHERE id = :uid" ), {"uid": legacy_user_id}, ).fetchone() engine.dispose() assert row is not None # totp_enabled should be 0 (false) for the legacy row — server_default="0" ensures this. assert row[0] == 0, f"Legacy user should have totp_enabled=0 after migration, got {row[0]}" # totp_secret should be NULL for the legacy row. assert row[1] is None, f"Legacy user should have totp_secret=NULL after migration, got {row[1]}" # --------------------------------------------------------------------------- # Downgrade / upgrade reversibility tests # --------------------------------------------------------------------------- def test_downgrade_removes_totp_columns_and_table(tmp_path: Path): """downgrade -1 from head must drop auth_recovery_code and remove TOTP columns from auth_users.""" db_path = tmp_path / "down_totp_test.db" db_url = f"sqlite:///{db_path}" alembic_cfg = _make_app_alembic_config(db_url) command.upgrade(alembic_cfg, "head") engine = create_engine(db_url, connect_args={"check_same_thread": False}) inspector = inspect(engine) assert "auth_recovery_code" in inspector.get_table_names() auth_user_cols = {col["name"] for col in inspector.get_columns("auth_users")} assert "totp_secret" in auth_user_cols assert "totp_enabled" in auth_user_cols engine.dispose() # Downgrade to the revision before TOTP (removes TOTP migration). # We use an explicit target rather than "-1" so the test stays correct even # as new revisions are added on top of the TOTP revision. command.downgrade(alembic_cfg, "20260621_07_auth_login_throttle") engine = create_engine(db_url, connect_args={"check_same_thread": False}) inspector = inspect(engine) assert "auth_recovery_code" not in inspector.get_table_names(), ( "auth_recovery_code table should be gone after downgrade" ) auth_user_cols_after = {col["name"] for col in inspector.get_columns("auth_users")} assert "totp_secret" not in auth_user_cols_after, ( "totp_secret column should be removed after downgrade" ) assert "totp_enabled" not in auth_user_cols_after, ( "totp_enabled column should be removed after downgrade" ) engine.dispose()