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.
This commit is contained in:
2026-04-21 16:20:51 -05:00
parent 4b088e5045
commit 59dea99079
25 changed files with 2673 additions and 15 deletions

209
tests/test_admin_routes.py Normal file
View File

@@ -0,0 +1,209 @@
"""End-to-end HTTP tests for the admin auth flow.
Exercises the real :class:`FastAPI` app constructed by
:func:`app.main.create_app` against a temp-file SQLite database and a
monkeypatched ``EmailService`` so we can capture the magic-link URL
without depending on Resend.
These tests intentionally do NOT use the session-scoped ``db_engine``
fixture — each builds its own fresh app so rate-limit state and DB
rows don't leak between tests.
"""
from __future__ import annotations
import importlib
import re
from pathlib import Path
from typing import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
# Regex used to extract the raw token from the dev-fallback log URL.
_URL_RE = re.compile(r"http[s]?://[^\s]+/admin/auth/consume/[A-Za-z0-9_-]+")
@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."""
# Point the settings loader at a clean DB and a known allowlist.
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")
# Reload config + main so the new env is picked up.
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
# Capture the magic-link URL instead of sending via Resend.
captured: dict[str, str] = {}
def _capture(**kw):
captured["url"] = kw["url"]
captured["to"] = kw["to"]
app.state.email_service.send_magic_link = _capture # type: ignore[assignment]
# Reset the in-memory rate limiter between tests (module-level
# singleton would otherwise leak state across tests).
from app.services.rate_limit import limiter
limiter.reset()
with TestClient(app) as client:
yield client, captured
_config.get_settings.cache_clear()
def _auth_events(app, event_type: str | None = None) -> list[dict]:
"""Return rows from ``auth_events`` (optionally filtered by type)."""
with app.state.engine.connect() as conn:
if event_type:
rows = conn.execute(
text(
"SELECT event_type, email, detail FROM auth_events"
" WHERE event_type = :t ORDER BY id"
),
{"t": event_type},
).mappings().all()
else:
rows = conn.execute(
text(
"SELECT event_type, email, detail FROM auth_events"
" ORDER BY id"
)
).mappings().all()
return [dict(r) for r in rows]
def test_full_login_flow(admin_app) -> None:
"""GET login, POST, consume, GET /admin, POST logout."""
client, captured = admin_app
# 1. GET login page.
resp = client.get("/admin/login")
assert resp.status_code == 200
assert "Admin log in" in resp.text
assert 'name="email"' in resp.text
# 2. POST login with allowlisted email.
resp = client.post("/admin/login", data={"email": "headhen@example.com"})
assert resp.status_code == 200
assert "Check your inbox" in resp.text
assert "url" in captured
raw_url = captured["url"]
assert "/admin/auth/consume/" in raw_url
# 3. Consume the magic link.
consume_path = raw_url.replace("http://testserver", "")
resp = client.get(consume_path, follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/admin"
# cb_session cookie set.
assert "cb_session" in resp.cookies
# 4. GET /admin renders welcome.
resp = client.get("/admin")
assert resp.status_code == 200
assert "Welcome" in resp.text
assert "headhen@example.com" in resp.text
# 5. POST /admin/logout clears cookie + redirects.
resp = client.post("/admin/logout", follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/admin/login"
# 6. Subsequent /admin redirects back to login.
# Manually drop the cookie from the test client, mirroring a browser
# that honored the delete_cookie directive.
client.cookies.clear()
resp = client.get("/admin", follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/admin/login"
# Audit rows: link_requested, link_consumed, session_created, session_revoked.
events = [e["event_type"] for e in _auth_events(client.app)]
for required in (
"link_requested",
"link_consumed",
"session_created",
"session_revoked",
):
assert required in events, f"missing audit event: {required}"
def test_non_allowlisted_returns_same_page_no_token(admin_app) -> None:
"""Non-allowlisted email: same 200 + 'Check your inbox', no token row."""
client, captured = admin_app
resp = client.post("/admin/login", data={"email": "stranger@example.com"})
assert resp.status_code == 200
assert "Check your inbox" in resp.text
assert "url" not in captured # email not sent
# No token row for this email.
with client.app.state.engine.connect() as conn:
count = conn.execute(
text(
"SELECT COUNT(*) AS c FROM magic_link_tokens"
" WHERE email = :e"
),
{"e": "stranger@example.com"},
).mappings().first()
assert count is not None and int(count["c"]) == 0
# Audit trail still records the attempt.
events = _auth_events(client.app, "link_requested")
assert len(events) == 1
assert "\"allowlisted\": false" in events[0]["detail"]
def test_invalid_token_returns_400(admin_app) -> None:
"""A random token on the consume endpoint returns the generic 400 page."""
client, _ = admin_app
resp = client.get("/admin/auth/consume/not-a-real-token", follow_redirects=False)
assert resp.status_code == 400
assert "Login link invalid" in resp.text
def test_login_form_validation(admin_app) -> None:
"""Empty / malformed email surfaces the inline validation message."""
client, _ = admin_app
resp = client.post("/admin/login", data={"email": ""})
assert resp.status_code == 400
assert "valid email" in resp.text
resp = client.post("/admin/login", data={"email": "not-an-email"})
assert resp.status_code == 400
assert "valid email" in resp.text
def test_replay_attack_fails(admin_app) -> None:
"""A consumed magic link cannot be used a second time."""
client, captured = admin_app
client.post("/admin/login", data={"email": "headhen@example.com"})
url = captured["url"].replace("http://testserver", "")
first = client.get(url, follow_redirects=False)
assert first.status_code == 303
# Clear cookies to simulate a replay from a fresh browser context.
client.cookies.clear()
second = client.get(url, follow_redirects=False)
assert second.status_code == 400

275
tests/test_auth_service.py Normal file
View File

@@ -0,0 +1,275 @@
"""Tests for :class:`app.services.auth.AuthService`.
Covers:
- Allowlisted email → token inserted, email "sent" (dev fallback), audit
rows emitted.
- Non-allowlisted email → no token row, audit row with allowlisted=False.
- Consume happy path (creates user on first login, bumps last_login_at
on subsequent logins, issues session).
- Consume replay → second attempt returns None + consume_failed/used audit.
- Consume expired token → None + consume_failed/expired.
- Per-email rate-limit threshold trips on the 6th request.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.templating import Jinja2Templates
from itsdangerous import URLSafeTimedSerializer
from pathlib import Path
from sqlalchemy import Engine, text
from app.config import Settings
from app.services.audit import AuditService
from app.services.auth import AuthService, RateLimitedError
from app.services.email import EmailService
from app.services.sessions import SessionService
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "app" / "templates"
def _build(engine: Engine, *, admin_emails: str = "headhen@example.com") -> tuple[AuthService, Settings]:
"""Wire up an AuthService against a real SQLite engine."""
settings = Settings(
app_env="development",
secret_key="a-very-long-test-secret-key-1234567890",
admin_emails=admin_emails,
resend_api_key=None,
resend_from=None,
public_base_url="http://localhost:8000",
magic_link_ttl_min=15,
) # type: ignore[call-arg]
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
signer = URLSafeTimedSerializer(settings.secret_key, salt="session")
email = EmailService(settings, templates)
audit = AuditService(engine)
sessions = SessionService(engine, signer, settings)
auth = AuthService(engine, email, sessions, audit, settings)
return auth, settings
def _count(engine: Engine, table: str, where: str = "1=1", **params) -> int:
with engine.connect() as conn:
row = conn.execute(
text(f"SELECT COUNT(*) AS c FROM {table} WHERE {where}"), params
).mappings().first()
return int(row["c"]) if row is not None else 0
def _latest_detail(engine: Engine, event_type: str) -> str:
with engine.connect() as conn:
row = conn.execute(
text(
"SELECT detail FROM auth_events WHERE event_type = :t"
" ORDER BY id DESC LIMIT 1"
),
{"t": event_type},
).mappings().first()
return str(row["detail"]) if row is not None else ""
def test_allowlisted_request_inserts_token_and_audit(
clean_db_engine: Engine,
) -> None:
"""An allowlisted email mints a token row and a link_requested audit."""
auth, _ = _build(clean_db_engine)
auth.request_link(email="HeadHen@example.com", ip="1.2.3.4", user_agent="ua")
assert _count(clean_db_engine, "magic_link_tokens") == 1
# Stored email is lowercased.
with clean_db_engine.connect() as conn:
row = conn.execute(
text("SELECT email, token_hash FROM magic_link_tokens")
).mappings().first()
assert row["email"] == "headhen@example.com"
# Hex SHA-256 is 64 chars.
assert len(row["token_hash"]) == 64
assert _count(clean_db_engine, "auth_events", "event_type='link_requested'") == 1
assert "\"allowlisted\": true" in _latest_detail(
clean_db_engine, "link_requested"
)
def test_non_allowlisted_request_inserts_no_token(
clean_db_engine: Engine,
) -> None:
"""A non-allowlisted email gets an audit row and no token."""
auth, _ = _build(clean_db_engine, admin_emails="headhen@example.com")
auth.request_link(email="intruder@example.com", ip="1.2.3.4", user_agent="ua")
assert _count(clean_db_engine, "magic_link_tokens") == 0
assert _count(clean_db_engine, "auth_events", "event_type='link_requested'") == 1
assert "\"allowlisted\": false" in _latest_detail(
clean_db_engine, "link_requested"
)
def test_consume_happy_path_creates_user_and_session(
clean_db_engine: Engine,
monkeypatch,
) -> None:
"""Consuming a fresh token upserts a user and mints a session."""
auth, _ = _build(clean_db_engine)
# Intercept the raw token via the dev-fallback log. Easiest path:
# patch EmailService.send_magic_link on this instance.
captured: dict[str, str] = {}
def _capture(**kw):
captured["url"] = kw["url"]
monkeypatch.setattr(auth._email, "send_magic_link", _capture)
auth.request_link(email="headhen@example.com", ip="1.2.3.4", user_agent="ua")
assert "url" in captured
raw_token = captured["url"].rsplit("/", 1)[-1]
result = auth.consume(
raw_token=raw_token, ip="1.2.3.4", user_agent="ua"
)
assert result is not None
user, session, cookie = result
assert user.email == "headhen@example.com"
assert user.display_name == "Headhen"
assert user.active is True
# User row persisted with last_login_at set.
with clean_db_engine.connect() as conn:
row = conn.execute(
text("SELECT last_login_at FROM users WHERE id = :id"),
{"id": user.id},
).mappings().first()
assert row["last_login_at"] is not None
# Token marked used.
with clean_db_engine.connect() as conn:
tok = conn.execute(
text("SELECT used_at FROM magic_link_tokens")
).mappings().first()
assert tok["used_at"] is not None
# Session row exists, cookie is non-empty.
assert session.user_id == user.id
assert cookie
# Audit trail: link_consumed + session_created.
assert _count(
clean_db_engine, "auth_events", "event_type='link_consumed'"
) == 1
assert _count(
clean_db_engine, "auth_events", "event_type='session_created'"
) == 1
def test_consume_replay_fails(clean_db_engine: Engine, monkeypatch) -> None:
"""The same raw token cannot be consumed twice."""
auth, _ = _build(clean_db_engine)
captured: dict[str, str] = {}
monkeypatch.setattr(
auth._email, "send_magic_link", lambda **kw: captured.update(url=kw["url"])
)
auth.request_link(email="headhen@example.com", ip="", user_agent="")
raw = captured["url"].rsplit("/", 1)[-1]
first = auth.consume(raw_token=raw, ip="", user_agent="")
assert first is not None
second = auth.consume(raw_token=raw, ip="", user_agent="")
assert second is None
# consume_failed audit with reason=used.
assert "\"reason\": \"used\"" in _latest_detail(
clean_db_engine, "consume_failed"
)
def test_consume_expired_token_fails(clean_db_engine: Engine, monkeypatch) -> None:
"""A token past its expires_at cannot be consumed."""
auth, _ = _build(clean_db_engine)
captured: dict[str, str] = {}
monkeypatch.setattr(
auth._email, "send_magic_link", lambda **kw: captured.update(url=kw["url"])
)
auth.request_link(email="headhen@example.com", ip="", user_agent="")
raw = captured["url"].rsplit("/", 1)[-1]
# Push expires_at into the past.
past = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
with clean_db_engine.begin() as conn:
conn.execute(
text("UPDATE magic_link_tokens SET expires_at = :p"), {"p": past}
)
assert auth.consume(raw_token=raw, ip="", user_agent="") is None
assert "\"reason\": \"expired\"" in _latest_detail(
clean_db_engine, "consume_failed"
)
def test_consume_unknown_token_fails(clean_db_engine: Engine) -> None:
"""Random tokens result in not_found."""
auth, _ = _build(clean_db_engine)
assert auth.consume(
raw_token="totally-random-nope", ip="", user_agent=""
) is None
assert "\"reason\": \"not_found\"" in _latest_detail(
clean_db_engine, "consume_failed"
)
def test_per_email_rate_limit_trips_on_sixth(
clean_db_engine: Engine, monkeypatch
) -> None:
"""Five requests in the window succeed; the sixth raises and audits."""
auth, _ = _build(clean_db_engine)
monkeypatch.setattr(auth._email, "send_magic_link", lambda **kw: None)
for _ in range(5):
auth.request_link(email="headhen@example.com", ip="", user_agent="")
with pytest.raises(RateLimitedError):
auth.request_link(email="headhen@example.com", ip="", user_agent="")
# Exactly 5 tokens persisted; 6th blocked before insert.
assert _count(clean_db_engine, "magic_link_tokens") == 5
# rate_limited audit with scope=email.
detail = _latest_detail(clean_db_engine, "rate_limited")
assert "\"scope\": \"email\"" in detail
def test_existing_user_reactivated_on_consume(
clean_db_engine: Engine, monkeypatch
) -> None:
"""Consume updates an existing (possibly deactivated) user row."""
# Seed a deactivated existing admin.
now_iso = datetime.now(timezone.utc).isoformat()
with clean_db_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO users"
" (email, display_name, created_at, last_login_at, active)"
" VALUES (:e, :d, :c, NULL, 0)"
),
{"e": "headhen@example.com", "d": "Old Name", "c": now_iso},
)
auth, _ = _build(clean_db_engine)
captured: dict[str, str] = {}
monkeypatch.setattr(
auth._email, "send_magic_link", lambda **kw: captured.update(url=kw["url"])
)
auth.request_link(email="headhen@example.com", ip="", user_agent="")
raw = captured["url"].rsplit("/", 1)[-1]
result = auth.consume(raw_token=raw, ip="", user_agent="")
assert result is not None
user, _, _ = result
# display_name preserved (we don't overwrite), active flipped back on.
assert user.display_name == "Old Name"
assert user.active is True

