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.
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user