fix(api): return 422 for validation errors with serializable payloads [FASE-3.3]
This commit is contained in:
@@ -6,9 +6,8 @@ from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
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.util import get_remote_address
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
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.domain.errors import DomainError
|
||||
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.jobs.mitre_sync_job import start_scheduler, scheduler
|
||||
|
||||
@@ -71,10 +72,11 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
# ── Rate Limiter ──────────────────────────────────────────────────────────
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
|
||||
# ── Domain exception → HTTP mapping ──────────────────────────────────────
|
||||
app.add_exception_handler(DomainError, domain_exception_handler)
|
||||
|
||||
@@ -136,15 +138,27 @@ def health():
|
||||
# ── 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)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""Handle validation errors with consistent format."""
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
"detail": "Validation error",
|
||||
"code": "VALIDATION_ERROR",
|
||||
"errors": exc.errors(),
|
||||
"errors": _serialize_validation_errors(exc),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ class TestUsernameValidation:
|
||||
with pytest.raises(ValidationError, match="3-50 characters"):
|
||||
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):
|
||||
with pytest.raises(ValidationError, match="reserved"):
|
||||
UserCreate(username="admin", password="SecurePass123!@#")
|
||||
|
||||
59
backend/tests/test_user_api_validation.py
Normal file
59
backend/tests/test_user_api_validation.py
Normal 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"
|
||||
Reference in New Issue
Block a user