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:
2026-04-21 20:42:01 -05:00
parent 76875a455e
commit 9a8506970c
30 changed files with 3831 additions and 74 deletions

View File

@@ -0,0 +1,242 @@
"""Tests for :class:`app.services.admin_posts.AdminPostsService`."""
from __future__ import annotations
from pathlib import Path
from typing import Iterator
import pytest
from sqlalchemy import Engine, text
from app.db import build_engine, run_migrations
from app.models.entities import PostStatus
from app.models.seed import run_seed
from app.services.admin_posts import AdminPostsService
from app.services.audit import AuditService
from app.services.markdown import MarkdownService
from app.services.pages import PageService
from app.services.posts import PostService
# Dedicated function-scoped DB per test so publish / delete side
# effects don't leak between tests.
@pytest.fixture
def engine(tmp_path: Path) -> Iterator[Engine]:
"""Return a migrated + seeded engine in an isolated file."""
db_path = tmp_path / "phase4_posts.db"
eng = build_engine(f"sqlite:///{db_path}")
run_migrations(eng)
run_seed(eng)
yield eng
eng.dispose()
@pytest.fixture
def service(engine: Engine) -> AdminPostsService:
"""Return a fully-wired :class:`AdminPostsService`."""
md = MarkdownService()
posts = PostService(engine)
pages = PageService(engine)
audit = AuditService(engine)
return AdminPostsService(
engine=engine,
markdown=md,
post_service=posts,
page_service=pages,
audit=audit,
)
# ---------------------------------------------------------------------------
# create
# ---------------------------------------------------------------------------
def test_create_draft_post(service: AdminPostsService) -> None:
"""Create generates a slug, renders HTML, and leaves ``published_at`` NULL."""
post = service.create(
title="My First Post",
body_md="Hello **world**.",
status=PostStatus.DRAFT,
author_id=1,
)
assert post.id > 0
assert post.slug == "my-first-post"
assert post.status is PostStatus.DRAFT
assert post.published_at is None
# Markdown pipeline ran and wrote sanitized HTML.
assert "<strong>world</strong>" in post.body_html_cached
def test_create_publish_stamps_published_at(service: AdminPostsService) -> None:
"""``status=published`` sets ``published_at`` to the creation time."""
post = service.create(
title="Announcement",
body_md="body",
status=PostStatus.PUBLISHED,
author_id=1,
)
assert post.status is PostStatus.PUBLISHED
assert post.published_at is not None
def test_create_collision_yields_suffixed_slug(
service: AdminPostsService,
) -> None:
"""Second post with the same title slugs as ``foo-2``."""
a = service.create(
title="Chicks Day",
body_md="x",
status=PostStatus.DRAFT,
author_id=1,
)
b = service.create(
title="Chicks Day",
body_md="x",
status=PostStatus.DRAFT,
author_id=1,
)
assert a.slug == "chicks-day"
assert b.slug == "chicks-day-2"
# ---------------------------------------------------------------------------
# update
# ---------------------------------------------------------------------------
def test_update_changes_title_body_but_not_slug(
service: AdminPostsService,
) -> None:
"""Updates regenerate HTML and bump updated_at; slug is locked."""
post = service.create(
title="Original",
body_md="before",
status=PostStatus.DRAFT,
author_id=1,
)
updated = service.update(
post.id,
title="Original Retitled",
body_md="after",
actor_user_id=1,
)
assert updated is not None
# Slug is preserved — not regenerated even on title change.
assert updated.slug == post.slug
assert updated.title == "Original Retitled"
assert "after" in updated.body_html_cached
assert "before" not in updated.body_html_cached
def test_update_unknown_post_returns_none(service: AdminPostsService) -> None:
"""An update on a missing post id returns ``None``."""
assert service.update(99999, title="x", body_md="y", actor_user_id=1) is None
# ---------------------------------------------------------------------------
# toggle_publish
# ---------------------------------------------------------------------------
def test_toggle_publish_draft_to_published(service: AdminPostsService) -> None:
"""Draft → published stamps ``published_at`` once."""
post = service.create(
title="Togglable",
body_md="x",
status=PostStatus.DRAFT,
author_id=1,
)
published = service.toggle_publish(post.id, actor_user_id=1)
assert published is not None
assert published.status is PostStatus.PUBLISHED
first_published_at = published.published_at
assert first_published_at is not None
def test_toggle_publish_preserves_original_published_at(
service: AdminPostsService,
) -> None:
"""Unpublish → re-publish keeps the original ``published_at``."""
post = service.create(
title="Preserve",
body_md="x",
status=PostStatus.PUBLISHED,
author_id=1,
)
original_published_at = post.published_at
assert original_published_at is not None
unpublished = service.toggle_publish(post.id, actor_user_id=1)
assert unpublished is not None
assert unpublished.status is PostStatus.DRAFT
# published_at preserved on unpublish per Phase 4 brief.
assert unpublished.published_at == original_published_at
republished = service.toggle_publish(post.id, actor_user_id=1)
assert republished is not None
assert republished.status is PostStatus.PUBLISHED
# published_at preserved across the re-publish cycle.
assert republished.published_at == original_published_at
# ---------------------------------------------------------------------------
# delete
# ---------------------------------------------------------------------------
def test_delete_removes_row(service: AdminPostsService, engine: Engine) -> None:
"""Delete really removes the row — no soft-delete column exists."""
post = service.create(
title="DeleteMe",
body_md="x",
status=PostStatus.DRAFT,
author_id=1,
)
assert service.delete(post.id, actor_user_id=1) is True
with engine.connect() as conn:
row = conn.execute(
text("SELECT id FROM posts WHERE id = :id"),
{"id": post.id},
).first()
assert row is None
def test_delete_unknown_returns_false(service: AdminPostsService) -> None:
"""Deleting a nonexistent post id returns False without raising."""
assert service.delete(99999, actor_user_id=1) is False
# ---------------------------------------------------------------------------
# cache invalidation
# ---------------------------------------------------------------------------
def test_create_invalidates_public_post_cache(
engine: Engine,
) -> None:
"""Creating a post clears the :class:`PostService` cache.
We prime the cache by calling ``list_published``, then use the
admin service to insert a published post, and confirm the next
``list_published`` call returns a freshly-built list (identity
different, content includes the new slug).
"""
md = MarkdownService()
posts = PostService(engine)
pages = PageService(engine)
audit = AuditService(engine)
service = AdminPostsService(
engine=engine,
markdown=md,
post_service=posts,
page_service=pages,
audit=audit,
)
primed = posts.list_published()
# Hit the cache so we have a concrete reference to compare against.
still_cached = posts.list_published()
assert primed is still_cached
created = service.create(
title="Fresh Post",
body_md="x",
status=PostStatus.PUBLISHED,
author_id=1,
)
after = posts.list_published()
assert after is not primed
assert any(p.slug == created.slug for p in after)