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
|
||||
275
tests/test_auth_service.py
Normal file
275
tests/test_auth_service.py
Normal 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
113
tests/test_email_service.py
Normal 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
142
tests/test_rate_limit.py
Normal 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()
|
||||
152
tests/test_session_service.py
Normal file
152
tests/test_session_service.py
Normal 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
|
||||
Reference in New Issue
Block a user