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,411 @@
"""End-to-end HTTP tests for the Phase 4 admin CMS.
Exercises the real :class:`FastAPI` app constructed by
:func:`app.main.create_app` against a temp-file SQLite database plus
a temp media directory. Uses the email-capture trick from
``tests/test_admin_routes.py`` to complete the magic-link login so we
have a real authenticated session + CSRF cookie.
"""
from __future__ import annotations
import importlib
import io
import re
from pathlib import Path
from typing import Iterator
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from sqlalchemy import text
_CSRF_META_RE = re.compile(
r'<meta name="csrf-token" content="([^"]*)"'
)
@pytest.fixture
def cms_app(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> Iterator[tuple[TestClient, dict, Path]]:
"""Build a fresh authed app + temp media dir + captured email URL."""
media_root = tmp_path / "media"
media_root.mkdir()
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path}/cms.db")
monkeypatch.setenv("ADMIN_EMAILS", "headhen@example.com")
monkeypatch.setenv("APP_ENV", "development")
monkeypatch.setenv(
"SECRET_KEY", "test-only-secret-key-0123456789abcdef-XYZ"
)
monkeypatch.setenv("RESEND_API_KEY", "")
monkeypatch.setenv("PUBLIC_BASE_URL", "http://testserver")
monkeypatch.setenv("MEDIA_ROOT", str(media_root))
from app import config as _config
_config.get_settings.cache_clear()
import app.main as main_module
importlib.reload(main_module)
app = main_module.app
captured: dict[str, str] = {}
def _capture(**kw):
captured["url"] = kw["url"]
captured["to"] = kw["to"]
app.state.email_service.send_magic_link = _capture # type: ignore[assignment]
from app.services.rate_limit import limiter
limiter.reset()
with TestClient(app) as client:
yield client, captured, media_root
_config.get_settings.cache_clear()
def _login(client: TestClient, captured: dict) -> None:
"""Walk the magic-link flow so subsequent requests are authed."""
client.post("/admin/login", data={"email": "headhen@example.com"})
assert "url" in captured
consume_path = captured["url"].replace("http://testserver", "")
resp = client.get(consume_path, follow_redirects=False)
assert resp.status_code == 303
def _dashboard_csrf(client: TestClient) -> str:
"""GET /admin and extract the csrf-token meta tag."""
resp = client.get("/admin")
assert resp.status_code == 200
match = _CSRF_META_RE.search(resp.text)
assert match, "csrf-token meta tag missing"
return match.group(1)
# ---------------------------------------------------------------------------
# unauthed
# ---------------------------------------------------------------------------
def test_dashboard_redirects_unauthed_to_login(cms_app) -> None:
"""An unauthenticated GET /admin yields 303 to /admin/login."""
client, _, _ = cms_app
resp = client.get("/admin", follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/admin/login"
def test_post_create_without_auth_is_redirected(cms_app) -> None:
"""Create POST is gated behind require_admin."""
client, _, _ = cms_app
resp = client.post(
"/admin/posts",
data={"title": "x", "body_md": "y", "status": "draft", "csrf_token": ""},
follow_redirects=False,
)
# Unauthenticated -> require_admin raises 303.
assert resp.status_code == 303
# ---------------------------------------------------------------------------
# dashboard
# ---------------------------------------------------------------------------
def test_dashboard_renders_with_seeded_post(cms_app) -> None:
"""The seeded welcome post shows up in the admin table."""
client, captured, _ = cms_app
_login(client, captured)
resp = client.get("/admin")
assert resp.status_code == 200
assert "Dashboard" in resp.text
assert "Welcome to the Farm" in resp.text
assert "New post" in resp.text
assert "Edit About" in resp.text
# ---------------------------------------------------------------------------
# create + edit + delete
# ---------------------------------------------------------------------------
def test_create_post_happy_path(cms_app) -> None:
"""POST /admin/posts with CSRF creates a row and 303s to the dashboard."""
client, captured, _ = cms_app
_login(client, captured)
csrf = _dashboard_csrf(client)
resp = client.post(
"/admin/posts",
data={
"title": "First Admin Post",
"body_md": "Hello **world**.",
"status": "draft",
"csrf_token": csrf,
},
follow_redirects=False,
)
assert resp.status_code == 303
assert resp.headers["location"].startswith("/admin?msg=created")
# The new post is visible on the dashboard.
dash = client.get("/admin")
assert "First Admin Post" in dash.text
def test_create_post_without_csrf_is_forbidden(cms_app) -> None:
"""Missing csrf_token on create returns 403."""
client, captured, _ = cms_app
_login(client, captured)
# Touch /admin to materialize the cb_csrf cookie.
client.get("/admin")
resp = client.post(
"/admin/posts",
data={
"title": "Sneaky",
"body_md": "x",
"status": "draft",
"csrf_token": "", # wrong
},
follow_redirects=False,
)
assert resp.status_code == 403
def test_update_and_publish_and_delete(cms_app) -> None:
"""Full CRUD + publish toggle + delete cycle."""
client, captured, _ = cms_app
_login(client, captured)
csrf = _dashboard_csrf(client)
# create
client.post(
"/admin/posts",
data={
"title": "Toggle Target",
"body_md": "body-v1",
"status": "draft",
"csrf_token": csrf,
},
)
# look up the post id via the engine
with client.app.state.engine.connect() as conn:
row = conn.execute(
text("SELECT id FROM posts WHERE title = :t"),
{"t": "Toggle Target"},
).mappings().first()
assert row is not None
post_id = int(row["id"])
# edit form renders
edit_resp = client.get(f"/admin/posts/{post_id}/edit")
assert edit_resp.status_code == 200
assert "Toggle Target" in edit_resp.text
# CSRF for subsequent POSTs should come off the edit page.
match = _CSRF_META_RE.search(edit_resp.text)
assert match
csrf = match.group(1)
# update
upd = client.post(
f"/admin/posts/{post_id}",
data={
"title": "Toggle Target Edited",
"body_md": "body-v2",
"csrf_token": csrf,
},
follow_redirects=False,
)
assert upd.status_code == 303
# publish
pub = client.post(
f"/admin/posts/{post_id}/publish",
data={"csrf_token": csrf},
follow_redirects=False,
)
assert pub.status_code == 303
assert pub.headers["location"] == "/admin?msg=published"
# unpublish
unpub = client.post(
f"/admin/posts/{post_id}/publish",
data={"csrf_token": csrf},
follow_redirects=False,
)
assert unpub.status_code == 303
assert unpub.headers["location"] == "/admin?msg=unpublished"
# delete
delete = client.post(
f"/admin/posts/{post_id}/delete",
data={"csrf_token": csrf},
follow_redirects=False,
)
assert delete.status_code == 303
assert delete.headers["location"] == "/admin?msg=deleted"
# row is gone
with client.app.state.engine.connect() as conn:
gone = conn.execute(
text("SELECT id FROM posts WHERE id = :i"),
{"i": post_id},
).first()
assert gone is None
def test_create_post_rejects_blank_title(cms_app) -> None:
"""Blank title re-renders the form at 400."""
client, captured, _ = cms_app
_login(client, captured)
csrf = _dashboard_csrf(client)
resp = client.post(
"/admin/posts",
data={
"title": " ",
"body_md": "x",
"status": "draft",
"csrf_token": csrf,
},
)
assert resp.status_code == 400
assert "Title is required" in resp.text
# ---------------------------------------------------------------------------
# About page
# ---------------------------------------------------------------------------
def test_about_update(cms_app) -> None:
"""Editing the About page updates the stored copy."""
client, captured, _ = cms_app
_login(client, captured)
form_resp = client.get("/admin/pages/about/edit")
assert form_resp.status_code == 200
match = _CSRF_META_RE.search(form_resp.text)
assert match
csrf = match.group(1)
resp = client.post(
"/admin/pages/about",
data={
"title": "Re-titled About",
"body_md": "Edited **copy**.",
"csrf_token": csrf,
},
follow_redirects=False,
)
assert resp.status_code == 303
assert resp.headers["location"] == "/admin?msg=saved"
# Public site reflects the change (cache was invalidated).
public = client.get("/about")
assert public.status_code == 200
assert "Re-titled About" in public.text
assert "<strong>copy</strong>" in public.text
# ---------------------------------------------------------------------------
# media upload
# ---------------------------------------------------------------------------
def _jpeg_bytes() -> bytes:
img = Image.new("RGB", (32, 32), "blue")
buf = io.BytesIO()
img.save(buf, format="JPEG")
return buf.getvalue()
def test_media_upload_happy_path(cms_app) -> None:
"""Valid JPEG upload with header CSRF returns JSON with a /media URL."""
client, captured, media_root = cms_app
_login(client, captured)
csrf = _dashboard_csrf(client)
data = _jpeg_bytes()
files = {"file": ("photo.jpg", data, "image/jpeg")}
resp = client.post(
"/admin/media/upload",
files=files,
headers={"X-CSRF-Token": csrf},
)
assert resp.status_code == 200, resp.text
payload = resp.json()
assert payload["url"].startswith("/media/")
assert payload["filename"].endswith(".jpg")
# The file actually exists under the media root.
stored = Path(media_root)
assert any(stored.rglob("*.jpg"))
def test_media_upload_missing_csrf_is_forbidden(cms_app) -> None:
"""Without the X-CSRF-Token header the upload is 403."""
client, captured, _ = cms_app
_login(client, captured)
# Warm cookie.
client.get("/admin")
files = {"file": ("x.jpg", _jpeg_bytes(), "image/jpeg")}
resp = client.post("/admin/media/upload", files=files)
assert resp.status_code == 403
def test_media_upload_rejects_non_image(cms_app) -> None:
"""Plain text rejected with a JSON error payload."""
client, captured, _ = cms_app
_login(client, captured)
csrf = _dashboard_csrf(client)
files = {"file": ("fake.jpg", b"not an image at all, really not", "image/jpeg")}
resp = client.post(
"/admin/media/upload",
files=files,
headers={"X-CSRF-Token": csrf},
)
assert resp.status_code == 400
assert "error" in resp.json()
# ---------------------------------------------------------------------------
# preview
# ---------------------------------------------------------------------------
def test_preview_renders_sanitized_html(cms_app) -> None:
"""Preview endpoint returns the same sanitized HTML as server-side."""
client, captured, _ = cms_app
_login(client, captured)
csrf = _dashboard_csrf(client)
resp = client.post(
"/admin/preview",
data={"markdown": "Hello **there**."},
headers={"X-CSRF-Token": csrf},
)
assert resp.status_code == 200
assert "<strong>there</strong>" in resp.text
def test_preview_strips_raw_html(cms_app) -> None:
"""Raw HTML in the markdown input is sanitized away."""
client, captured, _ = cms_app
_login(client, captured)
csrf = _dashboard_csrf(client)
resp = client.post(
"/admin/preview",
data={"markdown": "<script>alert(1)</script>\n\nOK."},
headers={"X-CSRF-Token": csrf},
)
assert resp.status_code == 200
assert "<script>" not in resp.text
def test_preview_without_csrf_is_forbidden(cms_app) -> None:
"""Preview endpoint enforces CSRF via header."""
client, captured, _ = cms_app
_login(client, captured)
client.get("/admin") # warm cookie
resp = client.post(
"/admin/preview",
data={"markdown": "hi"},
)
assert resp.status_code == 403

View File

@@ -0,0 +1,97 @@
"""Tests for :class:`app.services.admin_pages.AdminPagesService`."""
from __future__ import annotations
from pathlib import Path
from typing import Iterator
import pytest
from sqlalchemy import Engine
from app.db import build_engine, run_migrations
from app.models.seed import run_seed
from app.services.admin_pages import AdminPagesService
from app.services.audit import AuditService
from app.services.markdown import MarkdownService
from app.services.pages import PageService
from app.services.posts import PostService
@pytest.fixture
def engine(tmp_path: Path) -> Iterator[Engine]:
"""Return an isolated migrated + seeded engine."""
db_path = tmp_path / "phase4_pages.db"
eng = build_engine(f"sqlite:///{db_path}")
run_migrations(eng)
run_seed(eng)
yield eng
eng.dispose()
@pytest.fixture
def service(engine: Engine) -> AdminPagesService:
"""Return a fully-wired :class:`AdminPagesService`."""
return AdminPagesService(
engine=engine,
markdown=MarkdownService(),
page_service=PageService(engine),
post_service=PostService(engine),
audit=AuditService(engine),
)
def test_get_about_returns_seeded_page(service: AdminPagesService) -> None:
"""The seeded About page loads."""
page = service.get_about()
assert page is not None
assert page.slug == "about"
assert page.title == "About the Farm"
def test_update_about_rewrites_html_cache(
service: AdminPagesService,
) -> None:
"""Update regenerates the sanitized HTML and bumps ``updated_at``."""
before = service.get_about()
assert before is not None
updated = service.update_about(
title="New About Title",
body_md="Some **bold** copy.",
actor_user_id=1,
)
assert updated is not None
assert updated.slug == "about" # slug immutable
assert updated.title == "New About Title"
assert "<strong>bold</strong>" in updated.body_html_cached
# updated_at should bump (or at least not go backwards).
assert updated.updated_at >= before.updated_at
def test_update_about_invalidates_public_page_cache(engine: Engine) -> None:
"""After an update the public :class:`PageService` cache returns fresh copy."""
md = MarkdownService()
pages = PageService(engine)
posts = PostService(engine)
audit = AuditService(engine)
service = AdminPagesService(
engine=engine,
markdown=md,
page_service=pages,
post_service=posts,
audit=audit,
)
primed = pages.get_by_slug("about")
# Cache hit confirmation:
assert pages.get_by_slug("about") is primed
service.update_about(
title="After",
body_md="updated copy",
actor_user_id=1,
)
after = pages.get_by_slug("about")
assert after is not None
assert after is not primed
assert after.title == "After"

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)