113
tests/test_email_service.py Normal file
View File

@@ -0,0 +1,113 @@
"""Tests for :class:`app.services.email.EmailService`.
We exercise the dev-fallback path (no ``RESEND_API_KEY``) and assert:
- It does NOT raise.
- It emits a structured log event ``magic_link_dev_fallback`` with the
full URL so a developer can copy it.
- It doesn't call the real Resend SDK.
Production-missing-key is validated at startup by the pydantic model
validator (covered separately in the config tests — but we also verify
the behavior here via a direct settings construction).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from fastapi.templating import Jinja2Templates
from app.config import Settings
from app.logging_config import configure_logging
from app.services.email import EmailService
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent / "app"
_TEMPLATES_DIR = _PACKAGE_ROOT / "templates"
@pytest.fixture
def templates() -> Jinja2Templates:
"""Return a Jinja2Templates pointing at the real app templates dir."""
return Jinja2Templates(directory=_TEMPLATES_DIR)
def test_dev_fallback_logs_url(
templates: Jinja2Templates, capsys: pytest.CaptureFixture[str]
) -> None:
"""Missing RESEND_API_KEY in dev -> structured log, no exception.
structlog writes via ``PrintLoggerFactory`` (stdout), so we capture
stdout rather than the stdlib ``caplog`` handler.
"""
# Reconfigure structlog so the ConsoleRenderer is active — the test
# harness may have left it in another state.
configure_logging("development")
settings = Settings(
app_env="development",
resend_api_key=None,
resend_from=None,
) # type: ignore[call-arg]
svc = EmailService(settings, templates)
url = "http://localhost:8000/admin/auth/consume/raw-token-123"
svc.send_magic_link(
to="head-hen@example.com",
url=url,
display_name="Head Hen",
ttl_min=15,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
captured = capsys.readouterr()
combined = captured.out + captured.err
assert "magic_link_dev_fallback" in combined
assert url in combined
def test_production_missing_key_refuses_startup() -> None:
"""Production without resend key / from / admin_emails raises at init."""
with pytest.raises(ValueError) as excinfo:
Settings(
app_env="production",
secret_key="long-enough-prod-secret-key-value-000000",
) # type: ignore[call-arg]
msg = str(excinfo.value)
assert "RESEND_API_KEY" in msg
assert "RESEND_FROM" in msg
assert "ADMIN_EMAILS" in msg
def test_dev_fallback_does_not_touch_resend(
templates: Jinja2Templates, monkeypatch
) -> None:
"""If the SDK IS present, the dev fallback must still not call it."""
import resend # type: ignore[import-untyped]
called: dict[str, bool] = {"ok": False}
def _boom(*a, **kw): # pragma: no cover — shouldn't execute
called["ok"] = True
raise RuntimeError("should not be called in dev fallback")
monkeypatch.setattr(resend.Emails, "send", staticmethod(_boom))
settings = Settings(
app_env="development",
resend_api_key=None,
resend_from=None,
) # type: ignore[call-arg]
svc = EmailService(settings, templates)
svc.send_magic_link(
to="a@b.co",
url="http://x/y",
display_name="A",
ttl_min=15,
expires_at=datetime.now(timezone.utc),
)
assert called["ok"] is False

142
tests/test_rate_limit.py Normal file
View File

@@ -0,0 +1,142 @@
"""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()

