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.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),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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!@#")
|
||||||
|
|||||||
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