"""TOTP MFA workflow integration tests. Covers TOTP enrollment, verification during login, backup-code usage, and management (disable, regenerate). Every test includes a clear description of WHAT is tested, WHY it matters, and the EXPECTED result. """ import pytest import uuid import pyotp from tests.integration.client.base import ApiError # ============================================================================= # Helper assertions (mirrored from test_auth_flows for independence) # ============================================================================= def assert_success(response: dict, message_contains: str = "") -> dict: """Assert that an api_response-wrapped payload succeeded.""" data = response.get("data", {}) assert response.get("success") is not False, ( f"Expected success but got error: {response.get('message')}" ) if message_contains: assert message_contains.lower() in response.get("message", "").lower(), ( f"Expected message to contain '{message_contains}' but got: {response.get('message')}" ) return data def assert_error(exc: ApiError, expected_status: int, expected_error_type: str | None = None): """Inspect an ApiError raised by the client.""" assert exc.status_code == expected_status, ( f"Expected status {expected_status} but got {exc.status_code}\n" f"URL: {exc.method} {exc.url}\n" f"Response: {exc.response_data}" ) if expected_error_type: assert exc.error_type == expected_error_type, ( f"Expected error_type '{expected_error_type}' but got '{exc.error_type}'" ) # ============================================================================= # Tier 5 — L. TOTP Enrollment & Verification # ============================================================================= class TestTOTPEnrollment: """Test TOTP enrollment at POST /auth/totp/enroll and POST /auth/totp/verify-enrollment. TOTP is the primary MFA method for users without hardware passkeys. These tests ensure that enrollment generates valid secrets, duplicate enrollment is blocked, and verification completes the setup. """ def test_enroll_totp_positive(self, integration_client, create_test_user): """TEST: TOTP-01 — Enroll TOTP for a user. WHAT: Create a user, login, then POST /auth/totp/enroll. WHY: Enrollment must return a secret, provisioning URI, QR code, and backup codes so the user can configure their authenticator app. EXPECTED: 201 Created with secret, provisioning_uri, qr_code, and backup_codes array (length 10). """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") result = integration_client.mfa.enroll_totp() data = assert_success(result, "enrollment initiated") assert "secret" in data, "TOTP enrollment missing 'secret'" assert "provisioning_uri" in data, "TOTP enrollment missing 'provisioning_uri'" assert "qr_code" in data, "TOTP enrollment missing 'qr_code'" assert "backup_codes" in data, "TOTP enrollment missing 'backup_codes'" assert len(data["backup_codes"]) == 10, ( f"Expected 10 backup codes, got {len(data['backup_codes'])}" ) def test_enroll_totp_already_enrolled_negative(self, integration_client, create_test_user): """TEST: TOTP-02 — Reject duplicate TOTP enrollment. WHAT: Enroll TOTP, verify enrollment, then attempt to enroll again. WHY: Only one active TOTP secret should exist per user. Re-enrolling could lock the user out if they haven't updated their authenticator app. EXPECTED: 409 Conflict, error_type="CONFLICT". """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") # First enrollment enroll = integration_client.mfa.enroll_totp() data = assert_success(enroll) secret = data["secret"] # Verify enrollment totp = pyotp.TOTP(secret) code = totp.now() integration_client.mfa.verify_enrollment(code) # Second enrollment should fail with pytest.raises(ApiError) as exc_info: integration_client.mfa.enroll_totp() assert_error(exc_info.value, 409, "CONFLICT") def test_verify_enrollment_positive(self, integration_client, create_test_user): """TEST: TOTP-03 — Verify TOTP enrollment with a valid code. WHAT: Enroll TOTP, generate a code with pyotp, then POST /auth/totp/verify-enrollment. WHY: Verification proves the user has configured their authenticator correctly and can generate codes. EXPECTED: 200 OK, subsequent GET /auth/totp/status returns totp_enabled=True. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") enroll = integration_client.mfa.enroll_totp() data = assert_success(enroll) secret = data["secret"] totp = pyotp.TOTP(secret) code = totp.now() result = integration_client.mfa.verify_enrollment(code) assert_success(result, "enrollment completed") # Confirm status status = integration_client.mfa.get_totp_status() status_data = assert_success(status, "status retrieved") assert status_data.get("totp_enabled") is True, ( f"Expected totp_enabled=True after verification, got {status_data}" ) def test_verify_enrollment_invalid_code_negative(self, integration_client, create_test_user): """TEST: TOTP-04 — Reject enrollment verification with invalid code. WHAT: Enroll TOTP, then send an intentionally wrong 6-digit code to /auth/totp/verify-enrollment. WHY: We must not mark TOTP as enabled if the user cannot prove they have the secret. EXPECTED: 401 Unauthorized (or 400), indicating the code is incorrect. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") enroll = integration_client.mfa.enroll_totp() assert_success(enroll) with pytest.raises(ApiError) as exc_info: integration_client.mfa.verify_enrollment("000000") assert exc_info.value.status_code in (400, 401), ( f"Expected 400/401 for invalid TOTP code, got {exc_info.value.status_code}" ) class TestTOTPLogin: """Test TOTP verification during the login flow at POST /auth/totp/verify. When a user has TOTP enabled, the first login step returns ``requires_totp=True`` and stores a pending user id in the server session. The second step verifies the TOTP code and issues the real session token. """ def test_login_with_totp_positive(self, integration_client, create_test_user): """TEST: TOTP-05 — Complete login with TOTP. WHAT: Create a user, enroll and verify TOTP, logout, then login again and complete the TOTP verification step. WHY: This is the exact flow a user experiences every time they authenticate with MFA enabled. EXPECTED: Login step 1 returns requires_totp=True. Step 2 returns 200 OK with a fresh token. GET /auth/me succeeds with the new token. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") # Enroll and verify TOTP enroll = integration_client.mfa.enroll_totp() secret = assert_success(enroll)["secret"] totp = pyotp.TOTP(secret) integration_client.mfa.verify_enrollment(totp.now()) # Logout integration_client.auth.logout() # Step 1: login → requires_totp login_result = integration_client.auth.login( email=user["email"], password="MyPassword123!" ) login_data = login_result.get("data", {}) assert login_data.get("requires_totp") is True, ( f"Expected requires_totp=True, got: {login_data}" ) # Step 2: verify TOTP → full session verify_result = integration_client.mfa.verify_totp(totp.now()) verify_data = assert_success(verify_result, "verification successful") assert "token" in verify_data, "TOTP verification did not return a token" # Confirm session is valid me = integration_client.auth.me() assert_success(me) def test_verify_totp_wrong_code_negative(self, integration_client, create_test_user): """TEST: TOTP-06 — Reject TOTP login with wrong code. WHAT: Create a user with TOTP enabled, initiate login, then send an incorrect 6-digit code. WHY: Brute-force protection is essential; wrong codes must be rejected without issuing a session. EXPECTED: 401 Unauthorized (or 400), error_type indicating invalid credentials. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") enroll = integration_client.mfa.enroll_totp() secret = assert_success(enroll)["secret"] integration_client.mfa.verify_enrollment(pyotp.TOTP(secret).now()) integration_client.auth.logout() # Initiate login integration_client.auth.login(email=user["email"], password="MyPassword123!") # Wrong code with pytest.raises(ApiError) as exc_info: integration_client.mfa.verify_totp("000000") assert exc_info.value.status_code in (400, 401), ( f"Expected 400/401 for wrong TOTP, got {exc_info.value.status_code}" ) def test_verify_totp_no_pending_session_negative(self, integration_client): """TEST: TOTP-07 — Reject TOTP verification without pending login. WHAT: Call POST /auth/totp/verify without first calling POST /auth/login. WHY: The TOTP verify endpoint depends on server-side session state (totp_pending_user_id). Without it the request is meaningless. EXPECTED: 401 Unauthorized, message indicating no pending verification session. """ integration_client.clear_token() with pytest.raises(ApiError) as exc_info: integration_client.mfa.verify_totp("123456") assert exc_info.value.status_code == 401, ( f"Expected 401 for missing pending session, got {exc_info.value.status_code}" ) class TestTOTPBackupCodes: """Test backup code usage during TOTP login. Backup codes allow users to regain access when they lose their authenticator device. Each code can only be used once. """ def test_login_with_backup_code_positive(self, integration_client, create_test_user): """TEST: TOTP-08 — Login using a backup code. WHAT: Create a user, enroll TOTP, logout, initiate login, then complete verification with ``is_backup_code=True`` and one of the backup codes. WHY: Backup codes are the recovery path for lost devices. They must work exactly once and issue a full session. EXPECTED: 200 OK with token. Subsequent login with the same backup code must fail. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") enroll = integration_client.mfa.enroll_totp() data = assert_success(enroll) backup_codes = data["backup_codes"] integration_client.mfa.verify_enrollment(pyotp.TOTP(data["secret"]).now()) integration_client.auth.logout() # Initiate login integration_client.auth.login(email=user["email"], password="MyPassword123!") # Use backup code result = integration_client.mfa.verify_totp( backup_codes[0], is_backup_code=True ) verify_data = assert_success(result, "verification successful") assert "token" in verify_data, "Backup code login did not return token" def test_login_with_consumed_backup_code_negative(self, integration_client, create_test_user): """TEST: TOTP-09 — Reject reuse of a consumed backup code. WHAT: Use a backup code to login, logout, initiate login again, then attempt to use the same backup code. WHY: Backup codes are single-use. Reuse must be blocked to prevent credential stuffing. EXPECTED: 401 Unauthorized, indicating invalid credentials. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") enroll = integration_client.mfa.enroll_totp() data = assert_success(enroll) backup_codes = data["backup_codes"] integration_client.mfa.verify_enrollment(pyotp.TOTP(data["secret"]).now()) integration_client.auth.logout() # First use integration_client.auth.login(email=user["email"], password="MyPassword123!") integration_client.mfa.verify_totp(backup_codes[0], is_backup_code=True) integration_client.auth.logout() # Reuse attempt integration_client.auth.login(email=user["email"], password="MyPassword123!") with pytest.raises(ApiError) as exc_info: integration_client.mfa.verify_totp(backup_codes[0], is_backup_code=True) assert exc_info.value.status_code in (400, 401), ( f"Expected 400/401 for reused backup code, got {exc_info.value.status_code}" ) # ============================================================================= # Tier 5 — M. TOTP Management # ============================================================================= class TestTOTPManagement: """Test TOTP status, disable, and backup-code regeneration.""" def test_get_totp_status_positive(self, integration_client, create_test_user): """TEST: TOTP-10 — Get TOTP status for enrolled user. WHAT: Create a user, enroll and verify TOTP, then call GET /auth/totp/status. WHY: The frontend security page displays this status so users know whether MFA is active. EXPECTED: 200 OK with totp_enabled=True and backup_codes_remaining > 0. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") enroll = integration_client.mfa.enroll_totp() data = assert_success(enroll) integration_client.mfa.verify_enrollment(pyotp.TOTP(data["secret"]).now()) status = integration_client.mfa.get_totp_status() status_data = assert_success(status, "status retrieved") assert status_data.get("totp_enabled") is True assert status_data.get("backup_codes_remaining", 0) > 0 def test_disable_totp_positive(self, integration_client, create_test_user): """TEST: TOTP-11 — Disable TOTP with correct password. WHAT: Create a user, enroll and verify TOTP, then DELETE /auth/totp/disable with the correct password. WHY: Users may need to disable MFA when switching devices. The API must require the current password to prevent account takeover. EXPECTED: 200 OK, subsequent GET /auth/totp/status returns totp_enabled=False. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") enroll = integration_client.mfa.enroll_totp() data = assert_success(enroll) integration_client.mfa.verify_enrollment(pyotp.TOTP(data["secret"]).now()) result = integration_client.mfa.disable_totp("MyPassword123!") assert_success(result, "disabled") status = integration_client.mfa.get_totp_status() status_data = assert_success(status) assert status_data.get("totp_enabled") is False, ( f"Expected totp_enabled=False after disable, got {status_data}" ) def test_disable_totp_wrong_password_negative(self, integration_client, create_test_user): """TEST: TOTP-12 — Reject TOTP disable with wrong password. WHAT: Create a user with TOTP enabled, then attempt to disable it with an incorrect password. WHY: Disabling MFA is a sensitive operation. Wrong password must block the action. EXPECTED: 401 Unauthorized (or 400), indicating invalid credentials. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") enroll = integration_client.mfa.enroll_totp() data = assert_success(enroll) integration_client.mfa.verify_enrollment(pyotp.TOTP(data["secret"]).now()) with pytest.raises(ApiError) as exc_info: integration_client.mfa.disable_totp("WrongPassword123!") assert exc_info.value.status_code in (400, 401), ( f"Expected 400/401 for wrong password, got {exc_info.value.status_code}" ) def test_disable_totp_not_enrolled_negative(self, integration_client, create_test_user): """TEST: TOTP-13 — Reject disabling TOTP when not enrolled. WHAT: Create a user WITHOUT TOTP, then call DELETE /auth/totp/disable. WHY: The endpoint should handle the case gracefully rather than crashing or returning a confusing message. EXPECTED: 400 Bad Request (or 404), indicating no TOTP is configured. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") with pytest.raises(ApiError) as exc_info: integration_client.mfa.disable_totp("MyPassword123!") assert exc_info.value.status_code in (400, 401, 404), ( f"Expected 400/401/404 for non-enrolled TOTP disable, got {exc_info.value.status_code}" ) def test_regenerate_backup_codes_positive(self, integration_client, create_test_user): """TEST: TOTP-14 — Regenerate backup codes. WHAT: Create a user, enroll and verify TOTP, then POST /auth/totp/regenerate-backup-codes with the correct password. WHY: Users may lose their backup codes. Regeneration must invalidate old codes and return a fresh set of 10. EXPECTED: 200 OK with a new array of 10 backup codes. Old codes must no longer work. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") enroll = integration_client.mfa.enroll_totp() data = assert_success(enroll) old_codes = data["backup_codes"] integration_client.mfa.verify_enrollment(pyotp.TOTP(data["secret"]).now()) result = integration_client.mfa.regenerate_backup_codes("MyPassword123!") result_data = assert_success(result, "regenerated") new_codes = result_data["backup_codes"] assert len(new_codes) == 10, f"Expected 10 backup codes, got {len(new_codes)}" assert new_codes != old_codes, "New backup codes should differ from old codes" # Verify old codes no longer work integration_client.auth.logout() integration_client.auth.login(email=user["email"], password="MyPassword123!") with pytest.raises(ApiError) as exc_info: integration_client.mfa.verify_totp(old_codes[0], is_backup_code=True) assert exc_info.value.status_code in (400, 401) def test_regenerate_backup_codes_wrong_password_negative(self, integration_client, create_test_user): """TEST: TOTP-15 — Reject backup-code regeneration with wrong password. WHAT: Create a user with TOTP enabled, then attempt to regenerate backup codes with an incorrect password. WHY: Same rationale as TOTP-12 — this is a sensitive operation protected by the current password. EXPECTED: 401 Unauthorized (or 400). """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") enroll = integration_client.mfa.enroll_totp() data = assert_success(enroll) integration_client.mfa.verify_enrollment(pyotp.TOTP(data["secret"]).now()) with pytest.raises(ApiError) as exc_info: integration_client.mfa.regenerate_backup_codes("WrongPassword123!") assert exc_info.value.status_code in (400, 401), ( f"Expected 400/401 for wrong password, got {exc_info.value.status_code}" )