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"

500 Internal Server Error

{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 = ( + "

500 Internal Server Error

" + 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 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