- Disable OpenAPI docs/redoc/openapi.json when APP_ENV=production - Strip uvicorn Server header with --no-server-header in Dockerfile and docker-compose.yml - Add OriginValidatorMiddleware to reject state-changing requests from disallowed origins - Add global exception handler to prevent stack trace leakage on unhandled errors - Add tests for all new security features (OpenAPI, origin validation, exception handler, server header)
65 lines
2.2 KiB
Python
65 lines
2.2 KiB
Python
"""Unit tests for the unhandled exception handler in api/app.py."""
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import JSONResponse
|
|
from starlette.requests import Request
|
|
from starlette.testclient import TestClient
|
|
|
|
|
|
def _build_app() -> FastAPI:
|
|
"""Build a minimal FastAPI app with the unhandled exception handler."""
|
|
app = FastAPI()
|
|
|
|
@app.exception_handler(Exception)
|
|
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"detail": "Internal server error"},
|
|
)
|
|
|
|
@app.get("/ok")
|
|
async def ok() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
@app.get("/crash")
|
|
async def crash() -> None:
|
|
raise RuntimeError("something unexpected")
|
|
|
|
@app.get("/http-error")
|
|
async def http_error() -> None:
|
|
raise HTTPException(status_code=403, detail="forbidden")
|
|
|
|
return app
|
|
|
|
|
|
class TestUnhandledExceptionHandler:
|
|
"""Tests for the global exception handler."""
|
|
|
|
def test_normal_request_unaffected(self) -> None:
|
|
client = TestClient(_build_app())
|
|
resp = client.get("/ok")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"status": "ok"}
|
|
|
|
def test_unhandled_exception_returns_generic_500(self) -> None:
|
|
client = TestClient(_build_app(), raise_server_exceptions=False)
|
|
resp = client.get("/crash")
|
|
assert resp.status_code == 500
|
|
assert resp.json() == {"detail": "Internal server error"}
|
|
|
|
def test_http_exception_not_caught(self) -> None:
|
|
"""HTTPException should still be handled by FastAPI's built-in handler."""
|
|
client = TestClient(_build_app())
|
|
resp = client.get("/http-error")
|
|
assert resp.status_code == 403
|
|
assert resp.json() == {"detail": "forbidden"}
|
|
|
|
def test_error_body_does_not_leak_details(self) -> None:
|
|
"""The response must not contain the actual exception message."""
|
|
client = TestClient(_build_app(), raise_server_exceptions=False)
|
|
resp = client.get("/crash")
|
|
body = resp.text
|
|
assert "something unexpected" not in body
|
|
assert "RuntimeError" not in body
|