Files
chicken_babies_site/tests/test_rate_limit.py
Phillip Tarrant 59dea99079 feat: phase 3 admin magic-link auth — tokens, sessions, rate limits, audit
End-to-end passwordless admin auth. /admin/login accepts an email, POSTs
mint a 256-bit magic-link token stored only as SHA-256 in
magic_link_tokens (15-min TTL, single-use via atomic rowcount UPDATE).
Resend delivers the link; in dev with no API key, EmailService logs a
structured magic_link_dev_fallback event with the URL so the flow works
offline. /admin/auth/consume/{token} verifies, upserts a users row
(display_name from email local-part), creates a sessions row, and drops
an itsdangerous-signed cb_session cookie (HttpOnly, SameSite=Lax, Secure
in prod). /admin renders a placeholder "Welcome, <name>" page pending
Phase 4 CMS. /admin/logout flips revoked_at rather than deleting the row
to preserve the audit trail.

Rate limits use SlowAPI's in-memory limiter (5/15min/IP on login,
20/15min/IP on consume) plus a DB per-email count to catch
IP-rotating abuse. ADMIN_EMAILS enforces allowlist; non-allowlisted
submissions return the same "check your inbox" page with no token
inserted and no email sent (anti-enumeration). Every event lands in
auth_events via AuditService: link_requested, link_consumed,
consume_failed, session_created, session_revoked, rate_limited.

Add a production config validator refusing empty RESEND_API_KEY,
RESEND_FROM, or ADMIN_EMAILS; add PUBLIC_BASE_URL for email link
construction. CSRF deferred to Phase 6 per roadmap scoping; logout
handler marked # TODO(phase-6-csrf).

Mark Phase 3 complete in docs/ROADMAP.md.
2026-04-21 16:20:51 -05:00

143 lines
4.7 KiB
Python

"""Rate-limit tests for the admin auth flow.
Covers:
- IP-level SlowAPI limit: 6th POST /admin/login from the same IP within
15 minutes returns 429 + ``rate_limited.html`` body.
- Per-email DB limit: 6th POST /admin/login for the same email (even
from different IPs — which TestClient can't really simulate without
middleware tricks, so we directly insert 5 recent tokens to prime
the DB) returns 429.
- Every 429 path writes a ``rate_limited`` audit event.
"""
from __future__ import annotations
import importlib
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
@pytest.fixture
def admin_app(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> Iterator[tuple[TestClient, dict]]:
"""Build a fresh FastAPI app wired to a tmp DB + captured email URL."""
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path}/admin.db")
monkeypatch.setenv("ADMIN_EMAILS", "headhen@example.com")
monkeypatch.setenv("APP_ENV", "development")
monkeypatch.setenv(
"SECRET_KEY", "test-only-secret-key-0123456789abcdef-XYZ"
)
monkeypatch.setenv("RESEND_API_KEY", "")
monkeypatch.setenv("PUBLIC_BASE_URL", "http://testserver")
from app import config as _config
_config.get_settings.cache_clear()
import app.main as main_module
importlib.reload(main_module)
app = main_module.app
captured: dict = {"urls": []}
app.state.email_service.send_magic_link = lambda **kw: captured["urls"].append( # type: ignore[assignment]
kw["url"]
)
from app.services.rate_limit import limiter
limiter.reset()
with TestClient(app) as client:
yield client, captured
_config.get_settings.cache_clear()
def test_ip_rate_limit_trips_on_sixth(admin_app) -> None:
"""Five POSTs succeed; the sixth from the same IP returns 429."""
client, _ = admin_app
# Use a non-allowlisted email so we don't bump the DB per-email
# limit at the same time — isolates the IP-level limit.
for i in range(5):
resp = client.post(
"/admin/login", data={"email": f"nobody{i}@example.com"}
)
assert resp.status_code == 200, (i, resp.text[:200])
resp = client.post("/admin/login", data={"email": "nobody5@example.com"})
assert resp.status_code == 429
assert "Too many attempts" in resp.text
# Audit row written.
with client.app.state.engine.connect() as conn:
rows = conn.execute(
text(
"SELECT detail FROM auth_events"
" WHERE event_type = 'rate_limited'"
)
).mappings().all()
assert len(rows) >= 1
assert any("\"scope\": \"ip\"" in str(r["detail"]) for r in rows)
def test_per_email_rate_limit_trips_on_sixth(
admin_app, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Priming 5 recent tokens for an email causes the 6th request to 429."""
client, _ = admin_app
# Disable the SlowAPI IP limiter temporarily so we can isolate the
# DB-side per-email check. We can't call six requests through the
# IP limiter within 15 minutes — the IP limit would trip first.
from app.services.rate_limit import limiter
limiter.enabled = False
try:
# Seed 5 recent tokens for the allowlisted email.
now = datetime.now(timezone.utc)
with client.app.state.engine.begin() as conn:
for i in range(5):
conn.execute(
text(
"INSERT INTO magic_link_tokens"
" (email, token_hash, created_at, expires_at,"
" request_ip)"
" VALUES (:e, :h, :c, :x, :ip)"
),
{
"e": "headhen@example.com",
"h": f"seed-hash-{i}-" + ("x" * 50),
"c": now.isoformat(),
"x": (now + timedelta(minutes=15)).isoformat(),
"ip": "",
},
)
# 6th request trips the DB-side limiter.
resp = client.post(
"/admin/login", data={"email": "headhen@example.com"}
)
assert resp.status_code == 429
assert "Too many attempts" in resp.text
# rate_limited audit row with scope=email.
with client.app.state.engine.connect() as conn:
rows = conn.execute(
text(
"SELECT detail FROM auth_events"
" WHERE event_type = 'rate_limited'"
)
).mappings().all()
assert any("\"scope\": \"email\"" in str(r["detail"]) for r in rows)
finally:
limiter.enabled = True
limiter.reset()