diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index 66282c415..4c7505378 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -32,7 +32,8 @@ from .plugins import init_plugins, get_plugin_check_latency_ms # Import rate limiter -from .rate_limiter import make_scan_rate_limiter, RateLimitExceeded +from .rate_limiter import make_scan_rate_limiter +from .main_exception_handlers import RateLimitExceeded logging.basicConfig( level=getattr(logging, settings.log_level), @@ -175,18 +176,18 @@ async def lifespan(app: FastAPI): @app.get("/api/docs", include_in_schema=False) async def redirect_api_docs(): - from fastapi.responses import RedirectResponse - return RedirectResponse(url="/docs") + from .main_exception_handlers import redirect_api_docs as _h + return await _h() @app.get("/api/redoc", include_in_schema=False) async def redirect_api_redoc(): - from fastapi.responses import RedirectResponse - return RedirectResponse(url="/redoc") + from .main_exception_handlers import redirect_api_redoc as _h + return await _h() @app.get("/api/openapi.json", include_in_schema=False) async def redirect_api_openapi(): - from fastapi.responses import RedirectResponse - return RedirectResponse(url="/openapi.json") + from .main_exception_handlers import redirect_api_openapi as _h + return await _h() # CORS middleware cors_allow_all = "*" in settings.cors_allowed_origins @@ -206,88 +207,21 @@ async def redirect_api_openapi(): app.add_middleware(RequestIDMiddleware) # ─── CUSTOM 429 RATE LIMIT EXCEPTION HANDLER ────────────────────────────── -@app.exception_handler(RateLimitExceeded) -async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded): - """ - Custom handler for rate limit exceeded errors. - Returns a consistent JSON 429 response matching the API's error schema. - """ - logger.warning( - f"Rate limit exceeded for {request.client.host if request.client else 'unknown'} " - f"on {request.url.path} - {str(exc)}" - ) - - # Get retry-after from exception if available - retry_after = getattr(exc, 'retry_after', 60) - - return JSONResponse( - status_code=HTTP_429_TOO_MANY_REQUESTS, - content={ - "error": str(exc.detail) if hasattr(exc, 'detail') else "Too Many Requests", - "retry_after": retry_after, - "message": "Rate limit exceeded. Please wait before making more requests." - }, - headers={ - "Retry-After": str(retry_after), - "X-Request-ID": getattr(request.state, "request_id", get_request_id()), - }, - ) - -# Also handle generic 429 exceptions (for compatibility) -@app.exception_handler(HTTP_429_TOO_MANY_REQUESTS) -async def generic_rate_limit_handler(request: Request, exc: Exception): - """ - Generic handler for 429 status code exceptions. - - Merges headers from the original exception (e.g. X-RateLimit-Limit, - X-RateLimit-Remaining, Retry-After) with default headers, so - callers always receive accurate rate-limit metadata. - """ - exc_headers = getattr(exc, "headers", None) or {} - headers = { - "X-Request-ID": getattr(request.state, "request_id", get_request_id()), - **exc_headers, - } - if "Retry-After" not in headers: - headers["Retry-After"] = "60" +from .main_exception_handlers import ( + rate_limit_exceeded_handler, + generic_rate_limit_handler, + custom_http_exception_handler, + custom_validation_exception_handler, + custom_unhandled_exception_handler, +) - return JSONResponse( - status_code=HTTP_429_TOO_MANY_REQUESTS, - content={ - "error": "Too Many Requests", - "message": "Rate limit exceeded. Please try again later." - }, - headers=headers, - ) +app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) +app.add_exception_handler(HTTP_429_TOO_MANY_REQUESTS, generic_rate_limit_handler) +app.add_exception_handler(StarletteHTTPException, custom_http_exception_handler) +app.add_exception_handler(RequestValidationError, custom_validation_exception_handler) +app.add_exception_handler(Exception, custom_unhandled_exception_handler) # ─── END CUSTOM 429 HANDLER ────────────────────────────────────────────────── -@app.exception_handler(StarletteHTTPException) -async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException): - response = await http_exception_handler(request, exc) - response.headers["X-Request-ID"] = getattr(request.state, "request_id", get_request_id()) - return response - - -@app.exception_handler(RequestValidationError) -async def custom_validation_exception_handler(request: Request, exc: RequestValidationError): - response = await request_validation_exception_handler(request, exc) - response.headers["X-Request-ID"] = getattr(request.state, "request_id", get_request_id()) - return response - -@app.exception_handler(Exception) -async def custom_unhandled_exception_handler(request: Request, exc: Exception): - logger.exception("Unhandled exception in request lifecycle") - - if settings.debug: - import traceback - html = f"
{traceback.format_exc()}"
- response = HTMLResponse(html, status_code=500)
- else:
- response = PlainTextResponse("Internal Server Error", status_code=500)
-
- response.headers["X-Request-ID"] = getattr(request.state, "request_id", get_request_id())
- return response
-
# Include API routes
app.include_router(auth_router)
app.include_router(router)
diff --git a/backend/secuscan/main_exception_handlers.py b/backend/secuscan/main_exception_handlers.py
new file mode 100644
index 000000000..a09e782ef
--- /dev/null
+++ b/backend/secuscan/main_exception_handlers.py
@@ -0,0 +1,145 @@
+"""
+Exception handlers and redirect endpoints for main.py.
+
+Extracted into a standalone import-safe module so they can be unit-tested
+without pulling in the heavy FastAPI app initialization chain.
+"""
+
+from __future__ import annotations
+
+import logging
+import traceback
+
+from fastapi import Request
+from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
+from starlette.exceptions import HTTPException as StarletteHTTPException
+from starlette.status import HTTP_429_TOO_MANY_REQUESTS
+
+# RateLimitExceeded is a simple HTTPException subclass — define locally to avoid
+# pulling in the full rate_limiter module (which requires redis at import time).
+from starlette.exceptions import HTTPException
+from starlette.status import HTTP_429_TOO_MANY_REQUESTS
+
+
+class RateLimitExceeded(HTTPException):
+ """Raised when a rate limit is exceeded. Caught by a global exception handler."""
+
+ def __init__(self, detail: str = None, retry_after: int = None):
+ super().__init__(status_code=HTTP_429_TOO_MANY_REQUESTS, detail=detail)
+ self.retry_after = retry_after
+from .request_context import get_request_id
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Redirect endpoints
+# ---------------------------------------------------------------------------
+
+
+async def redirect_api_docs():
+ return RedirectResponse(url="/docs")
+
+
+async def redirect_api_redoc():
+ return RedirectResponse(url="/redoc")
+
+
+async def redirect_api_openapi():
+ return RedirectResponse(url="/openapi.json")
+
+
+# ---------------------------------------------------------------------------
+# Exception handlers
+# ---------------------------------------------------------------------------
+
+
+async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
+ """
+ Custom handler for rate limit exceeded errors.
+ Returns a consistent JSON 429 response matching the API's error schema.
+ """
+ logger.warning(
+ "Rate limit exceeded for %s on %s - %s",
+ request.client.host if request.client else "unknown",
+ request.url.path,
+ str(exc),
+ )
+ retry_after = getattr(exc, "retry_after", None) or 60
+ return JSONResponse(
+ status_code=HTTP_429_TOO_MANY_REQUESTS,
+ content={
+ "error": str(exc.detail) if hasattr(exc, "detail") else "Too Many Requests",
+ "retry_after": retry_after,
+ "message": "Rate limit exceeded. Please wait before making more requests.",
+ },
+ headers={
+ "Retry-After": str(retry_after),
+ "X-Request-ID": getattr(request.state, "request_id", get_request_id()),
+ },
+ )
+
+
+async def generic_rate_limit_handler(request: Request, exc: Exception) -> JSONResponse:
+ """
+ Generic handler for 429 status code exceptions.
+ Merges headers from the original exception with default headers.
+ """
+ exc_headers = getattr(exc, "headers", None) or {}
+ headers = {
+ "X-Request-ID": getattr(request.state, "request_id", get_request_id()),
+ **exc_headers,
+ }
+ if "Retry-After" not in headers:
+ headers["Retry-After"] = "60"
+ return JSONResponse(
+ status_code=HTTP_429_TOO_MANY_REQUESTS,
+ content={
+ "error": "Too Many Requests",
+ "message": "Rate limit exceeded. Please try again later.",
+ },
+ headers=headers,
+ )
+
+
+async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
+ """
+ Catches StarletteHTTPException and adds X-Request-ID to the response.
+ """
+ from fastapi.exception_handlers import http_exception_handler
+
+ response = await http_exception_handler(request, exc)
+ response.headers["X-Request-ID"] = getattr(request.state, "request_id", get_request_id())
+ return response
+
+
+async def custom_validation_exception_handler(request: Request, exc):
+ """
+ Catches RequestValidationError and adds X-Request-ID to the response.
+ """
+ from fastapi.exception_handlers import request_validation_exception_handler
+ from fastapi.exceptions import RequestValidationError
+
+ response = await request_validation_exception_handler(request, exc)
+ response.headers["X-Request-ID"] = getattr(request.state, "request_id", get_request_id())
+ return response
+
+
+async def custom_unhandled_exception_handler(request: Request, exc: Exception) -> HTMLResponse | PlainTextResponse:
+ """
+ Catches unhandled exceptions and returns either a debug traceback (in debug mode)
+ or a generic 500 error.
+ """
+ logger.exception("Unhandled exception in request lifecycle")
+ from .config import settings
+
+ if settings.debug:
+ html = (
+ "{traceback.format_exc()}"
+ )
+ response = HTMLResponse(html, status_code=500)
+ else:
+ response = PlainTextResponse("Internal Server Error", status_code=500)
+ response.headers["X-Request-ID"] = getattr(request.state, "request_id", get_request_id())
+ return response
diff --git a/testing/backend/unit/test_database.py b/testing/backend/unit/test_database.py
new file mode 100644
index 000000000..a998b7f25
--- /dev/null
+++ b/testing/backend/unit/test_database.py
@@ -0,0 +1,326 @@
+"""
+Unit tests for backend/secuscan/database.py Database helper methods.
+
+Covers the basic data access methods not tested by existing test files:
+ - Database.execute: run a write query and return cursor
+ - Database.fetchone: fetch a single row as dict
+ - Database.fetchall: fetch all rows as list of dicts
+ - Database.executescript: run a schema/migration script
+
+Tests use a real temporary SQLite database via aiosqlite.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import tempfile
+
+import pytest
+
+from backend.secuscan.database import Database
+
+
+def _make_db(tmp_path):
+ """Create and connect a Database backed by a temporary file. Returns (db, cleanup)."""
+ db_path = str(tmp_path / "test.db")
+ database = Database(db_path)
+
+ async def setup():
+ await database.connect()
+ return database
+
+ return database, setup
+
+
+class TestExecute:
+ @pytest.mark.asyncio
+ async def test_insert_returns_cursor_with_rowcount(self, tmp_path):
+ """INSERT returns a cursor with rowcount = 1."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ cursor = await db.execute(
+ "INSERT INTO audit_log (event_type, severity, message) VALUES (?, ?, ?)",
+ ("test_event", "info", "test message"),
+ )
+ assert cursor.rowcount == 1
+ finally:
+ await db.disconnect()
+
+ @pytest.mark.asyncio
+ async def test_update_returns_cursor_with_rowcount(self, tmp_path):
+ """UPDATE returns a cursor with rowcount = number of affected rows."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ await db.execute(
+ "INSERT INTO audit_log (event_type, severity, message) VALUES (?, ?, ?)",
+ ("update_test", "info", "before"),
+ )
+ cursor = await db.execute(
+ "UPDATE audit_log SET message = ? WHERE event_type = ?",
+ ("after", "update_test"),
+ )
+ assert cursor.rowcount == 1
+ finally:
+ await db.disconnect()
+
+ @pytest.mark.asyncio
+ async def test_delete_returns_cursor_with_rowcount(self, tmp_path):
+ """DELETE returns a cursor with rowcount = number of deleted rows."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ await db.execute(
+ "INSERT INTO audit_log (event_type, severity, message) VALUES (?, ?, ?)",
+ ("delete_test", "info", "to_delete"),
+ )
+ cursor = await db.execute(
+ "DELETE FROM audit_log WHERE event_type = ?", ("delete_test",)
+ )
+ assert cursor.rowcount == 1
+ finally:
+ await db.disconnect()
+
+ @pytest.mark.asyncio
+ async def test_update_nonexistent_returns_zero_rowcount(self, tmp_path):
+ """UPDATE on non-matching rows returns rowcount = 0."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ cursor = await db.execute(
+ "UPDATE audit_log SET message = ? WHERE event_type = ?",
+ ("nomatch", "this_event_type_does_not_exist"),
+ )
+ assert cursor.rowcount == 0
+ finally:
+ await db.disconnect()
+
+ @pytest.mark.asyncio
+ async def test_delete_nonexistent_returns_zero_rowcount(self, tmp_path):
+ """DELETE on non-matching rows returns rowcount = 0."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ cursor = await db.execute(
+ "DELETE FROM audit_log WHERE event_type = ?",
+ ("this_event_type_does_not_exist",),
+ )
+ assert cursor.rowcount == 0
+ finally:
+ await db.disconnect()
+
+ @pytest.mark.asyncio
+ async def test_execute_commits_transaction(self, tmp_path):
+ """execute must auto-commit so subsequent reads see the change."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ await db.execute(
+ "INSERT INTO audit_log (event_type, severity, message) VALUES (?, ?, ?)",
+ ("commit_test", "warning", "visible?"),
+ )
+ row = await db.fetchone(
+ "SELECT message FROM audit_log WHERE event_type = ?", ("commit_test",)
+ )
+ assert row is not None
+ assert row["message"] == "visible?"
+ finally:
+ await db.disconnect()
+
+
+
+
+class TestFetchone:
+ @pytest.mark.asyncio
+ async def test_returns_dict_for_existing_row(self, tmp_path):
+ """fetchone returns a dict for a matching row."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ await db.execute(
+ "INSERT INTO audit_log (event_type, severity, message) VALUES (?, ?, ?)",
+ ("fetchone_test", "error", "found"),
+ )
+ row = await db.fetchone(
+ "SELECT * FROM audit_log WHERE event_type = ?", ("fetchone_test",)
+ )
+ assert row is not None
+ assert isinstance(row, dict)
+ assert row["event_type"] == "fetchone_test"
+ assert row["severity"] == "error"
+ assert row["message"] == "found"
+ finally:
+ await db.disconnect()
+
+ @pytest.mark.asyncio
+ async def test_returns_none_for_missing_row(self, tmp_path):
+ """fetchone returns None when no row matches."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ row = await db.fetchone(
+ "SELECT * FROM audit_log WHERE event_type = ?",
+ ("nonexistent_event_type_xyz",),
+ )
+ assert row is None
+ finally:
+ await db.disconnect()
+
+ @pytest.mark.asyncio
+ async def test_column_access_is_case_insensitive(self, tmp_path):
+ """sqlite Row is case-insensitive when accessing columns.
+
+ Note: dict(row) normalizes keys to lowercase, but the Row object
+ itself is case-insensitive for column access.
+ """
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ await db.execute(
+ "INSERT INTO audit_log (event_type, severity, message) VALUES (?, ?, ?)",
+ ("case_test", "info", "lower"),
+ )
+ # dict(row) normalizes keys to lowercase (sqlite canonical form)
+ row = await db.fetchone(
+ "SELECT EVENT_TYPE, Severity, MESSAGE FROM audit_log WHERE event_type = ?",
+ ("case_test",),
+ )
+ assert row["event_type"] == "case_test"
+ # dict() normalizes all keys to lowercase
+ assert "EVENT_TYPE" not in row # dict keys are lowercase
+ assert "event_type" in row
+ finally:
+ await db.disconnect()
+
+
+class TestFetchall:
+ @pytest.mark.asyncio
+ async def test_returns_empty_list_for_no_matches(self, tmp_path):
+ """fetchall returns an empty list when no rows match."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ rows = await db.fetchall(
+ "SELECT * FROM audit_log WHERE event_type = ?", ("nonexistent",)
+ )
+ assert isinstance(rows, list)
+ assert len(rows) == 0
+ finally:
+ await db.disconnect()
+
+ @pytest.mark.asyncio
+ async def test_returns_all_matching_rows(self, tmp_path):
+ """fetchall returns all matching rows as dicts."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ for i in range(3):
+ await db.execute(
+ "INSERT INTO audit_log (event_type, severity, message) VALUES (?, ?, ?)",
+ (f"fetchall_test_{i}", "info", f"row {i}"),
+ )
+ rows = await db.fetchall(
+ "SELECT * FROM audit_log WHERE event_type LIKE ? ORDER BY event_type",
+ ("fetchall_test_%",),
+ )
+ assert len(rows) == 3
+ for row in rows:
+ assert isinstance(row, dict)
+ assert "fetchall_test" in row["event_type"]
+ finally:
+ await db.disconnect()
+
+ @pytest.mark.asyncio
+ async def test_aggregate_query_returns_single_row(self, tmp_path):
+ """fetchall with COUNT(*) returns a single row."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ for i in range(2):
+ await db.execute(
+ "INSERT INTO audit_log (event_type, severity, message) VALUES (?, ?, ?)",
+ (f"all_rows_test_{i}", "info", "all"),
+ )
+ rows = await db.fetchall(
+ "SELECT COUNT(*) AS cnt FROM audit_log WHERE event_type LIKE 'all_rows_test_%'"
+ )
+ assert len(rows) == 1
+ assert rows[0]["cnt"] == 2
+ finally:
+ await db.disconnect()
+
+
+class TestExecutescript:
+ @pytest.mark.asyncio
+ async def test_creates_table_and_inserts_rows(self, tmp_path):
+ """executescript runs a multi-statement SQL script."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ await db.executescript(
+ "CREATE TABLE test_script_table (id INTEGER PRIMARY KEY, name TEXT); "
+ "INSERT INTO test_script_table (name) VALUES ('from_script');"
+ )
+ row = await db.fetchone(
+ "SELECT name FROM test_script_table WHERE id = 1"
+ )
+ assert row["name"] == "from_script"
+ finally:
+ await db.disconnect()
+
+ @pytest.mark.asyncio
+ async def test_executescript_commits_changes(self, tmp_path):
+ """executescript must auto-commit so changes are visible."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ await db.executescript(
+ "CREATE TABLE test_script_commit (id INTEGER PRIMARY KEY, val TEXT);"
+ )
+ rows = await db.fetchall(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='test_script_commit'"
+ )
+ assert len(rows) == 1
+ assert rows[0]["name"] == "test_script_commit"
+ finally:
+ await db.disconnect()
+
+
+class TestDatabaseErrorHandling:
+ @pytest.mark.asyncio
+ async def test_fetchone_with_syntax_error_raises(self, tmp_path):
+ """SQL syntax errors propagate as exceptions."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ with pytest.raises(Exception):
+ await db.fetchone("SELECT * FORM nonexistent_table")
+ finally:
+ await db.disconnect()
+
+ @pytest.mark.asyncio
+ async def test_execute_with_syntax_error_raises(self, tmp_path):
+ """SQL syntax errors in execute propagate as exceptions."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ with pytest.raises(Exception):
+ await db.execute("INSRT INTO audit_log (x) VALUES (1)")
+ finally:
+ await db.disconnect()
+
+ @pytest.mark.asyncio
+ async def test_fetchall_with_invalid_query_raises(self, tmp_path):
+ """Invalid queries in fetchall propagate as exceptions."""
+ db, setup = _make_db(tmp_path)
+ await setup()
+ try:
+ with pytest.raises(Exception):
+ await db.fetchall("SELEC * FORM audit_log")
+ finally:
+ await db.disconnect()
+
+
+
diff --git a/testing/backend/unit/test_main.py b/testing/backend/unit/test_main.py
new file mode 100644
index 000000000..1062cda2e
--- /dev/null
+++ b/testing/backend/unit/test_main.py
@@ -0,0 +1,278 @@
+"""
+Unit tests for backend/secuscan/main_exception_handlers.py exception handlers.
+
+Covers the standalone exception handler and redirect functions extracted from
+main.py to enable safe unit testing without the full FastAPI app initialization.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, AsyncMock
+from fastapi import Request
+from fastapi.exceptions import RequestValidationError
+from pydantic import BaseModel
+from starlette.exceptions import HTTPException as StarletteHTTPException
+from starlette.status import HTTP_429_TOO_MANY_REQUESTS
+import pytest
+
+from backend.secuscan.main_exception_handlers import (
+ redirect_api_docs,
+ redirect_api_redoc,
+ redirect_api_openapi,
+ rate_limit_exceeded_handler,
+ generic_rate_limit_handler,
+ custom_http_exception_handler,
+ custom_validation_exception_handler,
+ custom_unhandled_exception_handler,
+)
+from backend.secuscan.main_exception_handlers import RateLimitExceeded
+
+
+# ---------------------------------------------------------------------------
+# Redirect endpoints
+# ---------------------------------------------------------------------------
+
+
+class TestRedirectEndpoints:
+ @pytest.mark.asyncio
+ async def test_redirect_api_docs_returns_307_to_docs(self):
+ result = await redirect_api_docs()
+ assert result.status_code == 307
+ assert result.headers["location"] == "/docs"
+
+ @pytest.mark.asyncio
+ async def test_redirect_api_redoc_returns_307_to_redoc(self):
+ result = await redirect_api_redoc()
+ assert result.status_code == 307
+ assert result.headers["location"] == "/redoc"
+
+ @pytest.mark.asyncio
+ async def test_redirect_api_openapi_returns_307_to_openapi(self):
+ result = await redirect_api_openapi()
+ assert result.status_code == 307
+ assert result.headers["location"] == "/openapi.json"
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def make_request(mock_request_id: str | None = None) -> Request:
+ """Build a mock Request with a state.request_id attribute."""
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/test",
+ "query_string": b"",
+ "headers": [],
+ }
+ request = Request(scope, receive=MagicMock())
+ if mock_request_id is not None:
+ request.state.request_id = mock_request_id
+ return request
+
+
+# ---------------------------------------------------------------------------
+# rate_limit_exceeded_handler
+# ---------------------------------------------------------------------------
+
+
+class TestRateLimitExceededHandler:
+ @pytest.mark.asyncio
+ async def test_returns_429_status(self):
+ exc = RateLimitExceeded(detail="Too many requests")
+ request = make_request("req-123")
+ response = await rate_limit_exceeded_handler(request, exc)
+ assert response.status_code == HTTP_429_TOO_MANY_REQUESTS
+
+ @pytest.mark.asyncio
+ async def test_body_contains_error_message(self):
+ exc = RateLimitExceeded(detail="Rate limit exceeded for IP 1.2.3.4")
+ request = make_request("req-123")
+ response = await rate_limit_exceeded_handler(request, exc)
+ body = response.body.decode()
+ assert "Rate limit exceeded" in body
+ assert "retry_after" in body
+
+ @pytest.mark.asyncio
+ async def test_retry_after_header_set(self):
+ exc = RateLimitExceeded(detail="Limit reached", retry_after=120)
+ request = make_request("req-456")
+ response = await rate_limit_exceeded_handler(request, exc)
+ assert response.headers["retry-after"] == "120"
+
+ @pytest.mark.asyncio
+ async def test_default_retry_after_is_60(self):
+ exc = RateLimitExceeded(detail="Limit reached")
+ request = make_request()
+ response = await rate_limit_exceeded_handler(request, exc)
+ assert response.headers["retry-after"] == "60"
+
+ @pytest.mark.asyncio
+ async def test_x_request_id_header_added(self):
+ exc = RateLimitExceeded(detail="Limit reached")
+ request = make_request("my-request-id")
+ response = await rate_limit_exceeded_handler(request, exc)
+ assert response.headers["x-request-id"] == "my-request-id"
+
+ @pytest.mark.asyncio
+ async def test_exc_with_detail_attribute(self):
+ exc = RateLimitExceeded(detail="Custom rate limit message")
+ request = make_request("req-789")
+ response = await rate_limit_exceeded_handler(request, exc)
+ body = response.body.decode()
+ assert "Custom rate limit message" in body
+
+
+# ---------------------------------------------------------------------------
+# generic_rate_limit_handler
+# ---------------------------------------------------------------------------
+
+
+class TestGenericRateLimitHandler:
+ @pytest.mark.asyncio
+ async def test_returns_429_status(self):
+ exc = Exception("429-like exception")
+ request = make_request("req-1")
+ response = await generic_rate_limit_handler(request, exc)
+ assert response.status_code == HTTP_429_TOO_MANY_REQUESTS
+
+ @pytest.mark.asyncio
+ async def test_body_contains_too_many_requests(self):
+ exc = Exception("rate limit")
+ request = make_request("req-2")
+ response = await generic_rate_limit_handler(request, exc)
+ body = response.body.decode()
+ assert "Too Many Requests" in body
+
+ @pytest.mark.asyncio
+ async def test_retry_after_defaults_to_60(self):
+ exc = Exception("rate limit")
+ request = make_request()
+ response = await generic_rate_limit_handler(request, exc)
+ assert response.headers["retry-after"] == "60"
+
+ @pytest.mark.asyncio
+ async def test_existing_retry_after_preserved(self):
+ exc = Exception("rate limit")
+ exc.headers = {"Retry-After": "300", "X-Custom": "val"}
+ request = make_request("req-3")
+ response = await generic_rate_limit_handler(request, exc)
+ assert response.headers["retry-after"] == "300"
+ assert response.headers["x-custom"] == "val"
+
+ @pytest.mark.asyncio
+ async def test_x_request_id_header_added(self):
+ exc = Exception("rate limit")
+ request = make_request("xid-abc")
+ response = await generic_rate_limit_handler(request, exc)
+ assert response.headers["x-request-id"] == "xid-abc"
+
+
+# ---------------------------------------------------------------------------
+# custom_http_exception_handler
+# ---------------------------------------------------------------------------
+
+
+class TestCustomHttpExceptionHandler:
+ @pytest.mark.asyncio
+ async def test_adds_x_request_id_header(self):
+ exc = StarletteHTTPException(status_code=404, detail="Not found")
+ request = make_request("http-exc-1")
+ response = await custom_http_exception_handler(request, exc)
+ assert response.status_code == 404
+ assert response.headers["x-request-id"] == "http-exc-1"
+
+ @pytest.mark.asyncio
+ async def test_propagates_original_status_code(self):
+ exc = StarletteHTTPException(status_code=403, detail="Forbidden")
+ request = make_request()
+ response = await custom_http_exception_handler(request, exc)
+ assert response.status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# custom_validation_exception_handler
+# ---------------------------------------------------------------------------
+
+
+class TestCustomValidationExceptionHandler:
+ @pytest.mark.asyncio
+ async def test_adds_x_request_id_header(self):
+ errors = [{"loc": ("body",), "msg": "field required", "type": "missing"}]
+ exc = RequestValidationError(errors)
+ request = make_request("val-exc-1")
+ response = await custom_validation_exception_handler(request, exc)
+ assert response.status_code == 422
+ assert response.headers["x-request-id"] == "val-exc-1"
+
+ @pytest.mark.asyncio
+ async def test_returns_422_for_validation_errors(self):
+ errors = []
+ exc = RequestValidationError(errors)
+ request = make_request()
+ response = await custom_validation_exception_handler(request, exc)
+ assert response.status_code == 422
+
+
+# ---------------------------------------------------------------------------
+# custom_unhandled_exception_handler
+# ---------------------------------------------------------------------------
+
+
+class TestCustomUnhandledExceptionHandler:
+ @pytest.mark.asyncio
+ async def test_returns_500_status(self):
+ exc = RuntimeError("unexpected error")
+ request = make_request("unhandled-1")
+ response = await custom_unhandled_exception_handler(request, exc)
+ assert response.status_code == 500
+
+ @pytest.mark.asyncio
+ async def test_plain_text_body_in_production(self):
+ exc = ValueError("bad value")
+ request = make_request("unhandled-2")
+ # settings is imported inside the function, so patch the source
+ from backend.secuscan import config as config_module
+ original = config_module.settings
+ config_module.settings = MagicMock(debug=False)
+ try:
+ response = await custom_unhandled_exception_handler(request, exc)
+ assert response.status_code == 500
+ body = response.body.decode()
+ assert "Internal Server Error" in body
+ # Should be plain text, not HTML
+ assert "" not in body
+ finally:
+ config_module.settings = original
+
+ @pytest.mark.asyncio
+ async def test_debug_mode_returns_html(self):
+ exc = ValueError("bad value")
+ request = make_request("unhandled-3")
+ from backend.secuscan import config as config_module
+ original = config_module.settings
+ config_module.settings = MagicMock(debug=True)
+ try:
+ response = await custom_unhandled_exception_handler(request, exc)
+ assert response.status_code == 500
+ body = response.body.decode()
+ assert "" in body
+ # In debug mode, the response is HTML, not plain text
+ assert "" in body
+ finally:
+ config_module.settings = original
+
+ @pytest.mark.asyncio
+ async def test_x_request_id_header_added(self):
+ exc = RuntimeError("unexpected")
+ request = make_request("unhandled-xid")
+ from backend.secuscan import config as config_module
+ original = config_module.settings
+ config_module.settings = MagicMock(debug=False)
+ try:
+ response = await custom_unhandled_exception_handler(request, exc)
+ assert response.headers["x-request-id"] == "unhandled-xid"
+ finally:
+ config_module.settings = original