From 68598e21724f824ca994a6b6c9d838c58a71c1b1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 18:43:26 +0000 Subject: [PATCH 1/2] fix(auth): protect GET /api/stats with read-only API key router Register /api/stats on protected_api so project metadata is not enumerable when CODE_SEARCH_API_KEY is configured. Add regression tests for open-when-unset and 401/200 auth behavior. Fixes #7 Co-authored-by: Solomon Neas --- src/code_search_api/server.py | 2 +- tests/test_auth.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/code_search_api/server.py b/src/code_search_api/server.py index ce1a98c..f9ef6ed 100644 --- a/src/code_search_api/server.py +++ b/src/code_search_api/server.py @@ -1313,7 +1313,7 @@ def list_projects() -> dict[str, Any]: return {"projects": [dict(r) for r in rows]} -@app.get("/api/stats") +@protected_api.get("/api/stats") def stats() -> dict[str, Any]: """Detailed stats about chunk types and coverage.""" with closing(get_conn()) as conn: diff --git a/tests/test_auth.py b/tests/test_auth.py index c5db123..9e77134 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -130,3 +130,26 @@ def test_readonly_route_requires_key_when_configured(server_module): good_key = client.get("/api/projects", headers={"X-API-Key": API_KEY}) assert no_key.status_code == 401 assert good_key.status_code == 200 + + +def test_stats_route_open_when_key_unset(server_module): + """GET /api/stats stays open when no key is configured.""" + server_module.CODE_SEARCH_API_KEY = None + with _client(server_module) as client: + resp = client.get("/api/stats") + assert resp.status_code == 200 + assert "by_project" in resp.json() + + +def test_stats_route_requires_key_when_configured(server_module): + """GET /api/stats must not leak project metadata without credentials.""" + server_module.CODE_SEARCH_API_KEY = API_KEY + with _client(server_module) as client: + no_key = client.get("/api/stats") + x_api_key = client.get("/api/stats", headers={"X-API-Key": API_KEY}) + bearer = client.get( + "/api/stats", headers={"Authorization": f"Bearer {API_KEY}"} + ) + assert no_key.status_code == 401 + assert x_api_key.status_code == 200 + assert bearer.status_code == 200 From 89dbc5b5e0b1cba6853301ef942007eae4e1bdbe Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Mon, 10 Aug 2026 15:04:37 -0400 Subject: [PATCH 2/2] test(auth): cover metadata endpoint credentials --- AGENTS.md | 12 +++++------ README.md | 2 +- scripts/verify | 8 +++++--- tests/test_auth.py | 50 ++++++++++++++++++++-------------------------- 4 files changed, 34 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 67b4ee8..19911c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,8 @@ Read this entire file before changing anything. Every rule is binding. ./scripts/verify ``` It runs the `mcp/` gates (`npm run typecheck`, `npm test`, `npm run build`) -and the Python check (throwaway-venv `pip install -e .`, `code-search-api ---help`, and a loopback serve smoke test against a throwaway +and the Python checks (throwaway-venv package install, `pytest`, +`code-search-api --help`, and a loopback serve smoke test against a throwaway `CODE_SEARCH_DB`, never port 5204). A change may be reported complete only when every applicable check below has @@ -18,10 +18,10 @@ failure verbatim and do not claim success. - `npm run typecheck` - `npm test` - `npm run build` -- Touched Python (`src/`, `pyproject.toml`)? There is no Python test suite. - Smallest real check: `pip install -e .` in a throwaway venv, then run - `code-search-api --help` and `code-search-api serve` against a throwaway - `CODE_SEARCH_DB`. Never point any check at the production database. +- Touched Python (`src/`, `pyproject.toml`)? Run the focused Python tests, then + `./scripts/verify`, which runs the complete Python suite in a throwaway venv + before the CLI and serve smoke checks. Never point any check at the + production database. - Docs-only change? Verify every command and path named in the file exists. ## Project Shape diff --git a/README.md b/README.md index c3b5c41..6c5479e 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ CODE_SEARCH_SUMMARY_MODEL=qwen3:32b # local, needs ~20GB VRAM | `POST` | `/api/index` | Yes | Trigger background indexing run | | `POST` | `/api/backfill-summaries` | Yes | Generate summaries for unsummarized chunks | | `GET` | `/api/projects` | Yes | Per-project chunk and summary counts | -| `GET` | `/api/stats` | No | Chunk type breakdown and project coverage | +| `GET` | `/api/stats` | Yes | Chunk type breakdown and project coverage | | `GET` | `/api/summary-stats` | Yes | Summary counts by model | ### Search request diff --git a/scripts/verify b/scripts/verify index 974cc1c..0f6da07 100755 --- a/scripts/verify +++ b/scripts/verify @@ -4,8 +4,9 @@ # # Gates, in order: # 1. mcp/: npm run typecheck, npm test, npm run build -# 2. Python: pip install -e . into a throwaway venv, code-search-api --help, -# then a serve smoke test on 127.0.0.1 with a throwaway CODE_SEARCH_DB. +# 2. Python: install the package and pytest into a throwaway venv, run the +# Python tests, run code-search-api --help, then a serve smoke test on +# 127.0.0.1 with a throwaway CODE_SEARCH_DB. # Never touches the live 5204 instance or any real database. set -euo pipefail cd "$(dirname "$0")/.." @@ -31,7 +32,8 @@ cleanup() { trap cleanup EXIT python3 -m venv "$TMPDIR_VERIFY/venv" -"$TMPDIR_VERIFY/venv/bin/pip" install --quiet -e . +"$TMPDIR_VERIFY/venv/bin/pip" install --quiet pytest -e . +"$TMPDIR_VERIFY/venv/bin/python" -m pytest -q "$TMPDIR_VERIFY/venv/bin/code-search-api" --help >/dev/null # Serve smoke test: throwaway DB, loopback only, never port 5204. diff --git a/tests/test_auth.py b/tests/test_auth.py index 9e77134..f6343ca 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -22,6 +22,11 @@ from fastapi.testclient import TestClient API_KEY = "test-secret-key" +METADATA_ENDPOINTS = [ + ("/api/projects", "projects"), + ("/api/stats", "by_project"), + ("/api/summary-stats", "total_chunks"), +] @pytest.fixture() @@ -112,44 +117,33 @@ def test_query_param_key_is_not_accepted(server_module): assert resp_named.status_code == 401 -def test_readonly_route_open_when_key_unset(server_module): - """Read-only endpoints stay open when no key is configured.""" +@pytest.mark.parametrize(("path", "response_key"), METADATA_ENDPOINTS) +def test_metadata_routes_open_when_key_unset(server_module, path, response_key): + """Metadata endpoints stay open when no API key is configured.""" server_module.CODE_SEARCH_API_KEY = None with _client(server_module) as client: - resp = client.get("/api/projects") + resp = client.get(path) # Auth must not block; the handler returns its normal payload. assert resp.status_code == 200 - assert "projects" in resp.json() + assert response_key in resp.json() -def test_readonly_route_requires_key_when_configured(server_module): - """When a key is set, read-only protected routes still validate it.""" +@pytest.mark.parametrize(("path", "response_key"), METADATA_ENDPOINTS) +def test_metadata_routes_require_valid_key_when_configured( + server_module, path, response_key, +): + """Configured API keys protect every project-metadata route.""" server_module.CODE_SEARCH_API_KEY = API_KEY with _client(server_module) as client: - no_key = client.get("/api/projects") - good_key = client.get("/api/projects", headers={"X-API-Key": API_KEY}) - assert no_key.status_code == 401 - assert good_key.status_code == 200 - - -def test_stats_route_open_when_key_unset(server_module): - """GET /api/stats stays open when no key is configured.""" - server_module.CODE_SEARCH_API_KEY = None - with _client(server_module) as client: - resp = client.get("/api/stats") - assert resp.status_code == 200 - assert "by_project" in resp.json() - - -def test_stats_route_requires_key_when_configured(server_module): - """GET /api/stats must not leak project metadata without credentials.""" - server_module.CODE_SEARCH_API_KEY = API_KEY - with _client(server_module) as client: - no_key = client.get("/api/stats") - x_api_key = client.get("/api/stats", headers={"X-API-Key": API_KEY}) + no_key = client.get(path) + wrong_key = client.get(path, headers={"X-API-Key": "wrong"}) + x_api_key = client.get(path, headers={"X-API-Key": API_KEY}) bearer = client.get( - "/api/stats", headers={"Authorization": f"Bearer {API_KEY}"} + path, headers={"Authorization": f"Bearer {API_KEY}"}, ) assert no_key.status_code == 401 + assert wrong_key.status_code == 401 assert x_api_key.status_code == 200 assert bearer.status_code == 200 + assert response_key in x_api_key.json() + assert response_key in bearer.json()