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>
92 lines
3.7 KiB
Python
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"
|