View File

@@ -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"

View File

@@ -0,0 +1,91 @@
"""Tests for :class:`app.services.csrf.CSRFService`."""
from __future__ import annotations
import pytest
from itsdangerous import URLSafeTimedSerializer
from app.services.csrf import CSRF_COOKIE_NAME, CSRFService
@pytest.fixture
def signer() -> URLSafeTimedSerializer:
"""Return an ``itsdangerous`` signer with the canonical CSRF salt."""
return URLSafeTimedSerializer("test-key-for-csrf-service-0123456789", salt="csrf")
@pytest.fixture
def service(signer: URLSafeTimedSerializer) -> CSRFService:
"""Return a dev-mode :class:`CSRFService` (Secure flag off)."""
return CSRFService(signer, production=False)
def test_issue_returns_matching_token_and_cookie(service: CSRFService) -> None:
"""Without a prior cookie, :meth:`issue` mints a fresh pair that verify.
The token and the cookie value are the same signed string — by
design — but the critical property is that ``verify`` accepts them
as a match.
"""
token, cookie = service.issue(existing_cookie=None)
assert token
assert cookie
assert service.verify(cookie_value=cookie, submitted=token) is True
def test_issue_reuses_nonce_when_cookie_valid(service: CSRFService) -> None:
"""A still-valid cookie is reused so open admin tabs keep working."""
_, cookie = service.issue()
token2, cookie2 = service.issue(existing_cookie=cookie)
# Cookie value is deterministic post-nonce but itsdangerous signs
# with a timestamp, so the signed strings differ even when the
# underlying nonce is reused. The cross-pair verify matters.
assert service.verify(cookie_value=cookie, submitted=token2) is True
assert service.verify(cookie_value=cookie2, submitted=token2) is True
def test_verify_rejects_tampered_token(service: CSRFService) -> None:
"""A single-char tweak to the signed token breaks the signature."""
token, cookie = service.issue()
# Flip a char in the signature payload.
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
assert service.verify(cookie_value=cookie, submitted=tampered) is False
def test_verify_rejects_different_cookie(service: CSRFService) -> None:
"""Two separately-issued nonces never match each other."""
t1, c1 = service.issue()
t2, c2 = service.issue()
assert service.verify(cookie_value=c1, submitted=t2) is False
assert service.verify(cookie_value=c2, submitted=t1) is False
def test_verify_rejects_missing_values(service: CSRFService) -> None:
"""Empty or None inputs fail closed."""
assert service.verify(cookie_value=None, submitted="x") is False
assert service.verify(cookie_value="x", submitted=None) is False
assert service.verify(cookie_value="", submitted="") is False
def test_verify_rejects_different_key(signer: URLSafeTimedSerializer) -> None:
"""Tokens signed by a different key never verify."""
service_a = CSRFService(signer, production=False)
other_signer = URLSafeTimedSerializer("DIFFERENT-KEY-XYZ", salt="csrf")
service_b = CSRFService(other_signer, production=False)
_, cookie_a = service_a.issue()
token_b, _ = service_b.issue()
assert service_a.verify(cookie_value=cookie_a, submitted=token_b) is False
def test_cookie_params_production_sets_secure(signer: URLSafeTimedSerializer) -> None:
"""``Secure=True`` only when constructed with ``production=True``."""
dev = CSRFService(signer, production=False)
prod = CSRFService(signer, production=True)
assert dev.cookie_params()["secure"] is False
assert prod.cookie_params()["secure"] is True
assert dev.cookie_params()["key"] == CSRF_COOKIE_NAME
# HttpOnly is intentionally off so JS can read the cookie for
# double-submit header POSTs.
assert dev.cookie_params()["httponly"] is False
assert dev.cookie_params()["samesite"] == "lax"

