Files
chicken_babies_site/tests/test_csrf_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

92 lines
3.7 KiB
Python

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