feat: production deployment - full update with providers, admin, glossaries, pricing, tests
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>
This commit is contained in:
284
tests/test_error_handling.py
Normal file
284
tests/test_error_handling.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user