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.
114 lines
3.5 KiB
Python
114 lines
3.5 KiB
Python
"""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
|