Files
chicken_babies_site/tests/test_media_service.py
Phillip Tarrant 9a8506970c 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>
2026-04-21 20:42:01 -05:00

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,
)