"""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/. 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")