Picarones / tests /web /test_csrf.py
Claude
feat(sprint-H.4)!: drop _legacy/ prefix — interfaces/{cli,web} consolidées
de9192c unverified
"""Tests Sprint A4 — protection CSRF (item B-11 de l'audit).
Couvre les deux modes de l'application :
- **Mode public** (``PICARONES_CSRF_REQUIRED`` non défini) : le
middleware est no-op, tous les POST passent sans token. C'est le
régime du HuggingFace Space où il n'y a pas de session
authentifiée à protéger.
- **Mode institutionnel** (``PICARONES_CSRF_REQUIRED=1``) : tout
POST/PUT/DELETE/PATCH non exempt exige un token cookie + header
signé HMAC. Régime cible BnF derrière SSO.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def public_client(monkeypatch) -> TestClient:
"""Client en mode public (CSRF désactivé)."""
monkeypatch.delenv("PICARONES_CSRF_REQUIRED", raising=False)
# On ré-importe l'app pour que le middleware lise la variable à
# chaque requête (il le fait déjà via ``is_csrf_required()`` à chaque
# appel — pas besoin de reload du module).
from picarones.interfaces.web.app import app
return TestClient(app)
@pytest.fixture
def institutional_client(monkeypatch) -> TestClient:
"""Client en mode institutionnel (CSRF activé)."""
monkeypatch.setenv("PICARONES_CSRF_REQUIRED", "1")
monkeypatch.setenv("PICARONES_CSRF_SECRET", "test-secret-do-not-use-in-prod" * 2)
from picarones.interfaces.web.app import app
return TestClient(app)
# ---------------------------------------------------------------------------
# Mode public — bypass complet
# ---------------------------------------------------------------------------
def test_public_mode_post_succeeds_without_token(public_client: TestClient) -> None:
"""En mode public, un POST sans token CSRF doit passer normalement."""
# ``/api/lang/{lang_code}`` est un POST simple sans dépendance lourde
r = public_client.post("/api/lang/fr")
assert r.status_code == 200, r.text
def test_public_mode_csrf_endpoint_returns_disabled(public_client: TestClient) -> None:
"""``/api/csrf/token`` doit signaler ``enabled=false`` en mode public."""
r = public_client.get("/api/csrf/token")
assert r.status_code == 200
body = r.json()
assert body["enabled"] is False
assert body["token"] is None
# ---------------------------------------------------------------------------
# Mode institutionnel — protection active
# ---------------------------------------------------------------------------
def test_institutional_post_without_token_returns_403(
institutional_client: TestClient,
) -> None:
"""En mode institutionnel, un POST sans token doit renvoyer 403."""
r = institutional_client.post("/api/lang/fr")
assert r.status_code == 403
assert "CSRF" in r.json()["detail"]
def test_institutional_post_with_only_cookie_returns_403(
institutional_client: TestClient,
) -> None:
"""Cookie présent mais header absent → 403 (le pattern double-submit
exige les deux)."""
# 1. Récupérer un token via /api/csrf/token (qui pose le cookie)
r = institutional_client.get("/api/csrf/token")
token = r.json()["token"]
# 2. POST avec le cookie automatique mais sans header
r2 = institutional_client.post("/api/lang/fr")
assert r2.status_code == 403
assert token # sanity : le token a bien été retourné
def test_institutional_post_with_only_header_returns_403(
institutional_client: TestClient,
) -> None:
"""Header présent mais cookie absent → 403."""
# On force l'absence de cookie en utilisant un nouveau client sans état
from picarones.interfaces.web.app import app
fresh = TestClient(app)
r = fresh.post(
"/api/lang/fr",
headers={"X-CSRF-Token": "deadbeef.cafebabe"},
)
assert r.status_code == 403
def test_institutional_post_with_valid_token_succeeds(
institutional_client: TestClient,
) -> None:
"""Token cookie + header identiques et signature valide → 200."""
r = institutional_client.get("/api/csrf/token")
token = r.json()["token"]
assert token is not None
# Le cookie est posé automatiquement par TestClient ; on injecte
# le même token dans le header.
r2 = institutional_client.post(
"/api/lang/fr",
headers={"X-CSRF-Token": token},
)
assert r2.status_code == 200, r2.text
def test_institutional_post_with_mismatched_token_returns_403(
institutional_client: TestClient,
) -> None:
"""Cookie et header différents → 403 (anti-CSRF en double-submit)."""
institutional_client.get("/api/csrf/token") # pose le cookie
r = institutional_client.post(
"/api/lang/fr",
headers={"X-CSRF-Token": "0011223344556677.aabbccddeeff0011"},
)
assert r.status_code == 403
def test_institutional_post_with_forged_signature_returns_403(
institutional_client: TestClient,
monkeypatch,
) -> None:
"""Token au bon format mais signature non-HMAC valide → 403."""
# On forge un token cookie+header identiques mais signé avec un
# secret bidon — le middleware doit le rejeter.
forged = "deadbeefcafebabe1234567890abcdef.000102030405060708090a0b0c0d0e0f"
institutional_client.cookies.set("picarones_csrf", forged)
r = institutional_client.post(
"/api/lang/fr",
headers={"X-CSRF-Token": forged},
)
assert r.status_code == 403
# ---------------------------------------------------------------------------
# Endpoints exemptés
# ---------------------------------------------------------------------------
def test_health_is_csrf_exempt(institutional_client: TestClient) -> None:
"""``/health`` doit rester accessible sans token même en mode CSRF."""
r = institutional_client.get("/health")
assert r.status_code == 200
def test_csrf_token_endpoint_does_not_require_token(
institutional_client: TestClient,
) -> None:
"""``/api/csrf/token`` lui-même ne doit pas exiger un token (sinon
impossible de bootstraper)."""
# Le endpoint est en GET donc CSRF ne s'applique pas, mais on
# vérifie aussi qu'il est dans la liste des exemptions (au cas où
# un PR futur le passerait en POST).
from picarones.interfaces.web.security import CSRF_EXEMPT_PATH_PREFIXES
assert any(
"/api/csrf/token".startswith(p) for p in CSRF_EXEMPT_PATH_PREFIXES
)
# ---------------------------------------------------------------------------
# Helpers internes
# ---------------------------------------------------------------------------
def test_generate_then_verify_token_round_trip(monkeypatch) -> None:
"""``generate_csrf_token`` produit un token que ``verify_csrf_token``
valide. Garantit le round-trip de signature."""
monkeypatch.setenv("PICARONES_CSRF_SECRET", "round-trip-secret-32-bytes-ok!")
from picarones.interfaces.web.security import generate_csrf_token, verify_csrf_token
token = generate_csrf_token()
assert verify_csrf_token(token) is True
def test_verify_token_rejects_garbage(monkeypatch) -> None:
monkeypatch.setenv("PICARONES_CSRF_SECRET", "round-trip-secret-32-bytes-ok!")
from picarones.interfaces.web.security import verify_csrf_token
assert verify_csrf_token(None) is False
assert verify_csrf_token("") is False
assert verify_csrf_token("no-dot") is False
assert verify_csrf_token("bad.hex") is False
assert verify_csrf_token("deadbeef.deadbeef") is False # ok format, signature wrong
def test_csrf_disabled_by_default(monkeypatch) -> None:
"""Garantit qu'on a bien posé la rétrocompat HuggingFace : sans la
variable d'env, ``is_csrf_required()`` retourne False."""
monkeypatch.delenv("PICARONES_CSRF_REQUIRED", raising=False)
from picarones.interfaces.web.security import is_csrf_required
assert is_csrf_required() is False
@pytest.mark.parametrize("value,expected", [
("1", True),
("true", True),
("yes", True),
# NB : pattern aligné avec ``is_public_mode()`` — sensible à la casse
# (convention pré-existante). ``TRUE`` n'est pas accepté par design.
("0", False),
("false", False),
("", False),
("anything", False),
("TRUE", False), # documenté : majuscule rejetée
])
def test_csrf_env_var_parsing(monkeypatch, value: str, expected: bool) -> None:
monkeypatch.setenv("PICARONES_CSRF_REQUIRED", value)
from picarones.interfaces.web.security import is_csrf_required
assert is_csrf_required() is expected