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>
180 lines
5.5 KiB
Python
180 lines
5.5 KiB
Python
"""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,
|
|
)
|