015c622016
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.
490 lines
21 KiB
Python
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}"
|
|
)
|