Files
office_translator/tests/test_error_handling.py
Sepehr Ramezani 26bd096a06 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>
2026-04-25 15:01:47 +02:00

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