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:
209
tests/test_admin_routes.py
Normal file
209
tests/test_admin_routes.py
Normal 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
|
||||
Reference in New Issue
Block a user