Initial commit: CI Agent competitive-intelligence monitoring app

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).
This commit is contained in:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
+654
View File
@@ -0,0 +1,654 @@
"""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."