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:
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
|
||||
Reference in New Issue
Block a user