Files
ENI-projet-piscine/apps/backend/tests/api/test_hardening.py
T
Johan LEROY e8f22bf427 feat(backend): durcit la surface exposée
En-têtes de sécurité, CORS resserré, caviardage des journaux, `/metrics`
derrière un jeton facultatif, documentation fermée en préproduction, et
la sonde de disponibilité cesse de publier la version de TimescaleDB.

HSTS et CSP sont volontairement absents : l'application ignore si TLS
termine devant elle, et une CSP sur une API JSON ne protège presque rien.
Les deux appartiennent au terminateur TLS, celle qui compte protège la
page Angular.

`/metrics` est gardé par un jeton statique et non par un rôle : coupler
la supervision au modèle d'utilisateurs casserait la collecte à chaque
panne d'authentification, c'est-à-dire quand on en a le plus besoin. Le
contrôle principal reste le réseau.

Le caviardage est la troisième ligne de défense, pas la première. On ne
passe aucun secret au logger et aucun jeton dans une URL ; le filtre
rattrape ce que personne n'a relu, à commencer par l'écho SQL qui
publiait les empreintes Argon2 quand `debug` est actif.

Corrige un défaut que le test a révélé : `create_app(settings)` ne
pilotait que la construction, les dépendances continuaient de lire
`get_settings()` depuis l'environnement. Un test « en production » ne
testait donc pas la production, et `TESTING.md` promet le contraire.
2026-09-15 14:56:17 +02:00

112 lines
3.8 KiB
Python

import pytest
from httpx import ASGITransport, AsyncClient
from httpx import Response as HttpResponse
from app.main import create_app
from tests.factories import make_settings
ORIGINE = "https://enervision.fr"
async def interroge(
settings_overrides: dict[str, object], chemin: str, **kwargs: object
) -> HttpResponse:
application = create_app(make_settings(**settings_overrides))
transport = ASGITransport(app=application)
async with AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get(chemin, **kwargs) # type: ignore[arg-type]
@pytest.mark.parametrize(
("entete", "valeur"),
[
("x-content-type-options", "nosniff"),
("x-frame-options", "DENY"),
("referrer-policy", "no-referrer"),
],
ids=["nosniff", "anti_iframe", "referrer"],
)
async def test_every_response_carries_the_security_headers(
client: AsyncClient, entete: str, valeur: str
) -> None:
response = await client.get("/api/v1/health/live")
assert response.headers[entete] == valeur
async def test_the_application_never_sets_hsts_itself(client: AsyncClient) -> None:
response = await client.get("/api/v1/health/live")
assert "strict-transport-security" not in response.headers
@pytest.mark.parametrize(
"env",
["staging", "prod"],
ids=["preproduction", "production"],
)
async def test_the_documentation_disappears_outside_development(env: str) -> None:
surcharges = {"env": env, "cors_origins": ORIGINE}
for chemin in ("/docs", "/openapi.json"):
assert (await interroge(surcharges, chemin)).status_code == 404
@pytest.mark.parametrize("env", ["local", "dev"], ids=["local", "developpement"])
async def test_the_documentation_stays_available_while_developing(env: str) -> None:
surcharges = {"env": env, "cors_origins": ORIGINE}
assert (await interroge(surcharges, "/openapi.json")).status_code == 200
async def test_an_explicit_override_can_reopen_the_documentation() -> None:
surcharges = {"env": "prod", "cors_origins": ORIGINE, "expose_api_docs": True}
assert (await interroge(surcharges, "/openapi.json")).status_code == 200
async def test_metrics_stay_open_when_no_token_is_configured(client: AsyncClient) -> None:
response = await client.get("/metrics")
assert response.status_code == 200
async def test_metrics_demand_the_token_once_one_is_configured() -> None:
surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"}
assert (await interroge(surcharges, "/metrics")).status_code == 401
async def test_metrics_answer_to_the_right_token() -> None:
surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"}
entetes = {"Authorization": "Bearer un-jeton-de-supervision-assez-long"}
response = await interroge(surcharges, "/metrics", headers=entetes)
assert response.status_code == 200
async def test_metrics_refuse_a_token_that_is_almost_right() -> None:
surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"}
entetes = {"Authorization": "Bearer un-jeton-de-supervision-assez-lon"}
response = await interroge(surcharges, "/metrics", headers=entetes)
assert response.status_code == 401
async def test_an_unhandled_error_returns_a_correlation_id_and_no_traceback() -> None:
application = create_app(make_settings())
@application.get("/api/v1/essai-panne")
async def _casse() -> None:
raise RuntimeError("secret interne de la pile")
transport = ASGITransport(app=application, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/essai-panne")
assert response.status_code == 500
assert "secret interne de la pile" not in response.text
assert response.json()["correlation"]