179
tests/test_media_service.py Normal file
View File

@@ -0,0 +1,179 @@
"""Tests for :class:`app.services.media.MediaService`.
Uses real bytes produced by Pillow in-test so the magic-byte check
exercises real image data. Does NOT mock the filesystem — writes go
to a per-test temp dir so we can assert the stored JPEG is a valid
JPEG after the re-encode pipeline.
"""
from __future__ import annotations
import io
from pathlib import Path
import pytest
from PIL import Image
from sqlalchemy import Engine
from app.services.audit import AuditService
from app.services.media import (
MAX_UPLOAD_BYTES,
MediaRejectedError,
MediaService,
)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _jpeg_bytes(size: tuple[int, int] = (64, 48), color: str = "red") -> bytes:
"""Return valid JPEG bytes produced by Pillow."""
img = Image.new("RGB", size, color)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
return buf.getvalue()
def _rgba_png_bytes(size: tuple[int, int] = (32, 32)) -> bytes:
"""Return valid RGBA-transparent PNG bytes."""
img = Image.new("RGBA", size, (0, 128, 0, 128))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
@pytest.fixture
def media_root(tmp_path: Path) -> Path:
"""Return a per-test directory rooted under the pytest tmpdir."""
root = tmp_path / "media"
root.mkdir()
return root
@pytest.fixture
def service(db_engine: Engine, media_root: Path) -> MediaService:
"""Return a :class:`MediaService` wired to a real engine + temp dir."""
return MediaService(
engine=db_engine,
media_root=str(media_root),
audit=AuditService(db_engine),
)
# ---------------------------------------------------------------------------
# happy path
# ---------------------------------------------------------------------------
def test_save_upload_stores_jpeg_and_returns_media(
service: MediaService, media_root: Path
) -> None:
"""A valid JPEG is re-encoded and written under a random filename."""
data = _jpeg_bytes()
media = service.save_upload(
original_filename="cute-chick.jpg",
data=data,
uploaded_by=1,
)
assert media.id > 0
assert media.content_type == "image/jpeg"
assert media.filename.endswith(".jpg")
assert media.original_filename == "cute-chick.jpg"
assert media.size_bytes > 0
stored = Path(media.stored_path)
assert stored.exists()
assert stored.parent.parent.parent == media_root
# Verify the written file is a real JPEG RGB image.
with Image.open(stored) as img:
assert img.format == "JPEG"
assert img.mode == "RGB"
def test_save_upload_assigns_random_filename_not_original(
service: MediaService,
) -> None:
"""The stored filename is a random token — not the client-supplied name."""
data = _jpeg_bytes()
media = service.save_upload(
original_filename="secret.jpg",
data=data,
uploaded_by=1,
)
assert media.filename != "secret.jpg"
# Random component is 16 bytes url-safe (~22 chars) + ".jpg".
name, _, ext = media.filename.rpartition(".")
assert ext == "jpg"
assert len(name) >= 16
def test_public_url_is_under_media_prefix(service: MediaService) -> None:
"""``public_url`` starts with ``/media/`` and a year partition."""
media = service.save_upload(
original_filename="ok.jpg",
data=_jpeg_bytes(),
uploaded_by=1,
)
url = service.public_url(media)
assert url.startswith("/media/")
assert url.endswith(media.filename)
def test_save_upload_flattens_rgba_onto_white(service: MediaService) -> None:
"""An RGBA PNG upload is flattened and stored as RGB JPEG."""
data = _rgba_png_bytes()
media = service.save_upload(
original_filename="transparent.png",
data=data,
uploaded_by=1,
)
stored = Path(media.stored_path)
with Image.open(stored) as img:
assert img.format == "JPEG"
assert img.mode == "RGB"
# ---------------------------------------------------------------------------
# rejection paths
# ---------------------------------------------------------------------------
def test_reject_empty_upload(service: MediaService) -> None:
"""Empty uploads are rejected before any decode runs."""
with pytest.raises(MediaRejectedError):
service.save_upload(
original_filename="empty.jpg",
data=b"",
uploaded_by=1,
)
def test_reject_non_image_bytes(service: MediaService) -> None:
"""A plain-text payload fails the magic-byte sniff."""
with pytest.raises(MediaRejectedError):
service.save_upload(
original_filename="totally.jpg",
data=b"this is definitely not an image payload" * 10,
uploaded_by=1,
)
def test_reject_gif_upload(service: MediaService) -> None:
"""Animated-GIF hazard — GIFs are explicitly not on the allowlist."""
img = Image.new("RGB", (16, 16), "green")
buf = io.BytesIO()
img.save(buf, format="GIF")
with pytest.raises(MediaRejectedError):
service.save_upload(
original_filename="animated.gif",
data=buf.getvalue(),
uploaded_by=1,
)
def test_reject_oversize_upload(service: MediaService) -> None:
"""Payloads over the 8 MB cap are rejected."""
huge = b"\xff" * (MAX_UPLOAD_BYTES + 1)
with pytest.raises(MediaRejectedError):
service.save_upload(
original_filename="huge.jpg",
data=huge,
uploaded_by=1,
)