View File

@@ -0,0 +1,152 @@
"""Tests for :class:`app.services.sessions.SessionService`.
Coverage:
- Signed-cookie round-trip (create → lookup succeeds).
- Revoke makes subsequent lookup return ``None``.
- Bad-signature cookie returns ``None`` without raising.
- Expired session row returns ``None``.
- Cookie flags reflect ``app_env`` (``Secure`` off in dev / on in prod).
"""
from __future__ import annotations
import hashlib
from datetime import datetime, timedelta, timezone
from itsdangerous import URLSafeTimedSerializer
from sqlalchemy import Engine, text
from app.config import Settings
from app.services.sessions import COOKIE_NAME, SessionService
def _seed_user(engine: Engine) -> int:
"""Insert a throwaway admin user; return its id."""
now_iso = datetime.now(timezone.utc).isoformat()
with engine.begin() as conn:
result = conn.execute(
text(
"INSERT INTO users"
" (email, display_name, created_at, last_login_at, active)"
" VALUES (:e, :d, :c, :c, 1)"
),
{"e": "session-test@example.com", "d": "Session Tester", "c": now_iso},
)
return int(result.lastrowid) # type: ignore[arg-type]
def _build(
engine: Engine,
*,
app_env: str = "development",
secret: str = "a-very-long-test-secret-key-1234567890",
) -> SessionService:
"""Return a SessionService wired for a given app_env."""
# In production mode the Settings validator requires these fields
# to be non-empty; populate them with test values so we can still
# exercise the prod-only ``Secure`` flag path.
if app_env == "production":
settings = Settings( # type: ignore[call-arg]
app_env=app_env,
secret_key=secret,
resend_api_key="test-key",
resend_from="no-reply@example.com",
admin_emails="admin@example.com",
)
else:
settings = Settings(app_env=app_env, secret_key=secret) # type: ignore[call-arg]
signer = URLSafeTimedSerializer(secret, salt="session")
return SessionService(engine, signer, settings)
def test_create_then_lookup_roundtrip(clean_db_engine: Engine) -> None:
"""Creating a session and then looking up the signed cookie returns it."""
user_id = _seed_user(clean_db_engine)
svc = _build(clean_db_engine)
session, cookie = svc.create(user_id=user_id, ip="10.0.0.1", user_agent="ua")
# Cookie is non-empty and not the raw session ID.
assert cookie
# DB row stores a sha256 hex hash, never the raw.
assert len(session.token_hash) == 64
found = svc.lookup(cookie)
assert found is not None
assert found.id == session.id
assert found.user_id == user_id
def test_lookup_empty_cookie_returns_none(clean_db_engine: Engine) -> None:
"""No cookie value resolves to None without raising."""
svc = _build(clean_db_engine)
assert svc.lookup(None) is None
assert svc.lookup("") is None
def test_lookup_bad_signature_returns_none(clean_db_engine: Engine) -> None:
"""A cookie signed by a different key resolves to None."""
user_id = _seed_user(clean_db_engine)
svc = _build(clean_db_engine, secret="key-one-abcdefghijklmnopqrstuvwxyz")
_, cookie = svc.create(user_id=user_id, ip="10.0.0.1", user_agent="ua")
# Different signer — same salt, different key — should fail verify.
other = _build(clean_db_engine, secret="key-two-abcdefghijklmnopqrstuvwxyz")
assert other.lookup(cookie) is None
def test_revoke_marks_session_inactive(clean_db_engine: Engine) -> None:
"""Revoking a session prevents further lookups."""
user_id = _seed_user(clean_db_engine)
svc = _build(clean_db_engine)
session, cookie = svc.create(user_id=user_id, ip="", user_agent="")
assert svc.lookup(cookie) is not None
svc.revoke(session)
assert svc.lookup(cookie) is None
def test_expired_session_returns_none(clean_db_engine: Engine) -> None:
"""A session whose DB expires_at is in the past resolves to None."""
user_id = _seed_user(clean_db_engine)
svc = _build(clean_db_engine)
_, cookie = svc.create(user_id=user_id, ip="", user_agent="")
# Force expires_at into the past without touching the cookie.
past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
with clean_db_engine.begin() as conn:
conn.execute(text("UPDATE sessions SET expires_at = :p"), {"p": past})
assert svc.lookup(cookie) is None
def test_cookie_params_secure_flag_gated_by_env(clean_db_engine: Engine) -> None:
"""``Secure`` is False in development and True in production."""
dev = _build(clean_db_engine, app_env="development").cookie_params()
prod = _build(clean_db_engine, app_env="production").cookie_params()
assert dev["secure"] is False
assert prod["secure"] is True
for params in (dev, prod):
assert params["key"] == COOKIE_NAME
assert params["httponly"] is True
assert params["samesite"] == "lax"
assert params["path"] == "/"
def test_raw_token_not_in_cookie_or_row(clean_db_engine: Engine) -> None:
"""The raw session token must never appear in the DB row."""
user_id = _seed_user(clean_db_engine)
svc = _build(clean_db_engine)
session, cookie = svc.create(user_id=user_id, ip="", user_agent="")
# Cookie is itsdangerous-signed, not the raw. Decoding confirms it.
signer = URLSafeTimedSerializer(
"a-very-long-test-secret-key-1234567890", salt="session"
)
raw = signer.loads(cookie)
# DB row stores only the sha256(raw) hex digest.
expected_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()
assert session.token_hash == expected_hash
assert raw not in session.token_hash