"""End-to-end HTTP + service-level tests for Phase 19: email verification, password reset, account lockout, and Turnstile enforcement. Each test that exercises the IP-throttle-gated endpoints uses its own synthetic client IP (`_unique_ip` + a fresh `TestClient(app, client=(ip, ...))`), rather than the shared `client` fixture's fake "testclient" peer - sharing that IP across tests previously caused a real bug (register() exhausting the resend-verification ladder and permanently banning "testclient", see auth_service.register's docstring) and every test here would risk reintroducing the same class of collision if it shared IPs. The login-lockout ladder is walked at the service level (like test_ip_throttle_service.py) with `ip_throttle_service._now` monkeypatched forward, so 12 escalating attempts take milliseconds of real test time instead of ~30 real minutes. """ from __future__ import annotations import re import uuid from datetime import UTC, datetime, timedelta import httpx import pytest import respx from fastapi.testclient import TestClient from app.core.config import get_settings from app.core.errors import AuthenticationError from app.db.base import ensure_aware_utc from app.main import app from app.models.enums import ThrottleAction from app.notifications.base import DeliveryResult from app.repositories.ip_throttle_repository import IpThrottleRepository from app.repositories.user_repository import UserRepository from app.schemas.auth import LoginRequest, RegisterRequest from app.services import auth_service, ip_throttle_service from app.services.turnstile_service import verify_turnstile def _unique_email() -> str: return f"user-{uuid.uuid4().hex[:12]}@example.com" def _unique_ip() -> str: # Randomize all three trailing octets (same convention as # test_ip_throttle_service.py) - a single-octet range only has ~250 # values, which collides often enough across a full suite run (birthday # paradox) to cause real, intermittent failures between unrelated tests # that happen to share ip_throttle_state rows. return f"10.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}" def _install_fake_smtp(monkeypatch) -> list[dict]: sent: list[dict] = [] class FakeSmtp: def __init__(self, host, port, timeout=10): pass def __enter__(self): return self def __exit__(self, *exc): return False def starttls(self): pass def login(self, username, password): pass def sendmail(self, from_addr, to_addrs, message): sent.append({"to": to_addrs, "message": message}) monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp) return sent def _extract_code(sent_emails: list[dict]) -> str: message = sent_emails[-1]["message"] match = re.search(r"code is[^\d]*(\d{6})", message) assert match, f"no 6-digit code found in most recent sent email: {message!r}" return match.group(1) # --- Email verification -------------------------------------------------- def test_verify_email_wrong_code_is_generic_failure(client: TestClient): resp = client.post( "/api/v1/auth/verify-email", json={"email": _unique_email(), "code": "000000"} ) assert resp.status_code == 401 def test_verify_email_code_guessing_throttled_after_five_attempts(monkeypatch): """A 6-digit code has only 1M possible values - without this, an attacker could brute-force it well within its 36h validity window.""" _install_fake_smtp(monkeypatch) ip = _unique_ip() with TestClient(app, client=(ip, 51234)) as c: email = _unique_email() c.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "T"}, ) for _ in range(5): resp = c.post("/api/v1/auth/verify-email", json={"email": email, "code": "000000"}) assert resp.status_code == 401 throttled = c.post("/api/v1/auth/verify-email", json={"email": email, "code": "000000"}) assert throttled.status_code == 429 # See the resend-verification test above for why this tolerates a # 1-second real-clock rounding jitter. assert throttled.json()["retry_after_seconds"] in (4, 5) def test_confirm_password_reset_code_guessing_throttled_after_five_attempts(monkeypatch): _install_fake_smtp(monkeypatch) ip = _unique_ip() with TestClient(app, client=(ip, 51234)) as c: email = _unique_email() c.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "T"}, ) c.post("/api/v1/auth/request-password-reset", json={"email": email}) for _ in range(5): resp = c.post( "/api/v1/auth/confirm-password-reset", json={"email": email, "code": "000000", "new_password": "new-horse-2"}, ) assert resp.status_code == 401 throttled = c.post( "/api/v1/auth/confirm-password-reset", json={"email": email, "code": "000000", "new_password": "new-horse-2"}, ) assert throttled.status_code == 429 assert throttled.json()["retry_after_seconds"] in (4, 5) async def test_login_blocked_until_verified_then_succeeds_after_verify_email(monkeypatch): sent = _install_fake_smtp(monkeypatch) ip = _unique_ip() with TestClient(app, client=(ip, 51234)) as c: email = _unique_email() c.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "T"}, ) # Test env auto-verifies on register (no real inbox to read from) - # flip it back off directly to exercise the actual gate. from app.db.session import get_sessionmaker session_factory = get_sessionmaker() async with session_factory() as db: user = await UserRepository(db).get_by_email(email) user.email_verified = False await db.commit() blocked = c.post("/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}) assert blocked.status_code == 401 assert "verify" in blocked.json()["detail"].lower() code = _extract_code(sent) verify_resp = c.post("/api/v1/auth/verify-email", json={"email": email, "code": code}) assert verify_resp.status_code == 204 allowed = c.post("/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}) assert allowed.status_code == 200 async def test_verify_email_old_code_invalidated_by_resend(monkeypatch): """A resend must fully supersede the prior code, not just make it harder to guess - the old one must stop working entirely.""" sent = _install_fake_smtp(monkeypatch) ip = _unique_ip() with TestClient(app, client=(ip, 51234)) as c: email = _unique_email() c.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "T"}, ) old_code = _extract_code(sent) # Test env auto-verifies on register, which would make resend() a # no-op (it only resends for a not-yet-verified account) - flip it # back off directly so a genuine second code actually gets issued. from app.db.session import get_sessionmaker session_factory = get_sessionmaker() async with session_factory() as db: user = await UserRepository(db).get_by_email(email) user.email_verified = False await db.commit() c.post("/api/v1/auth/resend-verification", json={"email": email}) new_code = _extract_code(sent) stale = c.post("/api/v1/auth/verify-email", json={"email": email, "code": old_code}) assert stale.status_code == 401 fresh = c.post("/api/v1/auth/verify-email", json={"email": email, "code": new_code}) assert fresh.status_code == 204 def test_resend_verification_throttled_immediately_after_first_click(monkeypatch): _install_fake_smtp(monkeypatch) ip = _unique_ip() with TestClient(app, client=(ip, 51234)) as c: email = _unique_email() c.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "T"}, ) first = c.post("/api/v1/auth/resend-verification", json={"email": email}) assert first.status_code == 204 second = c.post("/api/v1/auth/resend-verification", json={"email": email}) assert second.status_code == 429 # retry_after_seconds is int((next_allowed_at - now).total_seconds()), # computed against the real clock here - a few ms of real elapsed # time between the two HTTP calls can round it down from 30 to 29. retry_after = second.json()["retry_after_seconds"] assert retry_after in (29, 30) assert second.headers["retry-after"] == str(retry_after) def test_resend_verification_unknown_email_is_generic_success(monkeypatch): sent = _install_fake_smtp(monkeypatch) ip = _unique_ip() with TestClient(app, client=(ip, 51234)) as c: resp = c.post("/api/v1/auth/resend-verification", json={"email": _unique_email()}) assert resp.status_code == 204 assert sent == [] # no account -> nothing actually sent, but no enumeration signal either # --- Password reset -------------------------------------------------------- def test_password_reset_old_code_invalidated_by_new_request(monkeypatch): sent = _install_fake_smtp(monkeypatch) email = _unique_email() ip1, ip2 = _unique_ip(), _unique_ip() with TestClient(app, client=(ip1, 51234)) as c1: c1.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "T"}, ) c1.post("/api/v1/auth/request-password-reset", json={"email": email}) old_code = _extract_code(sent) with TestClient(app, client=(ip2, 51235)) as c2: c2.post("/api/v1/auth/request-password-reset", json={"email": email}) new_code = _extract_code(sent) stale = c2.post( "/api/v1/auth/confirm-password-reset", json={"email": email, "code": old_code, "new_password": "new-horse-2"}, ) assert stale.status_code == 401 fresh = c2.post( "/api/v1/auth/confirm-password-reset", json={"email": email, "code": new_code, "new_password": "new-horse-2"}, ) assert fresh.status_code == 204 def test_password_reset_rejects_reusing_current_password(monkeypatch): sent = _install_fake_smtp(monkeypatch) ip = _unique_ip() with TestClient(app, client=(ip, 51234)) as c: email = _unique_email() c.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "T"}, ) c.post("/api/v1/auth/request-password-reset", json={"email": email}) code = _extract_code(sent) rejected = c.post( "/api/v1/auth/confirm-password-reset", json={"email": email, "code": code, "new_password": "correct-horse-1"}, ) assert rejected.status_code == 400 assert "used this password before" in rejected.json()["detail"].lower() # Rejection must not consume the code - it still works with a # genuinely different password right after. retry = c.post( "/api/v1/auth/confirm-password-reset", json={"email": email, "code": code, "new_password": "different-horse-9"}, ) assert retry.status_code == 204 def test_password_reset_rejects_reusing_a_previous_not_just_current_password(monkeypatch): sent = _install_fake_smtp(monkeypatch) email = _unique_email() ip1, ip2 = _unique_ip(), _unique_ip() with TestClient(app, client=(ip1, 51234)) as c1: c1.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "T"}, ) c1.post("/api/v1/auth/request-password-reset", json={"email": email}) code1 = _extract_code(sent) first = c1.post( "/api/v1/auth/confirm-password-reset", json={"email": email, "code": code1, "new_password": "new-horse-2"}, ) assert first.status_code == 204 with TestClient(app, client=(ip2, 51235)) as c2: c2.post("/api/v1/auth/request-password-reset", json={"email": email}) code2 = _extract_code(sent) # correct-horse-1 is no longer the current password, but it's still # in this account's history - must still be rejected. rejected = c2.post( "/api/v1/auth/confirm-password-reset", json={"email": email, "code": code2, "new_password": "correct-horse-1"}, ) assert rejected.status_code == 400 accepted = c2.post( "/api/v1/auth/confirm-password-reset", json={"email": email, "code": code2, "new_password": "third-horse-3"}, ) assert accepted.status_code == 204 def test_password_reset_full_round_trip_then_old_sessions_revoked(monkeypatch): sent = _install_fake_smtp(monkeypatch) ip = _unique_ip() with TestClient(app, client=(ip, 51234)) as c: email = _unique_email() c.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "T"}, ) tokens = c.post( "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} ).json() reset_req = c.post("/api/v1/auth/request-password-reset", json={"email": email}) assert reset_req.status_code == 204 code = _extract_code(sent) confirm = c.post( "/api/v1/auth/confirm-password-reset", json={"email": email, "code": code, "new_password": "new-horse-2"}, ) assert confirm.status_code == 204 old_password_login = c.post( "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} ) assert old_password_login.status_code == 401 new_password_login = c.post( "/api/v1/auth/login", json={"email": email, "password": "new-horse-2"} ) assert new_password_login.status_code == 200 # A reset invalidates sessions that existed before it. stale_refresh = c.post( "/api/v1/auth/refresh", json={"refresh_token": tokens["refresh_token"]} ) assert stale_refresh.status_code == 401 def test_request_password_reset_unknown_email_is_generic_success(monkeypatch): sent = _install_fake_smtp(monkeypatch) ip = _unique_ip() with TestClient(app, client=(ip, 51234)) as c: resp = c.post("/api/v1/auth/request-password-reset", json={"email": _unique_email()}) assert resp.status_code == 204 assert sent == [] def test_confirm_password_reset_wrong_code_is_generic_failure(client: TestClient): resp = client.post( "/api/v1/auth/confirm-password-reset", json={"email": _unique_email(), "code": "000000", "new_password": "new-horse-2"}, ) assert resp.status_code == 401 # --- Account lockout (service-level, clock-walked) ------------------------- class _Clock: def __init__(self) -> None: self.now = datetime(2026, 1, 1, tzinfo=UTC) def __call__(self) -> datetime: return self.now def advance(self, seconds: float) -> None: self.now += timedelta(seconds=seconds) @pytest.fixture() def clock(monkeypatch): fake_clock = _Clock() monkeypatch.setattr(ip_throttle_service, "_now", fake_clock) return fake_clock async def _advance_past_login_throttle(db_session, clock: _Clock, ip: str) -> None: peeked = await ip_throttle_service.peek_throttle(db_session, ip, ThrottleAction.FAILED_LOGIN) if not peeked.allowed and peeked.retry_after_seconds: clock.advance(peeked.retry_after_seconds) elif not peeked.allowed: # Banned outright (no retry_after) - shouldn't happen within this # test's 12-attempt ladder, but fail loudly if it ever does. state = await IpThrottleRepository(db_session).get_state(ip, ThrottleAction.FAILED_LOGIN) if state and state.timeout_until is not None: clock.advance((ensure_aware_utc(state.timeout_until) - clock.now).total_seconds() + 1) async def test_login_lockout_after_twelve_failed_attempts_locks_account_and_notifies( db_session, clock, monkeypatch ): settings = get_settings() ip = _unique_ip() email = _unique_email() user = await auth_service.register( db_session, settings, ip, RegisterRequest(email=email, password="correct-horse-1", display_name="T"), ) assert user.email_verified is True # app_env == "test" auto-verify precedent locked_emails: list[str] = [] async def fake_send_locked(_settings, to): locked_emails.append(to) return DeliveryResult(success=True) monkeypatch.setattr( auth_service.security_email_service, "send_account_locked_email", fake_send_locked ) wrong_login = LoginRequest(email=email, password="wrong-password-1") for _ in range(12): await _advance_past_login_throttle(db_session, clock, ip) with pytest.raises(AuthenticationError): await auth_service.login(db_session, settings, ip, wrong_login) refreshed = await UserRepository(db_session).get_by_id(user.id) assert refreshed.failed_login_count == 12 assert refreshed.locked_at is not None assert locked_emails == [email] # The account stays locked even with the *correct* password, and even # once the IP itself is no longer throttled. await _advance_past_login_throttle(db_session, clock, ip) correct_login = LoginRequest(email=email, password="correct-horse-1") with pytest.raises(AuthenticationError, match="locked"): await auth_service.login(db_session, settings, ip, correct_login) async def test_login_correct_password_resets_failed_count_before_lockout(db_session, clock): settings = get_settings() ip = _unique_ip() email = _unique_email() user = await auth_service.register( db_session, settings, ip, RegisterRequest(email=email, password="correct-horse-1", display_name="T"), ) wrong_login = LoginRequest(email=email, password="wrong-password-1") for _ in range(3): await _advance_past_login_throttle(db_session, clock, ip) with pytest.raises(AuthenticationError): await auth_service.login(db_session, settings, ip, wrong_login) await _advance_past_login_throttle(db_session, clock, ip) correct_login = LoginRequest(email=email, password="correct-horse-1") await auth_service.login(db_session, settings, ip, correct_login) refreshed = await UserRepository(db_session).get_by_id(user.id) assert refreshed.failed_login_count == 0 assert refreshed.locked_at is None # --- Turnstile --------------------------------------------------------- def test_register_without_turnstile_token_rejected_when_configured(client: TestClient): settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"}) app.dependency_overrides[get_settings] = lambda: settings try: resp = client.post( "/api/v1/auth/register", json={"email": _unique_email(), "password": "correct-horse-1", "display_name": "T"}, ) assert resp.status_code == 400 finally: app.dependency_overrides.pop(get_settings, None) def test_register_with_verified_turnstile_token_succeeds(client: TestClient, monkeypatch): settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"}) app.dependency_overrides[get_settings] = lambda: settings async def fake_verify(token, remote_ip, _settings): assert token == "good-token" return True monkeypatch.setattr("app.api.v1.auth.verify_turnstile", fake_verify) try: resp = client.post( "/api/v1/auth/register", json={ "email": _unique_email(), "password": "correct-horse-1", "display_name": "T", "turnstile_token": "good-token", }, ) assert resp.status_code == 201 finally: app.dependency_overrides.pop(get_settings, None) def test_register_skips_turnstile_entirely_on_localhost_even_when_configured(): settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"}) app.dependency_overrides[get_settings] = lambda: settings try: with TestClient(app, client=("127.0.0.1", 54321)) as loopback_client: resp = loopback_client.post( "/api/v1/auth/register", json={ "email": _unique_email(), "password": "correct-horse-1", "display_name": "T", }, ) assert resp.status_code == 201 finally: app.dependency_overrides.pop(get_settings, None) async def test_register_succeeds_for_non_localhost_caller_when_configured_secret_is_invalid( client: TestClient, ): """End-to-end proof (not just verify_turnstile in isolation): a non-loopback caller can still register when the admin's configured secret is itself broken, regardless of what token they submitted.""" settings = get_settings().model_copy(update={"turnstile_secret": "a-typo-d-secret"}) app.dependency_overrides[get_settings] = lambda: settings try: with respx.mock: respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock( return_value=httpx.Response( 200, json={"success": False, "error-codes": ["invalid-input-secret"]} ) ) resp = client.post( "/api/v1/auth/register", json={ "email": _unique_email(), "password": "correct-horse-1", "display_name": "T", "turnstile_token": "whatever-token", }, ) assert resp.status_code == 201 finally: app.dependency_overrides.pop(get_settings, None) async def test_verify_turnstile_returns_true_on_cloudflare_success(): settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"}) with respx.mock: respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock( return_value=httpx.Response(200, json={"success": True}) ) result = await verify_turnstile("some-token", "1.2.3.4", settings) assert result is True async def test_verify_turnstile_fails_closed_on_network_error(): settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"}) with respx.mock: respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock( side_effect=httpx.ConnectError("boom") ) result = await verify_turnstile("some-token", "1.2.3.4", settings) assert result is False async def test_verify_turnstile_fails_open_when_the_configured_secret_itself_is_invalid(): """A typo'd/invalid secret is a detectable config problem (Cloudflare reports it via error-codes), not an ambiguous failure - locking out every real visitor over an admin's own mistake is worse than briefly running with reduced bot protection.""" settings = get_settings().model_copy(update={"turnstile_secret": "a-typo-d-secret"}) with respx.mock: respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock( return_value=httpx.Response( 200, json={"success": False, "error-codes": ["invalid-input-secret"]} ) ) result = await verify_turnstile("some-token", "1.2.3.4", settings) assert result is True async def test_verify_turnstile_still_fails_closed_for_a_genuinely_bad_user_token(): """The fail-open carve-out is scoped to secret-level error codes only - a real rejection of the user's own token must still fail closed.""" settings = get_settings().model_copy(update={"turnstile_secret": "fake-secret"}) with respx.mock: respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock( return_value=httpx.Response( 200, json={"success": False, "error-codes": ["invalid-input-response"]} ) ) result = await verify_turnstile("bad-token", "1.2.3.4", settings) assert result is False # --- IP ban ----------------------------------------------------------------- async def test_register_rejected_when_ip_is_banned(db_session): """A ban is IP-global - it must block brand-new account creation too, not just actions against existing accounts (login/resend/reset).""" ip = _unique_ip() await IpThrottleRepository(db_session).create_ban(ip, "failed_login", datetime.now(UTC)) await db_session.commit() with TestClient(app, client=(ip, 51234)) as c: resp = c.post( "/api/v1/auth/register", json={"email": _unique_email(), "password": "correct-horse-1", "display_name": "T"}, ) assert resp.status_code == 429 assert resp.json()["detail"] == "This IP address has been temporarily blocked."