68
tests/test_slugs.py Normal file
View File

@@ -0,0 +1,68 @@
"""Tests for :mod:`app.services.slugs`."""
from __future__ import annotations
import pytest
from app.services.slugs import ensure_unique, slugify
# ---------------------------------------------------------------------------
# slugify
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"title, expected",
[
("Hello World", "hello-world"),
("Hello, World!", "hello-world"),
(" leading and trailing ", "leading-and-trailing"),
("CamelCaseTitle", "camelcasetitle"),
("Multiple spaces", "multiple-spaces"),
("dashes---everywhere", "dashes-everywhere"),
("mix-of 99 items & stuff", "mix-of-99-items-stuff"),
("Café au lait", "caf-au-lait"), # non-ASCII dropped
("", "post"), # fallback
("...", "post"), # all-punctuation → fallback
("123", "123"), # digits allowed
("snake_case", "snake-case"),
],
)
def test_slugify_expected(title: str, expected: str) -> None:
""":func:`slugify` produces the documented output per input."""
assert slugify(title) == expected
def test_slugify_no_leading_or_trailing_hyphens() -> None:
"""Slugs never have dangling hyphens regardless of input shape."""
assert slugify("!!hello!!") == "hello"
assert slugify("--leading and trailing--") == "leading-and-trailing"
# ---------------------------------------------------------------------------
# ensure_unique
# ---------------------------------------------------------------------------
def test_ensure_unique_returns_base_when_free() -> None:
"""If nothing collides, the base slug is returned unchanged."""
taken: set[str] = set()
result = ensure_unique("hello", lambda s: s in taken)
assert result == "hello"
def test_ensure_unique_suffixes_on_collision() -> None:
"""Collisions produce -2, -3, etc. in order."""
taken = {"hello", "hello-2", "hello-3"}
result = ensure_unique("hello", lambda s: s in taken)
assert result == "hello-4"
def test_ensure_unique_suffix_sequence_starts_at_two() -> None:
"""First collision uses ``-2`` — never ``-1`` or ``-0``."""
taken = {"hello"}
result = ensure_unique("hello", lambda s: s in taken)
assert result == "hello-2"
def test_ensure_unique_raises_when_exhausted() -> None:
"""A pathological ``exists`` callable hits the guard ceiling."""
with pytest.raises(RuntimeError):
ensure_unique("hello", lambda s: True, max_attempts=3)