From fabd3e3b2cb6c8eba3b40b42f423e725c3ffdf10 Mon Sep 17 00:00:00 2001 From: Mischa Spiegelmock Date: Wed, 18 Feb 2026 12:00:29 -0800 Subject: [PATCH] fix: return 404 instead of 500 for stale scanner UUIDs When a scan is re-run, UUIDs are regenerated. Users with stale browser tabs would get unhandled KeyError (500) when fetching scanner input for old UUIDs. Wrap get_field calls in try/except KeyError to return a proper 404. Fixes Sentry HAWK-14. Co-Authored-By: Claude Opus 4.6 --- src/inspect_scout/_view/_api_v2_scans.py | 12 +++- tests/view/test_api_v2_scanner_input.py | 84 ++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/view/test_api_v2_scanner_input.py diff --git a/src/inspect_scout/_view/_api_v2_scans.py b/src/inspect_scout/_view/_api_v2_scans.py index 223b19034..8e0eb9003 100644 --- a/src/inspect_scout/_view/_api_v2_scans.py +++ b/src/inspect_scout/_view/_api_v2_scans.py @@ -282,8 +282,16 @@ async def scanner_input( detail=f"Scanner '{scanner}' not found in scan results", ) - input_value = result.get_field(scanner, "uuid", uuid, "input").as_py() - input_type = result.get_field(scanner, "uuid", uuid, "input_type").as_py() + try: + input_value: str = result.get_field(scanner, "uuid", uuid, "input").as_py() + input_type: str | None = result.get_field( + scanner, "uuid", uuid, "input_type" + ).as_py() + except KeyError: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"UUID '{uuid}' not found in scanner '{scanner}' results", + ) from None return Response( content=input_value, diff --git a/tests/view/test_api_v2_scanner_input.py b/tests/view/test_api_v2_scanner_input.py new file mode 100644 index 000000000..a26bd5906 --- /dev/null +++ b/tests/view/test_api_v2_scanner_input.py @@ -0,0 +1,84 @@ +"""Tests for scanner input endpoint (GET /scans/{dir}/{scan}/{scanner}/{uuid}/input).""" + +import base64 +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi.testclient import TestClient +from inspect_scout._view._api_v2 import v2_api_app + + +def _base64url(s: str) -> str: + """Encode string as base64url (URL-safe base64 without padding).""" + return base64.urlsafe_b64encode(s.encode()).decode().rstrip("=") + + +_SCANS_DIR = _base64url("/tmp/scans") +_SCAN_PATH = _base64url("scan_id=test123") + + +def _url(scanner: str, uuid: str) -> str: + return f"/scans/{_SCANS_DIR}/{_SCAN_PATH}/{scanner}/{uuid}/input" + + +def _make_mock_result(scanners: list[str]) -> MagicMock: + """Create a mock ScanResultsArrow.""" + result = MagicMock() + result.scanners = scanners + return result + + +class TestScannerInputEndpoint: + """Tests for the scanner_input endpoint.""" + + def test_returns_404_for_unknown_uuid(self) -> None: + """When get_field raises KeyError for a missing UUID, return 404.""" + mock_result = _make_mock_result(scanners=["refusal"]) + mock_result.get_field.side_effect = KeyError("'abc123' not found in uuid") + + client = TestClient(v2_api_app()) + with patch( + "inspect_scout._view._api_v2_scans.scan_results_arrow_async", + new_callable=AsyncMock, + return_value=mock_result, + ): + response = client.get(_url("refusal", "nonexistent-uuid")) + + assert response.status_code == 404 + assert "nonexistent-uuid" in response.json()["detail"] + + def test_returns_404_for_unknown_scanner(self) -> None: + """When scanner is not in results, return 404.""" + mock_result = _make_mock_result(scanners=["refusal"]) + + client = TestClient(v2_api_app()) + with patch( + "inspect_scout._view._api_v2_scans.scan_results_arrow_async", + new_callable=AsyncMock, + return_value=mock_result, + ): + response = client.get(_url("nonexistent", "some-uuid")) + + assert response.status_code == 404 + assert "nonexistent" in response.json()["detail"] + + def test_returns_input_for_valid_uuid(self) -> None: + """When UUID exists, return the input content with input type header.""" + mock_result = _make_mock_result(scanners=["refusal"]) + + input_scalar = MagicMock() + input_scalar.as_py.return_value = "the input text" + type_scalar = MagicMock() + type_scalar.as_py.return_value = "text" + mock_result.get_field.side_effect = [input_scalar, type_scalar] + + client = TestClient(v2_api_app()) + with patch( + "inspect_scout._view._api_v2_scans.scan_results_arrow_async", + new_callable=AsyncMock, + return_value=mock_result, + ): + response = client.get(_url("refusal", "valid-uuid")) + + assert response.status_code == 200 + assert response.text == "the input text" + assert response.headers["X-Input-Type"] == "text"