diff --git a/backend/src/control_center/api/routes_llm.py b/backend/src/control_center/api/routes_llm.py index 490e25c..e376093 100644 --- a/backend/src/control_center/api/routes_llm.py +++ b/backend/src/control_center/api/routes_llm.py @@ -3,15 +3,25 @@ import os from pathlib import Path import httpx -from fastapi import APIRouter +from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse +from control_center.core.auth import require_permission + router = APIRouter() OLLAMA_URL = os.environ.get("OLLAMA_BASE_URL", "http://ollama:11434") @router.get("/llms") async def get_llms() -> JSONResponse: + # DELIBERATELY UNAUTHENTICATED. No Depends(require_permission(...)), + # and llm_router is included in main.py with no router-level gate. + # This route backs ControlApp's anonymous LLMs page -- see main.py's + # llm_router include comment and docs/public-control-center.md. The + # response is boolean-only for secrets: `configured` flags, never key + # values (see api_keys below). Also called in-process by + # routes_dashboard.py's _ai_platform_section (a direct function call, + # unaffected by routing either way). # Ollama models models = [] ollama_status = "unreachable" @@ -137,7 +147,15 @@ def _index_size_bytes(index_root: Path) -> int: @router.get("/knowledge-base") -async def get_knowledge_base() -> JSONResponse: +async def get_knowledge_base( + _admin: dict = Depends(require_permission("platform.manage_infra")), +) -> JSONResponse: + # Gated per-route (not via llm_router's include, which is ungated so + # GET /llms above can stay public). Returns absolute internal + # filesystem paths (pubmed_root/index_root) -- same platform.manage_infra + # bar as /summary/docker/config/storage. Commit 8705cbf first added + # this gate at the router level; the 2026-09-02 investigation moved it + # here so it no longer also covers /llms. workspace = Path(os.environ.get("WORKSPACE_ROOT", "/workspace")) pubmed_root = None diff --git a/backend/src/control_center/main.py b/backend/src/control_center/main.py index ca0cb7c..7a81dd3 100644 --- a/backend/src/control_center/main.py +++ b/backend/src/control_center/main.py @@ -174,11 +174,26 @@ def _setup_logging() -> logging.Logger: app.include_router(cron_router) app.include_router(docker_router, dependencies=[Depends(require_permission("platform.manage_infra"))]) app.include_router(known_issues_router) -# llm_router (GET /llms, GET /knowledge-base) previously had no gate at -# all -- /knowledge-base in particular returns absolute internal -# filesystem paths (pubmed_root/index_root). Gated the same way -# docker_router/summary_router are, immediately above. -app.include_router(llm_router, dependencies=[Depends(require_permission("platform.manage_infra"))]) +# llm_router carries NO blanket router-level gate -- its two routes have +# deliberately different exposure: +# - GET /llms is intentionally public (Ollama status + which API-key +# env vars are set as booleans, never key values). It backs +# ControlApp's anonymous LLMs page (frontend/cc-ui/src/apps/ +# ControlApp.tsx, PublicEcosystemPage sibling) -- part of the Public +# Read-Only Control Center design (91755fb, docs/public-control- +# center.md), which ControlApp.tsx's own doc comment still names +# llm_router as no-dependency. Commit 8705cbf's route audit added a +# blanket platform.manage_infra dependency here that collapsed that +# public route into the admin gate along with /knowledge-base; the +# 2026-09-02 investigation confirmed that was an accidental +# over-gate, reverted here. +# - GET /knowledge-base stays gated -- it returns absolute internal +# filesystem paths (pubmed_root/index_root) and is not part of the +# public surface (no ControlApp page calls it). Its +# platform.manage_infra check now lives directly on the route in +# routes_llm.py, the same per-route pattern routes_cron.py uses for +# its two gated GET routes. +app.include_router(llm_router) app.include_router(infra_router) app.include_router(cloud_router) # PR-B6: same ungated posture as cloud_router directly above -- see @@ -726,13 +741,21 @@ def report_generate(_admin: dict = Depends(require_permission("platform.manage_c @app.get("/report/status") -def report_status(_admin: dict = Depends(require_permission("platform.manage_infra"))) -> JSONResponse: +def report_status() -> JSONResponse: """Poll job state. Frontend polls this every 2s while running. - Gated: previously had no auth requirement at all, unlike - /report/generate above (which nginx's auth_request also gates in the - normal topology, but control.omnibioai.org routing directly to this - backend bypasses that -- see main.py's router-inclusion comments).""" + DELIBERATELY UNAUTHENTICATED. This route backs ControlApp's anonymous + Ecosystem Report page (PublicEcosystemPage.tsx polls it to know when a + report exists) -- part of the Public Read-Only Control Center design + (91755fb, docs/public-control-center.md). Commit 8705cbf's route audit + added a platform.manage_infra gate here; the 2026-09-02 investigation + confirmed that was an accidental over-gate (it broke the anonymous + dashboard it was auditing) and reverted it. /report/generate (POST, + above) stays platform.manage_content-gated, and GET / plus + GET /report/data stay platform.manage_infra-gated -- only this poll + route and GET /llms were restored. The response carries job state + plus report_exists/report_generated_at; `message` can hold report-job + stderr on failure -- a known minor follow-up, not re-scoped here.""" state = _job.as_dict() report_path = _workspace_root() / "work" / "out" / "reports" / "omnibioai_ecosystem_report.html" state["report_exists"] = report_path.exists() @@ -856,6 +879,168 @@ def report_data(_admin: dict = Depends(require_permission("platform.manage_infra return JSONResponse({"error": str(e)}, status_code=500) +# ============================================================================== +# GET /report/public-stats — deliberately unauthenticated ecosystem totals +# ============================================================================== +# +# Unlike GET / and GET /report/data (both platform.manage_infra-gated), +# this route is reachable with no token at all. It exposes ONLY a handful +# of ecosystem-wide aggregate numbers and never the per-repo breakdowns +# that make /report/data sensitive. +# +# Design intent: docs/public-control-center.md (the Public Read-Only +# Control Center architecture, 91755fb / 3cbc785) established a +# public/admin split. Commit 8705cbf's route audit gated GET /report/data +# wholesale; the 2026-09-02 investigation deliberately declined to revert +# that (its projects[]/languages[]/coverage[]/gitStatus[] arrays leak the +# private repo roster and live dev state), and this narrow endpoint is the +# agreed replacement for the one genuinely-public slice of that data -- +# "how many lines of code, how well tested", with nothing that names a +# repo or reveals structure. +# +# CONTRACT (fail-closed, mirrors routes_dashboard.py's PUBLIC_FIELDS / +# _apply_public_contract): the response is built by EXPLICIT ALLOWLIST. +# _PUBLIC_STATS_FIELDS names every key; _build_public_stats() constructs a +# fresh dict with exactly those keys, reading only named scalars out of +# report_data.json. That file is never spread, merged, or filtered into +# the response -- so projects[], languages[], the per-repo coverage[] +# rows, and gitStatus[] cannot appear here under any code path, and a +# sensitive field added to report_data.json later cannot leak by omission +# (it simply isn't read). + +_PUBLIC_STATS_FIELDS = ( + "generated_at", + "total_lines", + "total_files", + "ecosystem_coverage_percent", + "repos_measured", +) + +# Returned verbatim when no report has been generated yet (or the data +# file is unreadable/malformed) -- HTTP 200 with every value null, same +# "fail to null, never 404 for an anonymous caller" posture +# GET /dashboard/summary uses. repos_measured is 0 (a count), not null. +_PUBLIC_STATS_NULL = { + "generated_at": None, + "total_lines": None, + "total_files": None, + "ecosystem_coverage_percent": None, + "repos_measured": 0, +} + +# The null shape and the allowlist must stay in lockstep -- both paths +# (report present / absent) return exactly these five keys. +assert set(_PUBLIC_STATS_NULL) == set(_PUBLIC_STATS_FIELDS) + + +def _build_public_stats(raw: dict) -> dict: + """Construct the /report/public-stats response from a parsed + report_data.json, naming every output key explicitly. + + ecosystem_coverage_percent is a STATEMENT-WEIGHTED average: + + 100 * sum(stmts - missed) / sum(stmts) + + over coverage[] rows that have a non-null `pct` AND numeric + `stmts`/`missed`. This is deliberately NOT the figure the HTML + report's Code Coverage tab shows -- that one + (scripts/sections/coverage.py: `valid["coverage_pct"].mean()`) is an + UNWEIGHTED mean of per-repo percentages, which over-weights small + repos. Weighted-by-statements is the more statistically honest + ecosystem-wide number; the two definitions are kept distinct on + purpose, so don't "reconcile" them. + + Only aggregate scalars are read here: grand.code, grand.files, + generated_at, and (stmts, missed, pct) off each coverage row. The + per-repo identity of those rows -- coverage[].repo, and the entire + projects[]/languages[]/gitStatus[] arrays -- is never touched. See + this section's CONTRACT note. + """ + grand = raw.get("grand") + grand = grand if isinstance(grand, dict) else {} + + coverage_rows = raw.get("coverage") + coverage_rows = coverage_rows if isinstance(coverage_rows, list) else [] + + total_stmts = 0.0 + covered_stmts = 0.0 + repos_measured = 0 + for row in coverage_rows: + if not isinstance(row, dict): + continue + pct = row.get("pct") + if pct is None: + continue + # repos_measured counts every row that actually produced a + # coverage percentage, matching the "with data" figure the HTML + # report shows. + repos_measured += 1 + stmts = row.get("stmts") + missed = row.get("missed") + if ( + isinstance(stmts, (int, float)) + and not isinstance(stmts, bool) + and stmts > 0 + and isinstance(missed, (int, float)) + and not isinstance(missed, bool) + ): + total_stmts += stmts + covered_stmts += max(stmts - missed, 0) + + ecosystem_coverage_percent = ( + round(100.0 * covered_stmts / total_stmts, 2) if total_stmts > 0 else None + ) + + total_lines = grand.get("code") + total_files = grand.get("files") + built = { + "generated_at": raw.get("generated_at"), + "total_lines": total_lines if isinstance(total_lines, int) else None, + "total_files": total_files if isinstance(total_files, int) else None, + "ecosystem_coverage_percent": ecosystem_coverage_percent, + "repos_measured": repos_measured, + } + # Final projection through the allowlist: the response can only ever + # contain _PUBLIC_STATS_FIELDS keys, regardless of what `built` + # picked up. Belt-and-suspenders on top of `built` already being + # hand-constructed -- this is the line that makes "projects[]/ + # languages[]/coverage[]/gitStatus[] can never appear here" + # structurally true rather than true-by-inspection. + return {k: built[k] for k in _PUBLIC_STATS_FIELDS} + + +@app.get("/report/public-stats") +def report_public_stats() -> JSONResponse: + """Ecosystem-wide aggregate code stats -- DELIBERATELY UNAUTHENTICATED. + + No Depends(require_permission(...)), and this route is registered + directly on `app`, NOT on report_router (which main.py's + include_router() gates behind platform.manage_infra). Returns exactly + the five keys in _PUBLIC_STATS_FIELDS -- {generated_at, total_lines, + total_files, ecosystem_coverage_percent, repos_measured} -- and never + the per-repo projects[]/languages[]/coverage[]/gitStatus[] arrays that + keep GET /report/data admin-only. See the section comment above for + the full rationale (docs/public-control-center.md, the 2026-09-02 + public/admin-split investigation). + + Fails to null, not 404: with no report generated yet, returns HTTP 200 + and _PUBLIC_STATS_NULL, matching GET /dashboard/summary's posture for + an anonymous caller.""" + data_path = _workspace_root() / "work" / "out" / "reports" / "report_data.json" + if not data_path.exists(): + return JSONResponse(dict(_PUBLIC_STATS_NULL)) + try: + import json as _json + raw = _json.loads(data_path.read_text(encoding="utf-8")) + except Exception: + # Unreadable / malformed report data -- same null shape rather + # than surfacing a parse error to an anonymous caller. + return JSONResponse(dict(_PUBLIC_STATS_NULL)) + if not isinstance(raw, dict): + return JSONResponse(dict(_PUBLIC_STATS_NULL)) + return JSONResponse(_build_public_stats(raw)) + + # ============================================================================== # Scheduled report generation # ============================================================================== diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index e9731f9..1fd66de 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -208,7 +208,16 @@ def test_has_status(self): self.assertIn("status", client.get("/report/status", def test_has_report_exists(self): self.assertIn("report_exists", client.get("/report/status", headers=_admin_headers()).json()) def test_has_generated_at(self): self.assertIn("report_generated_at", client.get("/report/status", headers=_admin_headers()).json()) def test_idle_by_default(self): self.assertEqual(client.get("/report/status", headers=_admin_headers()).json()["status"], "idle") - def test_401_when_no_token(self): self.assertEqual(client.get("/report/status").status_code, 401) + def test_200_when_no_token(self): + # DELIBERATELY PUBLIC (restored): commit 8705cbf gated this route + # behind platform.manage_infra, which broke ControlApp's anonymous + # Ecosystem Report page (PublicEcosystemPage.tsx polls it). The + # 2026-09-02 public/admin-split investigation reverted that gate. + # Was `test_401_when_no_token` asserting 401 here. + resp = client.get("/report/status") + self.assertEqual(resp.status_code, 200) + self.assertIn("status", resp.json()) + self.assertIn("report_exists", resp.json()) def test_report_exists_false(self): os.environ["WORKSPACE_ROOT"] = "/nonexistent" try: self.assertFalse(client.get("/report/status", headers=_admin_headers()).json()["report_exists"]) @@ -561,15 +570,20 @@ class TestPlatformManageInfraAuth(unittest.TestCase): cover only the authorization layer itself: missing token, wrong permission, and correct permission, once per gated router. - Extended (control.omnibioai.org direct-tunnel audit) to cover every - route that was found reachable with no auth at all: /, /report, - /report/status, /report/data, /coverage/status, /llms, - /knowledge-base, /storage, /cron/jobs, /cron/jobs/{id}/log. Each of - these previously returned 200 with no Authorization header -- see - test_main.py's TestDashboard/TestReportStatus/TestReportData/ - TestCoverageStatus and test_routes_cron.py's own 401 tests for the - per-route regression proof; this class only proves the shared gate - itself across all of them at once, same as the four routers above.""" + Extended (control.omnibioai.org direct-tunnel audit, commit 8705cbf) + to cover routes that were found reachable with no auth at all: /, + /report, /report/data, /coverage/status, /knowledge-base, /storage, + /cron/jobs, /cron/jobs/{id}/log. + + NOTE (2026-09-02 public/admin-split investigation): /report/status and + /llms were in this list but have been removed -- 8705cbf's audit + over-gated them. Both are part of the Public Read-Only Control Center + design (91755fb, docs/public-control-center.md): /report/status backs + ControlApp's anonymous Ecosystem Report page and /llms its anonymous + LLMs page. Their "public, no token needed" behavior is now asserted by + TestReportStatus.test_200_when_no_token and TestLlmsPublicAccess + respectively. Everything still in _cases() below stays gated -- that + is the regression guard this investigation must not weaken.""" def _cases(self): return ( @@ -579,10 +593,8 @@ def _cases(self): ("GET", "/config"), ("GET", "/"), ("GET", "/report"), - ("GET", "/report/status"), ("GET", "/report/data"), ("GET", "/coverage/status"), - ("GET", "/llms"), ("GET", "/knowledge-base"), ("GET", "/storage"), ("GET", "/cron/jobs"), @@ -610,5 +622,221 @@ def test_not_401_or_403_with_infra_permission(self): self.assertNotIn(resp.status_code, (401, 403)) +# A fully-populated report_data.json: every array that must NEVER reach +# /report/public-stats is present and non-empty here, plus realistic +# aggregate scalars. Shared by the negative-leak tests below. +_FULL_REPORT_DATA = { + "generated_at": "2026-09-02T04:00:00+00:00", + "grand": {"files": 14820, "code": 1863200, "comment": 240100, "blank": 190500}, + "projects": [ + {"name": "tes", "full": "omnibioai-tes", "cat": "execution", "catLabel": "Execution", + "files": 900, "code": 120000, "comment": 15000, "blank": 12000, "pct": 6.44}, + {"name": "auth", "full": "omnibioai-auth", "cat": "security", "catLabel": "Security", + "files": 300, "code": 40000, "comment": 5000, "blank": 4000, "pct": 2.15}, + ], + "languages": [ + {"name": "Python", "type": "backend", "typeLabel": "Backend", + "files": 6000, "code": 900000, "comment": 120000, "blank": 90000, "pct": 48.3}, + {"name": "TypeScript", "type": "frontend", "typeLabel": "Frontend", + "files": 4000, "code": 500000, "comment": 40000, "blank": 50000, "pct": 26.8}, + ], + "coverage": [ + {"repo": "omnibioai-tes", "status": "ok", "pct": 92.5, + "stmts": 4000, "missed": 300, "branches": 800, "failUnder": 90.0}, + {"repo": "omnibioai-auth", "status": "ok", "pct": 61.0, + "stmts": 2000, "missed": 780, "branches": 400, "failUnder": 85.0}, + {"repo": "omnibioai-rag", "status": "no_total_found", "pct": None, + "stmts": None, "missed": None, "branches": None, "failUnder": None}, + ], + "gitStatus": [ + {"repo": "omnibioai-tes", "branch": "feat/secret-internal-branch", "nonMain": True, + "clean": False, "modified": 3, "untracked": 1, "unpushed": 2, "details": "3 modified, 1 untracked, 2 unpushed"}, + ], +} + +# The exact set of keys /report/public-stats is allowed to return, and +# every substring that would prove a per-repo array leaked in. +_PUBLIC_STATS_KEYS = { + "generated_at", "total_lines", "total_files", + "ecosystem_coverage_percent", "repos_measured", +} +# Substrings that would only be present if a per-repo array leaked in. +# Deliberately avoids bare "coverage"/"repos" -- those collide with the +# legitimate keys ecosystem_coverage_percent / repos_measured. Uses the +# JSON-quoted array keys plus distinctive per-repo values instead. +_LEAK_MARKERS = ( + '"projects"', '"languages"', '"gitStatus"', '"coverage":', + "omnibioai-tes", "omnibioai-auth", "omnibioai-rag", + "feat/secret-internal-branch", "unpushed", "failUnder", +) + + +class TestReportPublicStats(unittest.TestCase): + """PART 1 (2026-09-02 investigation): the new deliberately-public + GET /report/public-stats. No token, five aggregate keys only, and -- + critically -- the per-repo arrays from report_data.json can never + appear in it under any code path.""" + + def _get_with_data(self, data, *, headers=None): + with tempfile.TemporaryDirectory() as tmp: + reports_dir = Path(tmp) / "work" / "out" / "reports" + reports_dir.mkdir(parents=True) + import json as _json + (reports_dir / "report_data.json").write_text(_json.dumps(data)) + with patch("control_center.main._workspace_root", return_value=Path(tmp)): + return client.get("/report/public-stats", headers=headers or {}) + + def test_200_no_token_exact_keys(self): + resp = self._get_with_data(_FULL_REPORT_DATA) + self.assertEqual(resp.status_code, 200) + self.assertEqual(set(resp.json().keys()), _PUBLIC_STATS_KEYS) + + def test_values_from_fixture(self): + body = self._get_with_data(_FULL_REPORT_DATA).json() + self.assertEqual(body["generated_at"], "2026-09-02T04:00:00+00:00") + self.assertEqual(body["total_lines"], 1863200) + self.assertEqual(body["total_files"], 14820) + # statement-weighted: (4000-300)+(2000-780) = 4920 covered of + # 6000 total stmts -> 82.0%. NOT the unweighted mean of + # (92.5, 61.0) = 76.75 the HTML report would show. + self.assertEqual(body["ecosystem_coverage_percent"], 82.0) + # two rows have a non-null pct; the third (pct=None) does not. + self.assertEqual(body["repos_measured"], 2) + + def test_never_leaks_per_repo_arrays_even_when_populated(self): + """The critical negative test: a fully-populated report_data.json + (projects/languages/coverage/gitStatus all present) must still + produce a response with none of them, by key or by value.""" + resp = self._get_with_data(_FULL_REPORT_DATA) + body = resp.json() + self.assertEqual(set(body.keys()), _PUBLIC_STATS_KEYS) + raw_text = resp.text + for marker in _LEAK_MARKERS: + with self.subTest(marker=marker): + self.assertNotIn(marker, raw_text) + # and nothing list/dict-shaped snuck through as a value + for v in body.values(): + self.assertNotIsInstance(v, (list, dict)) + + def test_token_does_not_change_shape(self): + """Presence of a valid admin token must not widen the response -- + this endpoint has exactly one shape for everyone.""" + anon = self._get_with_data(_FULL_REPORT_DATA).json() + authed = self._get_with_data(_FULL_REPORT_DATA, headers=_admin_headers()).json() + self.assertEqual(anon, authed) + + def test_no_report_data_returns_200_null_shape_not_404(self): + with tempfile.TemporaryDirectory() as tmp: + with patch("control_center.main._workspace_root", return_value=Path(tmp)): + resp = client.get("/report/public-stats") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), { + "generated_at": None, + "total_lines": None, + "total_files": None, + "ecosystem_coverage_percent": None, + "repos_measured": 0, + }) + + def test_malformed_report_data_returns_200_null_shape(self): + with tempfile.TemporaryDirectory() as tmp: + reports_dir = Path(tmp) / "work" / "out" / "reports" + reports_dir.mkdir(parents=True) + (reports_dir / "report_data.json").write_text("not-json{") + with patch("control_center.main._workspace_root", return_value=Path(tmp)): + resp = client.get("/report/public-stats") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["repos_measured"], 0) + self.assertIsNone(resp.json()["ecosystem_coverage_percent"]) + + def test_no_coverage_rows_with_data_gives_null_percent(self): + data = dict(_FULL_REPORT_DATA) + data["coverage"] = [ + {"repo": "x", "status": "no_total_found", "pct": None, + "stmts": None, "missed": None, "branches": None, "failUnder": None}, + ] + body = self._get_with_data(data).json() + self.assertIsNone(body["ecosystem_coverage_percent"]) + self.assertEqual(body["repos_measured"], 0) + + def test_non_dict_top_level_json_returns_null_shape(self): + # report_data.json is valid JSON but not an object (e.g. a bare + # list) -- treated the same as absent/malformed. + body = self._get_with_data([1, 2, 3]).json() + self.assertEqual(body["repos_measured"], 0) + self.assertIsNone(body["total_lines"]) + + def test_non_dict_coverage_row_is_skipped(self): + data = dict(_FULL_REPORT_DATA) + data["coverage"] = [ + "junk", + {"repo": "omnibioai-tes", "pct": 90.0, "stmts": 1000, "missed": 100}, + ] + body = self._get_with_data(data).json() + self.assertEqual(body["repos_measured"], 1) + self.assertEqual(body["ecosystem_coverage_percent"], 90.0) + + def test_grand_missing_gives_null_totals_but_still_computes_coverage(self): + data = {k: v for k, v in _FULL_REPORT_DATA.items() if k != "grand"} + body = self._get_with_data(data).json() + self.assertIsNone(body["total_lines"]) + self.assertIsNone(body["total_files"]) + self.assertEqual(body["ecosystem_coverage_percent"], 82.0) + + def test_registered_directly_on_app_not_report_router(self): + """report_router carries a platform.manage_infra include-time gate; + this route must not be on it (it 200s with no token, proven + above). Guard the structural placement too.""" + import control_center.api.routes_report as routes_report + report_router_paths = {r.path for r in routes_report.router.routes} + self.assertNotIn("/report/public-stats", report_router_paths) + + +class TestLlmsPublicAccess(unittest.TestCase): + """PART 2 (2026-09-02 investigation): GET /llms restored to public. + Commit 8705cbf's blanket llm_router gate collapsed it into the admin + tier; it backs ControlApp's anonymous LLMs page (91755fb, + docs/public-control-center.md). GET /knowledge-base on the same + router stays gated -- see TestKnowledgeBaseStillGated below and + TestPlatformManageInfraAuth.""" + + def test_200_when_no_token(self): + resp = client.get("/llms") + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertIn("ollama", body) + self.assertIn("api_keys", body) + # boolean-only for secrets: no key value ever, just `configured`. + for provider in body["api_keys"].values(): + self.assertIn("configured", provider) + self.assertIsInstance(provider["configured"], bool) + + def test_200_with_token_too(self): + self.assertEqual(client.get("/llms", headers=_admin_headers()).status_code, 200) + + +class TestOverGateRegressionGuard(unittest.TestCase): + """The 2026-09-02 revert must not spill past /report/status + /llms. + Every route below stays platform.manage_infra-gated (401 w/o token) + exactly as commit 8705cbf left it. Overlaps TestPlatformManageInfraAuth + on purpose -- this one is the named, human-readable list from the + change's own scope statement.""" + + STILL_GATED = ( + "/", + "/report/data", + "/coverage/status", + "/knowledge-base", + "/storage", + "/cron/jobs", + "/cron/jobs/mysql-backup/log", + ) + + def test_still_401_without_token(self): + for path in self.STILL_GATED: + with self.subTest(path=path): + self.assertEqual(client.get(path).status_code, 401) + + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_public_dashboard_no_leak.py b/backend/tests/test_public_dashboard_no_leak.py index 7e08397..c5d4fe8 100644 --- a/backend/tests/test_public_dashboard_no_leak.py +++ b/backend/tests/test_public_dashboard_no_leak.py @@ -14,7 +14,9 @@ from __future__ import annotations import json +import tempfile import unittest +from pathlib import Path from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient @@ -154,5 +156,49 @@ def test_integrity(self) -> None: self.assertEqual(_find_forbidden_keys(data), []) +class TestReportPublicStatsCarriesNoForbiddenKeys(unittest.TestCase): + """2026-09-02 public/admin-split investigation: GET /report/public-stats + is deliberately anonymous. Its whole design is that it returns only + ecosystem-wide aggregate scalars -- so beyond the forbidden-key sweep + every other public endpoint gets, assert the per-repo arrays from + report_data.json never appear in it even when that file is fully + populated with them.""" + + _FULL = { + "generated_at": "2026-09-02T04:00:00+00:00", + "grand": {"files": 14820, "code": 1863200, "comment": 240100, "blank": 190500}, + "projects": [{"full": "omnibioai-tes", "code": 120000, "pct": 6.44}], + "languages": [{"name": "Python", "code": 900000, "pct": 48.3}], + "coverage": [ + {"repo": "omnibioai-tes", "pct": 92.5, "stmts": 4000, "missed": 300}, + {"repo": "omnibioai-auth", "pct": 61.0, "stmts": 2000, "missed": 780}, + ], + "gitStatus": [{"repo": "omnibioai-tes", "branch": "feat/wip", "unpushed": 2}], + } + + def _get(self): + with tempfile.TemporaryDirectory() as tmp: + reports_dir = Path(tmp) / "work" / "out" / "reports" + reports_dir.mkdir(parents=True) + (reports_dir / "report_data.json").write_text(json.dumps(self._FULL)) + with patch("control_center.main._workspace_root", return_value=Path(tmp)): + return client.get("/report/public-stats") + + def test_no_forbidden_keys(self) -> None: + self.assertEqual(_find_forbidden_keys(self._get().json()), []) + + def test_no_per_repo_arrays_or_repo_names(self) -> None: + resp = self._get() + body = resp.json() + self.assertEqual( + set(body), + {"generated_at", "total_lines", "total_files", + "ecosystem_coverage_percent", "repos_measured"}, + ) + for marker in ('"projects"', '"languages"', '"gitStatus"', '"coverage":', + "omnibioai-tes", "omnibioai-auth", "feat/wip", "unpushed"): + self.assertNotIn(marker, resp.text) + + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_routes_llm.py b/backend/tests/test_routes_llm.py index c0fd190..13bcda1 100644 --- a/backend/tests/test_routes_llm.py +++ b/backend/tests/test_routes_llm.py @@ -22,11 +22,14 @@ from control_center.core.jwt_verify import JWT_SECRET from control_center.main import app -# llm_router is gated at router-inclusion time (main.py) behind -# platform.manage_infra -- these tests exercise the routes' own logic, not -# authorization (see test_main.py for the 401/403 permission checks), so -# the client carries a fixed, always-sufficient token by default, same -# convention as test_routes_docker.py. +# Route exposure (2026-09-02 public/admin-split investigation): llm_router +# has NO blanket gate -- GET /llms is deliberately public, GET +# /knowledge-base carries its own per-route platform.manage_infra Depends. +# These tests exercise the routes' own logic, not authorization (see +# test_main.py's TestLlmsPublicAccess / TestPlatformManageInfraAuth for +# the access checks), so the client carries a fixed always-sufficient +# token by default -- harmless for /llms, required for /knowledge-base, +# same convention as test_routes_docker.py. _INFRA_TOKEN = jwt.encode({"sub": "1", "permissions": ["platform.manage_infra"]}, JWT_SECRET, algorithm="HS256") client = TestClient(app, headers={"Authorization": f"Bearer {_INFRA_TOKEN}"})