Logs de diagnostic et rapprochement SumUp ↔ bénévole
- 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d722a81a87
commit
d0fa1e109c
@@ -0,0 +1,78 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user