diff --git a/backend/app/main.py b/backend/app/main.py index 24ce66c..c67b735 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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), }, ) diff --git a/backend/tests/test_security_validators.py b/backend/tests/test_security_validators.py index 6e6868e..eb04c69 100644 --- a/backend/tests/test_security_validators.py +++ b/backend/tests/test_security_validators.py @@ -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!@#") diff --git a/backend/tests/test_user_api_validation.py b/backend/tests/test_user_api_validation.py new file mode 100644 index 0000000..f79d418 --- /dev/null +++ b/backend/tests/test_user_api_validation.py @@ -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"