feat: phase 4 admin CMS — dashboard, editor, media, CSRF
Head Hen CMS end-to-end: dashboard lists all posts (drafts + published), Markdown editor with live preview + drag-drop image upload, Pillow media pipeline re-encoding every upload to JPEG, post CRUD + publish toggle + hard delete, About page edit, and double-submit CSRF cookie enforced on every admin mutating endpoint (Phase 3's TODO markers resolved). Slug auto-generated on create and server-locked once a post has been published. Unpublish preserves `published_at` so re-publish keeps original date ordering. Every admin write invalidates the read-side Post/Page TTL caches and records an `auth_events` audit row. CSRF middleware is narrow by design — issues/refreshes the `cb_csrf` cookie only on `GET /admin*`, and mutating endpoints opt in via `require_csrf_form` or `require_csrf_header` Depends. Public routes, healthz, and pre-auth login stay untouched. 64 new tests cover slugs, CSRF, media, admin posts/pages services, and end-to-end CMS routes. Tests never mock the DB — real temp SQLite files per the CLAUDE.md mandate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,21 @@ from sqlalchemy import text
|
||||
_URL_RE = re.compile(r"http[s]?://[^\s]+/admin/auth/consume/[A-Za-z0-9_-]+")
|
||||
|
||||
|
||||
def _csrf_from_response(resp) -> str:
|
||||
"""Extract the CSRF token from a rendered admin page.
|
||||
|
||||
The admin base template emits a ``<meta name="csrf-token">`` tag
|
||||
populated by the CSRFCookieMiddleware. Tests pull it from there
|
||||
rather than the cookie value directly so the assertion also
|
||||
proves the template wiring is intact.
|
||||
"""
|
||||
match = re.search(
|
||||
r'<meta name="csrf-token" content="([^"]*)"', resp.text
|
||||
)
|
||||
assert match, "csrf-token meta tag missing"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_app(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -119,14 +134,20 @@ def test_full_login_flow(admin_app) -> None:
|
||||
# cb_session cookie set.
|
||||
assert "cb_session" in resp.cookies
|
||||
|
||||
# 4. GET /admin renders welcome.
|
||||
# 4. GET /admin renders the Phase 4 dashboard.
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200
|
||||
assert "Welcome" in resp.text
|
||||
assert "Dashboard" in resp.text
|
||||
assert "headhen@example.com" in resp.text
|
||||
csrf_token = _csrf_from_response(resp)
|
||||
|
||||
# 5. POST /admin/logout clears cookie + redirects.
|
||||
resp = client.post("/admin/logout", follow_redirects=False)
|
||||
# 5. POST /admin/logout clears cookie + redirects. CSRF required
|
||||
# now that Phase 4 has enabled the double-submit cookie.
|
||||
resp = client.post(
|
||||
"/admin/logout",
|
||||
data={"csrf_token": csrf_token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/admin/login"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user