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:
@@ -88,3 +88,76 @@ Use the browser devtools responsive toolbar.
|
||||
- [ ] `python -c "from app.main import app"` exits cleanly.
|
||||
- [ ] `python scripts/generate_static_assets.py` regenerates the four asset files without error.
|
||||
- [ ] `docker compose config` still parses cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Admin CMS
|
||||
|
||||
Pre-requisites:
|
||||
- Logged in via the Phase 3 magic-link flow (dev-fallback URL in server logs).
|
||||
- Landed on the Phase 4 dashboard at `/admin`.
|
||||
|
||||
### Dashboard (`/admin`)
|
||||
|
||||
- [ ] Page returns 200.
|
||||
- [ ] Page title reads **"Dashboard"**.
|
||||
- [ ] Signed-in email appears in the greeting.
|
||||
- [ ] Posts table lists the seeded **"Welcome to the Farm"** row with status **Published**.
|
||||
- [ ] Each row shows Edit / Publish-or-Unpublish / Delete buttons.
|
||||
- [ ] Delete click triggers a confirmation dialog.
|
||||
- [ ] "New post" button is visible and links to `/admin/posts/new`.
|
||||
- [ ] "Edit About" button links to `/admin/pages/about/edit`.
|
||||
- [ ] `<meta name="csrf-token">` is present in the rendered HTML (view source).
|
||||
- [ ] The `cb_csrf` cookie is set with `SameSite=Lax`, `HttpOnly=false` (readable by JS).
|
||||
|
||||
### Create a post (`/admin/posts/new`)
|
||||
|
||||
- [ ] Form renders without errors.
|
||||
- [ ] Title + status + body fields visible; preview pane on the right.
|
||||
- [ ] Drop zone is visible below the textarea with prompt copy.
|
||||
- [ ] Typing in the textarea causes the preview to update within ~300ms.
|
||||
- [ ] Dragging a JPG / PNG / WebP image onto the drop zone uploads it; a Markdown image tag is inserted at the cursor.
|
||||
- [ ] Dropping a GIF, plain text file, or anything >8 MB triggers the `.is-error` state on the drop zone.
|
||||
- [ ] Submitting with a blank title re-renders with "Title is required." and preserves other fields.
|
||||
- [ ] Submitting a valid form 303-redirects to `/admin?msg=created`.
|
||||
|
||||
### Edit a post (`/admin/posts/{id}/edit`)
|
||||
|
||||
- [ ] Form is pre-populated with the post's current title + body.
|
||||
- [ ] Slug is rendered read-only below the title.
|
||||
- [ ] For a published post, the UI notes the slug is locked.
|
||||
- [ ] Saving updates the row; public `/` reflects the change immediately (no caching delay).
|
||||
|
||||
### Publish / unpublish / delete
|
||||
|
||||
- [ ] Publish button on a draft row flips the status to published and shows a "published" flash on the next dashboard load.
|
||||
- [ ] Unpublish button on a published row flips the status back to draft; `published_at` is preserved in the DB (check via `sqlite3 data/app.db`).
|
||||
- [ ] Delete button on any row removes it entirely after confirmation.
|
||||
|
||||
### About page edit (`/admin/pages/about/edit`)
|
||||
|
||||
- [ ] Form renders with the current About title and body.
|
||||
- [ ] There is no slug editor.
|
||||
- [ ] Saving updates the row and the public `/about` page on next load.
|
||||
|
||||
### Media upload
|
||||
|
||||
- [ ] `data/media/<yyyy>/<mm>/` is created lazily the first time an image is saved.
|
||||
- [ ] Uploaded files are stored under a random filename ending in `.jpg` regardless of the source format.
|
||||
- [ ] Hitting the `/media/<yyyy>/<mm>/<name>.jpg` URL directly serves the image at HTTP 200.
|
||||
- [ ] An uploaded transparent PNG comes through as an RGB JPEG (transparent areas become white).
|
||||
- [ ] Uploading an animated GIF is rejected with a generic error.
|
||||
- [ ] Uploading a >8 MB file is rejected.
|
||||
- [ ] Uploading while the `X-CSRF-Token` header is missing returns 403.
|
||||
|
||||
### CSRF
|
||||
|
||||
- [ ] Admin POST routes (`/admin/logout`, `/admin/posts`, `/admin/posts/*/delete`, `/admin/posts/*/publish`, `/admin/pages/about`, `/admin/media/upload`, `/admin/preview`) all return 403 when the submitted token does not match the cookie.
|
||||
- [ ] A legitimate form submission succeeds because both the cookie and the form field were issued during the previous GET.
|
||||
|
||||
### Public site regression
|
||||
|
||||
- [ ] `/` shows only published posts (newly created drafts do NOT appear).
|
||||
- [ ] A newly-published post shows at the top of `/` within one request.
|
||||
- [ ] `/about` shows the most recently edited copy.
|
||||
- [ ] No admin-facing text (status, dashboard wording) leaks into the public HTML.
|
||||
|
||||
Reference in New Issue
Block a user