fix(api): return 422 for validation errors with serializable payloads [FASE-3.3]

This commit is contained in:
2026-05-18 14:16:53 +02:00
parent 6b076f52b2
commit 5b29c2fc56
3 changed files with 86 additions and 5 deletions

View File

@@ -6,9 +6,8 @@ from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.routers import auth as auth_router from app.routers import auth as auth_router
@@ -40,6 +39,8 @@ from app.routers import advanced_metrics as advanced_metrics_router
from app.routers import osint as osint_router from app.routers import osint as osint_router
from app.domain.errors import DomainError from app.domain.errors import DomainError
from app.middleware.error_handler import domain_exception_handler from app.middleware.error_handler import domain_exception_handler
from app.middleware.request_context import RequestContextMiddleware
from app.limiter import limiter
from app.storage import ensure_bucket_exists from app.storage import ensure_bucket_exists
from app.jobs.mitre_sync_job import start_scheduler, scheduler from app.jobs.mitre_sync_job import start_scheduler, scheduler
@@ -71,10 +72,11 @@ app = FastAPI(
) )
# ── Rate Limiter ────────────────────────────────────────────────────────── # ── Rate Limiter ──────────────────────────────────────────────────────────
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(RequestContextMiddleware)
# ── Domain exception → HTTP mapping ────────────────────────────────────── # ── Domain exception → HTTP mapping ──────────────────────────────────────
app.add_exception_handler(DomainError, domain_exception_handler) app.add_exception_handler(DomainError, domain_exception_handler)
@@ -136,15 +138,27 @@ def health():
# ── Exception Handlers ──────────────────────────────────────────────────── # ── Exception Handlers ────────────────────────────────────────────────────
def _serialize_validation_errors(exc: RequestValidationError) -> list[dict]:
"""Return validation errors safe for JSON (no raw exception objects)."""
serialized: list[dict] = []
for err in exc.errors():
item = dict(err)
ctx = item.get("ctx")
if isinstance(ctx, dict):
item["ctx"] = {key: str(value) for key, value in ctx.items()}
serialized.append(item)
return serialized
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError): async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handle validation errors with consistent format.""" """Handle validation errors with consistent format."""
return JSONResponse( return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={ content={
"detail": "Validation error", "detail": "Validation error",
"code": "VALIDATION_ERROR", "code": "VALIDATION_ERROR",
"errors": exc.errors(), "errors": _serialize_validation_errors(exc),
}, },
) )

View File

@@ -42,6 +42,14 @@ class TestUsernameValidation:
with pytest.raises(ValidationError, match="3-50 characters"): with pytest.raises(ValidationError, match="3-50 characters"):
UserCreate(username="john@doe", password="SecurePass123!@#") UserCreate(username="john@doe", password="SecurePass123!@#")
def test_reserved_username_system(self):
with pytest.raises(ValidationError):
UserCreate(username="system", password="SecurePass123!@#")
def test_invalid_username_path_chars(self):
with pytest.raises(ValidationError):
UserCreate(username="../admin", password="SecurePass123!@#")
def test_reserved_username_admin(self): def test_reserved_username_admin(self):
with pytest.raises(ValidationError, match="reserved"): with pytest.raises(ValidationError, match="reserved"):
UserCreate(username="admin", password="SecurePass123!@#") UserCreate(username="admin", password="SecurePass123!@#")

View File

@@ -0,0 +1,59 @@
"""API-level validation tests for user creation (SEC-004, SEC-007)."""
def test_create_user_weak_password_rejected(client, admin_user, admin_token):
response = client.post(
"/api/v1/users",
json={
"username": "newuser",
"password": "123",
"email": "new@test.com",
"role": "viewer",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 422
assert "password" in response.text.lower()
def test_create_user_reserved_username(client, admin_user, admin_token):
response = client.post(
"/api/v1/users",
json={
"username": "system",
"password": "SecurePass123!@#",
"email": "sys@test.com",
"role": "viewer",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 422
def test_create_user_invalid_username_chars(client, admin_user, admin_token):
response = client.post(
"/api/v1/users",
json={
"username": "../admin",
"password": "SecurePass123!@#",
"email": "bad@test.com",
"role": "viewer",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 422
def test_create_user_valid_password_accepted(client, admin_user, admin_token):
response = client.post(
"/api/v1/users",
json={
"username": "validuser99",
"password": "ValidPass123!@#",
"email": "valid@test.com",
"role": "viewer",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 201
assert response.json()["username"] == "validuser99"