Spaces:
Running
Running
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 | |
| 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) | |
| 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 | |
| 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 | |