Files
gatehouse-api/tests/integration/test_auth_flows.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

591 lines
26 KiB
Python

"""Authentication flow integration tests.
Covers user registration, login, logout, sessions, and password
recovery. Every test prints a clear description of WHAT is being
tested, WHY it matters, and the EXPECTED result so failures are
actionable.
"""
import pytest
import uuid
from tests.integration.client.base import ApiError
# =============================================================================
# Helper assertions
# =============================================================================
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(response_or_exc, expected_status: int, expected_error_type: str | None = None):
"""Assert that an ApiError carries the expected status (and optionally error_type).
Because our client raises on >=400, we catch ApiError and inspect it.
"""
assert isinstance(response_or_exc, ApiError), (
f"Expected ApiError but got: {type(response_or_exc).__name__}{response_or_exc}"
)
assert response_or_exc.status_code == expected_status, (
f"Expected status {expected_status} but got {response_or_exc.status_code}\n"
f"URL: {response_or_exc.method} {response_or_exc.url}\n"
f"Response: {response_or_exc.response_data}"
)
if expected_error_type:
assert response_or_exc.error_type == expected_error_type, (
f"Expected error_type '{expected_error_type}' but got '{response_or_exc.error_type}'"
)
# =============================================================================
# Tier 2 — E. User Registration & Login
# =============================================================================
class TestRegistration:
"""Test user registration at POST /auth/register.
Registration is the front door of the application. These tests
ensure that valid users can sign up, duplicate accounts are
rejected, and weak passwords are blocked.
"""
def test_register_user_positive(self, integration_client):
"""TEST: AUTH-01 — Register a new user with valid data.
WHAT: Call POST /auth/register with a unique email, strong
password, and full name.
WHY: This is the primary on-ramp for every user. It must
create the user, return a session token, and flag the
account as the first user when appropriate.
EXPECTED: 201 Created, response contains user object, token,
expires_at, and is_first_user=True (since this is
the first user in the fresh test DB).
"""
email = f"auth01_{uuid.uuid4().hex[:8]}@example.com"
result = integration_client.auth.register(
email=email,
password="StrongPass123!",
full_name="Auth One",
)
data = assert_success(result, "registration successful")
assert "user" in data, "Response missing 'user' object"
assert data["user"]["email"] == email
assert "token" in data, "Response missing 'token' — session not created"
assert "expires_at" in data, "Response missing 'expires_at'"
assert data.get("is_first_user") is True, "First user should have is_first_user=True"
def test_register_duplicate_email_negative(self, integration_client):
"""TEST: AUTH-02 — Reject registration with a duplicate email.
WHAT: Register a user, then attempt to register again with
the same email address.
WHY: Duplicate accounts would break email-based lookups,
password reset flows, and invite acceptance.
EXPECTED: 400 Bad Request, error_type="VALIDATION_ERROR".
"""
email = f"auth02_{uuid.uuid4().hex[:8]}@example.com"
integration_client.auth.register(email=email, password="StrongPass123!", full_name="First")
with pytest.raises(ApiError) as exc_info:
integration_client.auth.register(email=email, password="DifferentPass123!", full_name="Second")
assert_error(exc_info.value, 409, "CONFLICT")
def test_register_weak_password_negative(self, integration_client):
"""TEST: AUTH-03 — Reject registration with a weak password.
WHAT: Attempt to register with a password shorter than 8
characters.
WHY: Weak passwords are the #1 cause of account takeovers.
The API must enforce a minimum length.
EXPECTED: 400 Bad Request, error_type="VALIDATION_ERROR".
"""
email = f"auth03_{uuid.uuid4().hex[:8]}@example.com"
with pytest.raises(ApiError) as exc_info:
integration_client.auth.register(email=email, password="short", full_name="Weak")
assert_error(exc_info.value, 400, "VALIDATION_ERROR")
def test_register_missing_fields_negative(self, integration_client):
"""TEST: AUTH-04 — Reject registration with missing required fields.
WHAT: Send a POST /auth/register payload without the email
and password fields.
WHY: The schema must validate presence of required fields
before touching the database.
EXPECTED: 400 Bad Request, error_type="VALIDATION_ERROR".
"""
with pytest.raises(ApiError) as exc_info:
integration_client.post("/auth/register", data={})
assert_error(exc_info.value, 400, "VALIDATION_ERROR")
class TestLogin:
"""Test user login at POST /auth/login.
Login is the most frequently used endpoint. These tests verify
that valid credentials issue a session, invalid credentials are
rejected without leaking existence, and suspended accounts are
blocked.
"""
def test_login_positive(self, integration_client, create_test_user):
"""TEST: AUTH-05 — Login with valid credentials.
WHAT: Create a user via factory, then call POST /auth/login
with the correct email and password.
WHY: This is the core authentication flow. A successful
login must issue a session token and return user data.
EXPECTED: 200 OK, response contains user object, token, and
expires_at. Subsequent GET /auth/me must succeed.
"""
user = create_test_user(password="MyPassword123!")
result = integration_client.auth.login(email=user["email"], password="MyPassword123!")
data = assert_success(result, "login successful")
assert "token" in data, "Login response missing token"
assert data["user"]["email"] == user["email"]
# Verify the token actually works
me_result = integration_client.auth.me()
me_data = assert_success(me_result)
assert me_data["user"]["email"] == user["email"]
def test_login_wrong_password_negative(self, integration_client, create_test_user):
"""TEST: AUTH-06 — Reject login with wrong password.
WHAT: Create a user, then attempt login with an incorrect
password.
WHY: We must not leak whether the email exists. The
response for wrong-password and non-existent-user
should be identical.
EXPECTED: 400 Bad Request (or 401) with a generic failure
message.
"""
user = create_test_user(password="CorrectPass123!")
with pytest.raises(ApiError) as exc_info:
integration_client.auth.login(email=user["email"], password="wrongpassword")
assert exc_info.value.status_code in (400, 401), (
f"Expected 400 or 401 for wrong password, got {exc_info.value.status_code}"
)
def test_login_nonexistent_user_negative(self, integration_client):
"""TEST: AUTH-07 — Reject login for non-existent user.
WHAT: Attempt to login with an email that has never been
registered.
WHY: Same as AUTH-06 — user enumeration must be prevented.
EXPECTED: Identical response to wrong-password (400/401).
"""
with pytest.raises(ApiError) as exc_info:
integration_client.auth.login(
email=f"doesnotexist_{uuid.uuid4().hex[:8]}@example.com",
password="SomePassword123!",
)
assert exc_info.value.status_code in (400, 401), (
f"Expected 400 or 401 for non-existent user, got {exc_info.value.status_code}"
)
def test_login_suspended_user_negative(self, integration_app, integration_client, create_test_user):
"""TEST: AUTH-08 — Reject login for suspended account.
WHAT: Create a user, suspend the account by setting
user.status = SUSPENDED, then attempt login.
WHY: Admin suspension is a critical security tool. A
suspended user must not be able to obtain a session.
EXPECTED: 403 Forbidden, error_type="ACCOUNT_SUSPENDED".
"""
from gatehouse_app.utils.constants import UserStatus
user_info = create_test_user(password="MyPassword123!")
# Suspend the user directly in the DB
with integration_app.app_context():
from gatehouse_app.models.user.user import User
user = User.query.get(user_info["id"])
user.status = UserStatus.SUSPENDED
from gatehouse_app.extensions import db
db.session.commit()
with pytest.raises(ApiError) as exc_info:
integration_client.auth.login(email=user_info["email"], password="MyPassword123!")
assert_error(exc_info.value, 403, "AUTHORIZATION_ERROR")
class TestLogoutAndSessions:
"""Test logout and session management.
Sessions are the mechanism that keeps users authenticated across
requests. These tests verify that logout destroys the session and
that users can list and revoke their active sessions.
"""
def test_logout_positive(self, integration_client, create_test_user):
"""TEST: AUTH-09 — Logout an authenticated user.
WHAT: Login, verify /auth/me works, call /auth/logout, then
verify /auth/me returns 401.
WHY: Logout must invalidate the token so it cannot be reused
for protected endpoints.
EXPECTED: 200 OK on logout, then 401 on subsequent me call.
"""
user = create_test_user(password="MyPassword123!")
integration_client.auth.login(email=user["email"], password="MyPassword123!")
# Confirm we're authenticated
me = integration_client.auth.me()
assert_success(me)
# Logout
result = integration_client.auth.logout()
assert_success(result, "logout successful")
# Token should no longer work
with pytest.raises(ApiError) as exc_info:
integration_client.auth.me()
assert exc_info.value.status_code == 401, (
f"Expected 401 after logout, got {exc_info.value.status_code}"
)
def test_logout_without_auth_negative(self, integration_client):
"""TEST: AUTH-10 — Reject logout when not authenticated.
WHAT: Call POST /auth/logout without a Bearer token.
WHY: The endpoint is protected by @login_required; an
unauthenticated request must be rejected.
EXPECTED: 401 Unauthorized.
"""
integration_client.clear_token()
with pytest.raises(ApiError) as exc_info:
integration_client.auth.logout()
assert exc_info.value.status_code == 401, (
f"Expected 401 for unauthenticated logout, got {exc_info.value.status_code}"
)
def test_list_sessions_positive(self, integration_client, create_test_user):
"""TEST: AUTH-11 — List active sessions.
WHAT: Login and request GET /auth/sessions.
WHY: Users need visibility into where they are logged in for
security hygiene.
EXPECTED: 200 OK with a list containing at least the current
session.
"""
user = create_test_user(password="MyPassword123!")
integration_client.auth.login(email=user["email"], password="MyPassword123!")
result = integration_client.auth.list_sessions()
data = assert_success(result, "sessions retrieved")
sessions = data.get("sessions", [])
assert len(sessions) >= 1, "Expected at least one active session"
assert "id" in sessions[0], "Session object missing 'id'"
def test_revoke_session_positive(self, integration_client, create_test_user):
"""TEST: AUTH-12 — Revoke an active session.
WHAT: Login, list sessions, revoke the first session, then
verify the token no longer works.
WHY: Session revocation allows users to remotely sign out
devices they no longer control.
EXPECTED: 200 OK on revocation, then 401 on subsequent me call.
"""
user = create_test_user(password="MyPassword123!")
integration_client.auth.login(email=user["email"], password="MyPassword123!")
sessions = integration_client.auth.list_sessions()
session_id = sessions["data"]["sessions"][0]["id"]
result = integration_client.auth.revoke_session(session_id)
assert_success(result, "session revoked")
# Token should be invalid now
with pytest.raises(ApiError) as exc_info:
integration_client.auth.me()
assert exc_info.value.status_code == 401, (
f"Expected 401 after revoking session, got {exc_info.value.status_code}"
)
def test_revoke_nonexistent_session_negative(self, integration_client, create_test_user):
"""TEST: AUTH-13 — Reject revoking a non-existent session.
WHAT: Login and attempt to DELETE /auth/sessions/<fake-id>.
WHY: The API must distinguish between "not found" and
"forbidden" so clients can show correct error states.
EXPECTED: 404 Not Found.
"""
user = create_test_user(password="MyPassword123!")
integration_client.auth.login(email=user["email"], password="MyPassword123!")
with pytest.raises(ApiError) as exc_info:
integration_client.auth.revoke_session("00000000-0000-0000-0000-000000000000")
assert exc_info.value.status_code == 404, (
f"Expected 404 for non-existent session, got {exc_info.value.status_code}"
)
class TestCurrentUser:
"""Test the /auth/me endpoint."""
def test_get_current_user_positive(self, integration_client, create_test_user):
"""TEST: AUTH-14 — Get current user when authenticated.
WHAT: Login and call GET /auth/me.
WHY: The frontend uses this endpoint on every page load to
determine login state and populate the user menu.
EXPECTED: 200 OK with user object and organizations list.
"""
user = create_test_user(password="MyPassword123!")
integration_client.auth.login(email=user["email"], password="MyPassword123!")
result = integration_client.auth.me()
data = assert_success(result, "user retrieved")
assert data["user"]["email"] == user["email"]
assert "organizations" in data, "Response missing 'organizations' list"
def test_get_current_user_without_auth_negative(self, integration_client):
"""TEST: AUTH-15 — Reject /auth/me without authentication.
WHAT: Call GET /auth/me with no Bearer token.
WHY: Protected endpoints must reject unauthenticated requests
to prevent data leakage.
EXPECTED: 401 Unauthorized.
"""
integration_client.clear_token()
with pytest.raises(ApiError) as exc_info:
integration_client.auth.me()
assert exc_info.value.status_code == 401, (
f"Expected 401 for unauthenticated /auth/me, got {exc_info.value.status_code}"
)
class TestPasswordRecovery:
"""Test password reset flow at POST /auth/forgot-password and
POST /auth/reset-password.
These endpoints allow users to regain access when they forget their
password. Security requirements: the forgot-password endpoint must
not leak whether an email exists, and tokens must be single-use.
"""
def test_forgot_password_positive(self, integration_app, integration_client, create_test_user):
"""TEST: AUTH-20 — Request password reset for existing email.
WHAT: Create a user, then POST /auth/forgot-password with
the user's email.
WHY: This is the entry point for password recovery. It must
succeed silently and generate a token in the DB.
EXPECTED: 200 OK with a generic success message. A
PasswordResetToken should exist in the database.
"""
user = create_test_user(password="OldPass123!")
result = integration_client.auth.forgot_password(user["email"])
data = assert_success(result, "you will receive")
# Verify token was created in DB
from gatehouse_app.models.auth.password_reset_token import PasswordResetToken
from gatehouse_app.extensions import db
from gatehouse_app.models.user.user import User
with integration_app.app_context():
db_user = User.query.filter_by(email=user["email"]).first()
token = PasswordResetToken.query.filter_by(user_id=db_user.id, used_at=None).first()
assert token is not None, "Password reset token was not created"
def test_forgot_password_nonexistent_email_positive(self, integration_client):
"""TEST: AUTH-21 — Request password reset for non-existent email.
WHAT: POST /auth/forgot-password with an email that has never
been registered.
WHY: User enumeration must be prevented. The response for
non-existent and existing emails must be identical.
EXPECTED: 200 OK with the exact same message as AUTH-20.
"""
result = integration_client.auth.forgot_password("doesnotexist@example.com")
data = assert_success(result, "you will receive")
def test_reset_password_positive(self, integration_app, integration_client, create_test_user):
"""TEST: AUTH-22 — Reset password with a valid token.
WHAT: Create a user, generate a PasswordResetToken directly in
the DB, then POST /auth/reset-password with the token
and a new password.
WHY: This is the actual password change step. It must update
the auth method hash and invalidate the token.
EXPECTED: 200 OK. Subsequent login with the NEW password must
succeed; login with the OLD password must fail.
"""
from gatehouse_app.models.auth.password_reset_token import PasswordResetToken
from gatehouse_app.extensions import db
from gatehouse_app.models.user.user import User
user = create_test_user(password="OldPass123!")
# Generate token directly in DB
with integration_app.app_context():
db_user = User.query.filter_by(email=user["email"]).first()
reset_token = PasswordResetToken.generate(user_id=db_user.id)
token_value = reset_token.token
result = integration_client.auth.reset_password(
token=token_value,
new_password="NewPass456!",
new_password_confirm="NewPass456!",
)
assert_success(result, "reset")
# Verify old password no longer works
with pytest.raises(ApiError) as exc_info:
integration_client.auth.login(email=user["email"], password="OldPass123!")
assert exc_info.value.status_code in (400, 401)
# Verify new password works
login_result = integration_client.auth.login(email=user["email"], password="NewPass456!")
assert_success(login_result, "login successful")
def test_reset_password_invalid_token_negative(self, integration_client):
"""TEST: AUTH-23 — Reject password reset with invalid/expired token.
WHAT: POST /auth/reset-password with a made-up token string.
WHY: Expired or forged tokens must not allow password changes.
EXPECTED: 400 Bad Request, error_type="INVALID_TOKEN".
"""
with pytest.raises(ApiError) as exc_info:
integration_client.auth.reset_password(
token="invalid-token-12345",
new_password="NewPass456!",
new_password_confirm="NewPass456!",
)
assert_error(exc_info.value, 400, "INVALID_TOKEN")
def test_reset_password_mismatched_passwords_negative(self, integration_app, integration_client, create_test_user):
"""TEST: AUTH-24 — Reject password reset with mismatched passwords.
WHAT: Generate a valid reset token, then submit mismatched
new_password and new_password_confirm.
WHY: Typo protection — ensures the user knows what they typed.
EXPECTED: 400 Bad Request, error_type="VALIDATION_ERROR".
"""
from gatehouse_app.models.auth.password_reset_token import PasswordResetToken
from gatehouse_app.models.user.user import User
user = create_test_user(password="OldPass123!")
with integration_app.app_context():
db_user = User.query.filter_by(email=user["email"]).first()
reset_token = PasswordResetToken.generate(user_id=db_user.id)
token_value = reset_token.token
with pytest.raises(ApiError) as exc_info:
integration_client.auth.reset_password(
token=token_value,
new_password="NewPass456!",
new_password_confirm="DifferentPass789!",
)
assert_error(exc_info.value, 400, "VALIDATION_ERROR")
def test_reset_password_weak_password_negative(self, integration_app, integration_client, create_test_user):
"""TEST: AUTH-25 — Reject password reset with weak password.
WHAT: Generate a valid reset token, then submit a password
shorter than 8 characters.
WHY: Weak passwords must be blocked even during reset.
EXPECTED: 400 Bad Request, error_type="VALIDATION_ERROR".
"""
from gatehouse_app.models.auth.password_reset_token import PasswordResetToken
from gatehouse_app.models.user.user import User
user = create_test_user(password="OldPass123!")
with integration_app.app_context():
db_user = User.query.filter_by(email=user["email"]).first()
reset_token = PasswordResetToken.generate(user_id=db_user.id)
token_value = reset_token.token
with pytest.raises(ApiError) as exc_info:
integration_client.auth.reset_password(
token=token_value,
new_password="short",
new_password_confirm="short",
)
assert_error(exc_info.value, 400, "VALIDATION_ERROR")
class TestEmailVerification:
"""Test email verification at POST /auth/verify-email and
POST /auth/resend-verification.
"""
def test_verify_email_positive(self, integration_app, integration_client, create_test_user):
"""TEST: AUTH-26 — Verify email with valid token.
WHAT: Create a user with email_verified=False, generate an
EmailVerificationToken in the DB, then POST
/auth/verify-email.
WHY: Email verification is required for some features. The
token must mark the user as verified.
EXPECTED: 200 OK. User.email_verified becomes True.
"""
from gatehouse_app.models.auth.email_verification_token import EmailVerificationToken
from gatehouse_app.extensions import db
from gatehouse_app.models.user.user import User
user = create_test_user(password="MyPassword123!", email_verified=False)
assert user["email"]
with integration_app.app_context():
db_user = User.query.filter_by(email=user["email"]).first()
verify_token = EmailVerificationToken.generate(user_id=db_user.id)
token_value = verify_token.token
result = integration_client.auth.verify_email(token=token_value)
assert_success(result, "verified")
with integration_app.app_context():
db_user = User.query.filter_by(email=user["email"]).first()
assert db_user.email_verified is True
def test_verify_email_invalid_token_negative(self, integration_client):
"""TEST: AUTH-27 — Reject email verification with invalid token.
WHAT: POST /auth/verify-email with a fabricated token.
WHY: Invalid or expired tokens must not verify emails.
EXPECTED: 400 Bad Request, error_type="INVALID_TOKEN".
"""
with pytest.raises(ApiError) as exc_info:
integration_client.auth.verify_email(token="invalid-token-12345")
assert_error(exc_info.value, 400, "INVALID_TOKEN")
def test_resend_verification_positive(self, integration_client, create_test_user):
"""TEST: AUTH-28 — Resend verification email.
WHAT: Create a user with email_verified=False, then POST
/auth/resend-verification.
WHY: Users may lose the original verification email. The
endpoint must generate a new token.
EXPECTED: 200 OK with generic success message.
"""
user = create_test_user(password="MyPassword123!", email_verified=False)
result = integration_client.auth.resend_verification(email=user["email"])
assert_success(result, "you will receive")