""" Tests de la Story 2.17 : Gestion d'Erreurs Graceful (Zero HTTP 500) Valide tous les Acceptance Criteria de la gestion centralisée des erreurs. """ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from fastapi.responses import JSONResponse from starlette.requests import Request from middleware.error_handler import ErrorHandlingMiddleware, format_error_response # --------------------------------------------------------------------------- # Helpers / fixtures locales # --------------------------------------------------------------------------- def _make_app_with_middleware(*routes) -> FastAPI: """Crée une mini-app FastAPI avec ErrorHandlingMiddleware et les routes fournies.""" app = FastAPI() app.add_middleware(ErrorHandlingMiddleware) for route in routes: app.add_api_route(route["path"], route["endpoint"], methods=route.get("methods", ["GET"])) return app # --------------------------------------------------------------------------- # AC1 / AC2 : Gestionnaire global + Format JSON structuré # --------------------------------------------------------------------------- class TestGlobalExceptionHandler: """AC1 : Toutes les exceptions non capturées → gestionnaire global.""" def test_unhandled_exception_returns_500_internal_error(self): """AC4 : Exception inattendue → INTERNAL_ERROR 500, stack trace masquée.""" async def _crash(request: Request): result = 1 / 0 # ZeroDivisionError délibéré app = _make_app_with_middleware({"path": "/crash", "endpoint": _crash}) client = TestClient(app, raise_server_exceptions=False) response = client.get("/crash") assert response.status_code == 500 body = response.json() # AC2 : format structuré obligatoire assert "error" in body assert "message" in body assert "details" in body # AC3 : code d'erreur standard assert body["error"] == "INTERNAL_ERROR" # AC5 : message en français assert "inattendue" in body["message"].lower() or "produite" in body["message"].lower() # AC6 : zéro stack trace dans la réponse assert "Traceback" not in response.text assert "ZeroDivisionError" not in response.text def test_unhandled_exception_includes_request_id(self): """AC2 : details contient request_id.""" async def _crash(request: Request): raise RuntimeError("boom") app = _make_app_with_middleware({"path": "/crash", "endpoint": _crash}) client = TestClient(app, raise_server_exceptions=False) response = client.get("/crash") body = response.json() assert "details" in body assert "request_id" in body["details"] # --------------------------------------------------------------------------- # AC2 : Format JSON structuré {error, message, details} # --------------------------------------------------------------------------- class TestStructuredErrorFormat: """AC2 : Toutes les erreurs retournent {error, message, details}.""" def test_format_error_response_always_includes_details(self): """format_error_response inclut toujours details même si vide.""" response = format_error_response( status_code=400, message="Erreur de test", error_code="INVALID_FORMAT", request_id="test-123", ) body = response.body import json data = json.loads(body) assert "error" in data assert "message" in data assert "details" in data assert data["details"]["request_id"] == "test-123" def test_format_error_response_no_extra_fields(self): """La réponse ne contient pas de champs supplémentaires non spécifiés.""" response = format_error_response( status_code=500, message="Erreur interne", error_code="INTERNAL_ERROR", request_id="abc", ) import json data = json.loads(response.body) allowed_keys = {"error", "message", "details"} assert set(data.keys()) == allowed_keys # --------------------------------------------------------------------------- # AC3 : Codes d'erreur standards # --------------------------------------------------------------------------- class TestStandardErrorCodes: """AC3 : Seuls les codes architecturaux sont utilisés.""" ALLOWED_CODES = { "INVALID_FORMAT", "QUOTA_EXCEEDED", "UNAUTHORIZED", "FORBIDDEN", "FILE_TOO_LARGE", "PROVIDER_ERROR", "INTERNAL_ERROR", # Codes étendus acceptables "NOT_FOUND", "METHOD_NOT_ALLOWED", "SERVICE_UNAVAILABLE", "VALIDATION_ERROR", } def test_invalid_format_code_used_for_400(self): from middleware.error_handler import _map_http_status_to_code assert _map_http_status_to_code(400) == "INVALID_FORMAT" def test_quota_exceeded_code_used_for_429(self): from middleware.error_handler import _map_http_status_to_code assert _map_http_status_to_code(429) == "QUOTA_EXCEEDED" def test_unauthorized_code_used_for_401(self): from middleware.error_handler import _map_http_status_to_code assert _map_http_status_to_code(401) == "UNAUTHORIZED" def test_forbidden_code_used_for_403(self): from middleware.error_handler import _map_http_status_to_code assert _map_http_status_to_code(403) == "FORBIDDEN" def test_file_too_large_code_used_for_413(self): from middleware.error_handler import _map_http_status_to_code assert _map_http_status_to_code(413) == "FILE_TOO_LARGE" def test_provider_error_code_used_for_502(self): from middleware.error_handler import _map_http_status_to_code assert _map_http_status_to_code(502) == "PROVIDER_ERROR" def test_internal_error_code_used_for_500(self): from middleware.error_handler import _map_http_status_to_code assert _map_http_status_to_code(500) == "INTERNAL_ERROR" # --------------------------------------------------------------------------- # AC4 : Masquage des détails techniques # --------------------------------------------------------------------------- class TestTechnicalDetailsMasking: """AC4 : Stack traces et messages internes jamais exposés au client.""" def test_attribute_error_not_leaked(self): """AttributeError interne → INTERNAL_ERROR, message générique.""" async def _crash(request: Request): obj = None obj.does_not_exist # AttributeError app = _make_app_with_middleware({"path": "/crash", "endpoint": _crash}) client = TestClient(app, raise_server_exceptions=False) response = client.get("/crash") assert response.status_code == 500 assert "AttributeError" not in response.text assert "does_not_exist" not in response.text assert response.json()["error"] == "INTERNAL_ERROR" def test_key_error_not_leaked(self): """KeyError interne → INTERNAL_ERROR, clé non exposée.""" async def _crash(request: Request): d = {} return d["secret_key"] app = _make_app_with_middleware({"path": "/crash", "endpoint": _crash}) client = TestClient(app, raise_server_exceptions=False) response = client.get("/crash") assert "secret_key" not in response.text assert "KeyError" not in response.text def test_http_404_format(self): """AC4 : HTTPException 404 retourne format structuré, pas de stack trace.""" from fastapi import HTTPException async def _not_found(request: Request): raise HTTPException(status_code=404, detail="File not found") app = FastAPI() from starlette.exceptions import HTTPException as StarletteHTTPException from middleware.error_handler import format_error_response @app.exception_handler(StarletteHTTPException) async def http_exc(request, exc): return format_error_response( status_code=exc.status_code, message=str(exc.detail) if hasattr(exc, "detail") else "Ressource introuvable.", request_id=getattr(request.state, "request_id", "unknown"), ) app.add_api_route("/not-found", _not_found, methods=["GET"]) client = TestClient(app, raise_server_exceptions=False) response = client.get("/not-found") assert response.status_code == 404 body = response.json() assert "error" in body assert "message" in body assert "details" in body assert "Traceback" not in response.text # --------------------------------------------------------------------------- # AC5 : Messages en français # --------------------------------------------------------------------------- class TestFrenchMessages: """AC5 : Messages d'erreur destinés à l'utilisateur en français.""" def test_internal_error_message_is_french(self): """Le message générique pour INTERNAL_ERROR est en français.""" async def _crash(request: Request): raise Exception("crash") app = _make_app_with_middleware({"path": "/crash", "endpoint": _crash}) client = TestClient(app, raise_server_exceptions=False) response = client.get("/crash") message = response.json().get("message", "") # Doit contenir du texte français, pas "An unexpected error" assert any( word in message.lower() for word in ["erreur", "inattendue", "produite", "réessayez", "veuillez"] ), f"Message attendu en français, obtenu : {message!r}" def test_validation_error_message_is_french(self): """format_error_response avec message français est transmis tel quel.""" response = format_error_response( status_code=400, message="Erreur de validation des données transmises.", error_code="INVALID_FORMAT", request_id="x", ) import json body = json.loads(response.body) assert "Erreur" in body["message"] # --------------------------------------------------------------------------- # AC6 : Zéro stack trace dans les réponses API # --------------------------------------------------------------------------- class TestZeroStackTrace: """AC6 : Aucune trace d'exécution exposée dans les réponses API.""" def test_no_traceback_in_500_response(self): """500 response ne contient pas de Traceback.""" async def _crash(request: Request): raise ValueError("deep internal error with secrets") app = _make_app_with_middleware({"path": "/crash", "endpoint": _crash}) client = TestClient(app, raise_server_exceptions=False) response = client.get("/crash") assert "Traceback" not in response.text assert "ValueError" not in response.text assert "deep internal error with secrets" not in response.text def test_no_file_path_in_500_response(self): """Les chemins de fichiers internes ne sont pas exposés.""" async def _crash(request: Request): open("/this/path/does/not/exist.txt") app = _make_app_with_middleware({"path": "/crash", "endpoint": _crash}) client = TestClient(app, raise_server_exceptions=False) response = client.get("/crash") assert "/this/path/does/not/exist.txt" not in response.text assert "FileNotFoundError" not in response.text