FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
655 lines
25 KiB
Python
655 lines
25 KiB
Python
"""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."
|