- Une ligne de log par requête (route modèle, statut, durée, code d'erreur, compte), identifiant X-Request-ID repris dans chaque log et affiché à l'utilisateur sous une erreur, exceptions non gérées tracées (500 JSON) - Journal d'accès d'uvicorn coupé : il exposait le jeton du formulaire public - Logs métier : paiement SumUp, connexion, import, plannings, solveur - Nom du bénévole dans la description du checkout SumUp - Code et identifiant de transaction SumUp enregistrés au paiement (migration 0023), affichés sur la fiche et dans l'export Excel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
2.8 KiB
Python
79 lines
2.8 KiB
Python
"""Journal des requêtes (`app/request_logging.py`) : référence de requête,
|
|
route modèle sans jeton ni chaîne de requête, erreurs détaillées sans
|
|
valeur saisie."""
|
|
|
|
import logging
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.main import app
|
|
|
|
|
|
def test_each_response_carries_a_request_id(client):
|
|
resp = client.get("/health")
|
|
assert len(resp.headers["X-Request-ID"]) >= 8
|
|
|
|
|
|
def test_a_well_formed_incoming_request_id_is_kept(client):
|
|
resp = client.get("/health", headers={"X-Request-ID": "caddy-1234abcd"})
|
|
assert resp.headers["X-Request-ID"] == "caddy-1234abcd"
|
|
|
|
|
|
def test_a_malformed_incoming_request_id_is_replaced(client):
|
|
resp = client.get("/health", headers={"X-Request-ID": "x\nforged log line"})
|
|
assert resp.headers["X-Request-ID"] != "x\nforged log line"
|
|
|
|
|
|
def test_access_line_uses_the_route_template_and_hides_tokens(raw_client, edition, caplog):
|
|
with caplog.at_level(logging.INFO, logger="app.request"):
|
|
raw_client.get(f"/editions/{edition.id}/form/public?token=secret-access-token")
|
|
|
|
line = next(r.getMessage() for r in caplog.records if r.name == "app.request")
|
|
assert "/editions/{edition_id}/form/public" in line
|
|
assert f"edition_id={edition.id}" in line
|
|
assert "secret-access-token" not in caplog.text
|
|
|
|
|
|
def test_business_error_code_is_logged(client, edition, caplog):
|
|
with caplog.at_level(logging.INFO, logger="app.request"):
|
|
resp = client.get(f"/editions/{edition.id}/volunteers/00000000-0000-0000-0000-000000000000")
|
|
|
|
assert resp.status_code == 404
|
|
record = next(r for r in caplog.records if r.name == "app.request")
|
|
assert record.levelno == logging.WARNING
|
|
assert "code=error.not_found" in record.getMessage()
|
|
|
|
|
|
def test_validation_error_logs_the_field_but_not_the_value(client, edition, caplog):
|
|
with caplog.at_level(logging.INFO, logger="app.request"):
|
|
resp = client.post(
|
|
f"/editions/{edition.id}/volunteers",
|
|
json={"first_name": "Alexandra-Secret", "last_name": 12345678},
|
|
)
|
|
|
|
assert resp.status_code == 422
|
|
assert "last_name" in caplog.text
|
|
assert "Alexandra-Secret" not in caplog.text
|
|
|
|
|
|
@pytest.fixture
|
|
def boom_route():
|
|
def boom():
|
|
raise RuntimeError("panne")
|
|
|
|
app.add_api_route("/__boom", boom)
|
|
yield
|
|
app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) != "/__boom"]
|
|
|
|
|
|
def test_unhandled_exception_returns_a_reference(boom_route, caplog):
|
|
with caplog.at_level(logging.ERROR, logger="app.request"):
|
|
resp = TestClient(app).get("/__boom")
|
|
|
|
assert resp.status_code == 500
|
|
body = resp.json()
|
|
assert body["detail"]["code"] == "error.internal"
|
|
assert body["detail"]["request_id"] == resp.headers["X-Request-ID"]
|
|
assert "RuntimeError" in caplog.text
|