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:
411
tests/test_admin_cms_routes.py
Normal file
411
tests/test_admin_cms_routes.py
Normal 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
|
||||
97
tests/test_admin_pages_service.py
Normal file
97
tests/test_admin_pages_service.py
Normal 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"
|
||||
242
tests/test_admin_posts_service.py
Normal file
242
tests/test_admin_posts_service.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
|
||||
91
tests/test_csrf_service.py
Normal file
91
tests/test_csrf_service.py
Normal 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
179
tests/test_media_service.py
Normal 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
68
tests/test_slugs.py
Normal 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)
|
||||
Reference in New Issue
Block a user