Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
285 lines
11 KiB
Python
285 lines
11 KiB
Python
"""
|
|
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
|