Files
gatehouse-api/tests/integration/test_totp_workflows.py
T
nexgen_mirrors 015c622016 test: add comprehensive integration test suite for IAM platform
Add 162 integration tests covering authentication flows, TOTP MFA,
SSH key/certificate management, organization workflows, multi-org
access, self-service features, admin operations, authorization,
security edge cases, department/principal management, CA management,
policy compliance, WebAuthn passkeys, and ZeroTier network access.

Includes:
- Reusable API client library with session management
- Test fixtures for users, organizations, memberships, and CAs
- Helper functions for SSH key generation and verification
- Documentation for running and writing tests

Also update test configuration to disable conflicting maas plugins
and configure WebAuthn/session settings for localhost testing.
2026-04-23 15:41:37 +09:30

490 lines
21 KiB
Python

"""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}"
)