Files
chicken_babies_site/tests/test_auth_service.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

276 lines
9.7 KiB
Python

"""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