"""Tests for the read-only system-health API (#634). Covers the acceptance criteria directly: a structured payload with readiness and a dependency list (AC1), version and uptime when knowable (AC2), stale runtime reported without a false mutation-safe claim (AC3), and the healthy / degraded-dependency / redaction cases (AC4). """ import json import os import sqlite3 import sys import tempfile import unittest from pathlib import Path from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from starlette.testclient import TestClient import control_plane_db from webui.app import create_app from webui.deployment_boundary import scan_text_for_client_secrets from webui.system_health import ( API_PATH, STATUS_DEGRADED, STATUS_DOWN, STATUS_OK, STATUS_SKIPPED, DependencyProbe, StaleRuntime, assess_stale_runtime, clear_probe_cache, load_system_health, namespace_summaries, probe_control_plane_db, probe_gitea, process_uptime, redact, redact_url, snapshot_to_dict, ) def _probe(name, status, *, required=True, detail="detail", kind="test"): return DependencyProbe( name=name, kind=kind, status=status, detail=detail, required=required, latency_ms=1.5, metadata={}, ) _ALL_HEALTHY = ( _probe("control_plane_db", STATUS_OK, kind="sqlite"), _probe("repository", STATUS_OK, kind="git"), _probe("gitea", STATUS_OK, required=False, kind="http"), ) _CLEAN_PARITY = StaleRuntime( daemon_head="abc123", checkout_head="abc123", remote_head="abc123", stale=False, determinable=True, mutation_safe=True, reasons=(), ) class CleanParityMixin: """Pin parity for tests about aggregation rather than staleness. Without this the assertions depend on the real checkout: a worktree whose branch is ahead of its upstream is genuinely stale, which would degrade the overall status and make these cases fail for an unrelated reason. """ def setUp(self): super().setUp() patcher = mock.patch( "webui.system_health.assess_stale_runtime", return_value=_CLEAN_PARITY, ) patcher.start() self.addCleanup(patcher.stop) class TestDependencyAggregation(CleanParityMixin, unittest.TestCase): """AC1 — readiness and dependency list derived from probe results.""" def test_all_healthy_is_ok_and_ready(self): snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123") self.assertEqual(snapshot.status, STATUS_OK) self.assertTrue(snapshot.ready) self.assertTrue(snapshot.readiness_complete) self.assertEqual(snapshot.readiness_reasons, ()) self.assertEqual(len(snapshot.dependencies), 3) def test_required_dependency_down_blocks_readiness(self): probes = ( _probe("control_plane_db", STATUS_DOWN, detail="file missing", kind="sqlite"), _probe("repository", STATUS_OK, kind="git"), _probe("gitea", STATUS_OK, required=False, kind="http"), ) snapshot = load_system_health(probes=probes, daemon_head="abc123") self.assertEqual(snapshot.status, STATUS_DOWN) self.assertFalse(snapshot.ready) self.assertTrue( any("control_plane_db" in reason for reason in snapshot.readiness_reasons) ) def test_optional_dependency_down_degrades_but_stays_ready(self): """A failing optional probe must not claim the process itself is unready.""" probes = ( _probe("control_plane_db", STATUS_OK, kind="sqlite"), _probe("repository", STATUS_OK, kind="git"), _probe("gitea", STATUS_DOWN, required=False, detail="timeout", kind="http"), ) snapshot = load_system_health(probes=probes, daemon_head="abc123") self.assertEqual(snapshot.status, STATUS_DEGRADED) self.assertTrue(snapshot.ready) self.assertTrue(any("gitea" in reason for reason in snapshot.readiness_reasons)) def test_unrun_required_probe_leaves_readiness_incomplete(self): """Not probed is not the same as passing.""" probes = ( _probe("control_plane_db", STATUS_OK, kind="sqlite"), _probe("repository", STATUS_SKIPPED, detail="offline", kind="git"), ) snapshot = load_system_health(probes=probes, daemon_head="abc123") self.assertFalse(snapshot.ready) self.assertFalse(snapshot.readiness_complete) self.assertEqual(snapshot.status, STATUS_DEGRADED) def test_skipped_optional_probe_does_not_block_readiness(self): probes = ( _probe("control_plane_db", STATUS_OK, kind="sqlite"), _probe("repository", STATUS_OK, kind="git"), _probe("gitea", STATUS_SKIPPED, required=False, kind="http"), ) snapshot = load_system_health(probes=probes, daemon_head="abc123") self.assertTrue(snapshot.ready) self.assertTrue(snapshot.readiness_complete) class TestVersionAndUptime(CleanParityMixin, unittest.TestCase): """AC2 — version and uptime present when knowable.""" def test_uptime_and_start_time_present(self): snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123") self.assertGreaterEqual(snapshot.uptime_seconds, 0.0) self.assertIn("T", snapshot.started_at) def test_process_uptime_helper_matches_shape(self): started_at, uptime = process_uptime() self.assertIn("T", started_at) self.assertGreaterEqual(uptime, 0.0) def test_version_reports_python_and_schema_version(self): probes = ( DependencyProbe( name="control_plane_db", kind="sqlite", status=STATUS_OK, detail="ok", required=True, latency_ms=1.0, metadata={"schema_version": control_plane_db.SCHEMA_VERSION}, ), _probe("repository", STATUS_OK, kind="git"), ) snapshot = load_system_health(probes=probes, daemon_head="abc123") self.assertEqual( snapshot.version.control_plane_schema_version, control_plane_db.SCHEMA_VERSION, ) self.assertTrue(snapshot.version.python_version) def test_version_known_flag_false_when_sha_unavailable(self): with mock.patch("webui.system_health._git", return_value=None): snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc") self.assertIsNone(snapshot.version.git_sha) self.assertFalse(snapshot.version.known) class TestStaleRuntime(unittest.TestCase): """AC3 — stale runtime reflected without a false mutation-safe claim.""" def test_matching_commits_are_mutation_safe(self): assessment = assess_stale_runtime( Path("/tmp"), daemon_head="aaa", git_reader=lambda *args: "aaa", ) self.assertFalse(assessment.stale) self.assertTrue(assessment.determinable) self.assertTrue(assessment.mutation_safe) def test_diverged_commits_are_stale_and_not_mutation_safe(self): reads = {"HEAD": "aaa", "@{upstream}": "bbb"} assessment = assess_stale_runtime( Path("/tmp"), daemon_head="aaa", git_reader=lambda *args: reads.get(args[-1]), ) self.assertTrue(assessment.stale) self.assertFalse(assessment.mutation_safe) self.assertTrue(assessment.reasons) def test_unknown_remote_is_not_mutation_safe(self): """Indeterminate must never read as safe.""" reads = {"HEAD": "aaa", "@{upstream}": None} assessment = assess_stale_runtime( Path("/tmp"), daemon_head="aaa", git_reader=lambda *args: reads.get(args[-1]), ) self.assertFalse(assessment.determinable) self.assertFalse(assessment.mutation_safe) self.assertFalse(assessment.stale) self.assertTrue( any("indeterminate" in reason for reason in assessment.reasons) ) def test_unobservable_daemon_head_is_disclosed(self): assessment = assess_stale_runtime( Path("/tmp"), git_reader=lambda *args: "aaa", ) self.assertTrue( any("not observable" in reason for reason in assessment.reasons) ) def test_stale_runtime_degrades_overall_status(self): reads = {"HEAD": "aaa", "@{upstream}": "bbb"} # Pinned rather than inherited: this path uses the default git reader, # so the assertion must hold whether or not the suite runs offline. with mock.patch.dict(os.environ, {"WEBUI_TEST_OFFLINE": ""}), mock.patch( "webui.system_health._git", side_effect=lambda repo, *args: reads.get(args[-1]), ): snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="aaa") self.assertTrue(snapshot.stale_runtime.stale) self.assertFalse(snapshot.stale_runtime.mutation_safe) self.assertEqual(snapshot.status, STATUS_DEGRADED) class TestControlPlaneDbProbe(unittest.TestCase): """The required local dependency, probed read-only.""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.db_path = str(Path(self.tmp.name) / "control-plane.db") def _build_db(self, schema_version): conn = sqlite3.connect(self.db_path) conn.execute("CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT)") conn.execute("CREATE TABLE leases (lease_id TEXT PRIMARY KEY, status TEXT)") conn.execute( "INSERT INTO schema_meta(key, value) VALUES ('schema_version', ?)", (str(schema_version),), ) conn.execute("INSERT INTO leases(lease_id, status) VALUES ('l1', 'active')") conn.commit() conn.close() def test_missing_database_is_down(self): probe = probe_control_plane_db(str(Path(self.tmp.name) / "absent.db")) self.assertEqual(probe.status, STATUS_DOWN) self.assertTrue(probe.required) self.assertIsNotNone(probe.latency_ms) def test_matching_schema_is_ok(self): self._build_db(control_plane_db.SCHEMA_VERSION) probe = probe_control_plane_db(self.db_path) self.assertEqual(probe.status, STATUS_OK) self.assertEqual( probe.metadata["schema_version"], control_plane_db.SCHEMA_VERSION ) self.assertEqual(probe.metadata["active_leases"], 1) def test_mismatched_schema_is_degraded(self): self._build_db(control_plane_db.SCHEMA_VERSION + 99) probe = probe_control_plane_db(self.db_path) self.assertEqual(probe.status, STATUS_DEGRADED) def test_probe_does_not_create_a_database(self): """A health check must never initialise the substrate it inspects.""" absent = str(Path(self.tmp.name) / "never-created.db") probe_control_plane_db(absent) self.assertFalse(Path(absent).exists()) def test_unreadable_database_is_down_not_raised(self): Path(self.db_path).write_text("this is not a sqlite database") probe = probe_control_plane_db(self.db_path) self.assertEqual(probe.status, STATUS_DOWN) class TestRedaction(unittest.TestCase): """AC4 — redaction. No credential-shaped text crosses the boundary.""" def test_redacts_token_assignment(self): cleaned = redact("failed with token=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345") self.assertNotIn("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", cleaned) self.assertIn("[redacted]", cleaned) def test_redacts_authorization_header_text(self): cleaned = redact("Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456") self.assertNotIn("abcdefghijklmnopqrstuvwxyz123456", cleaned) def test_redacts_long_opaque_strings(self): cleaned = redact("value 0123456789abcdef0123456789abcdef here") self.assertNotIn("0123456789abcdef0123456789abcdef", cleaned) def test_url_userinfo_and_query_are_stripped(self): cleaned = redact_url("https://user:secretpass@gitea.example.com/api/v1?token=xyz") self.assertNotIn("secretpass", cleaned) self.assertNotIn("token=xyz", cleaned) self.assertEqual(cleaned, "https://gitea.example.com/api/v1") def test_url_inside_free_text_is_redacted(self): cleaned = redact("GET https://u:p@host.example.com/x?token=abc failed") self.assertNotIn("u:p@", cleaned) self.assertNotIn("token=abc", cleaned) def test_gitea_probe_failure_detail_is_redacted(self): boom = RuntimeError( "connection refused for https://user:hunter2@gitea.example.com/api/v1/version" ) with mock.patch("webui.system_health.get_auth_header", return_value="token x"), \ mock.patch("webui.system_health.api_request", side_effect=boom): probe = probe_gitea("gitea.example.com") self.assertEqual(probe.status, STATUS_DOWN) self.assertNotIn("hunter2", probe.detail) self.assertEqual(scan_text_for_client_secrets(probe.detail), []) def test_credential_guard_refusal_is_a_status_not_a_crash(self): with mock.patch( "webui.system_health.get_auth_header", side_effect=RuntimeError("daemon guard refused"), ): probe = probe_gitea("gitea.example.com") self.assertEqual(probe.status, STATUS_DEGRADED) self.assertFalse(probe.required) class TestNamespaceSummaries(unittest.TestCase): """A web process cannot prove IDE namespace health, and must not claim to.""" def test_every_namespace_reports_unproven(self): rows = namespace_summaries() self.assertTrue(rows) for row in rows: with self.subTest(namespace=row["namespace"]): self.assertEqual(row["status"], "unproven") self.assertFalse(row["ide_namespace_proven"]) self.assertIn("client_namespace", row["reason"]) class TestSystemHealthRoutes(CleanParityMixin, unittest.TestCase): """The HTTP surface: versioned path, status codes, read-only guard.""" def setUp(self): super().setUp() clear_probe_cache() self.addCleanup(clear_probe_cache) self.client = TestClient(create_app()) def _patch_snapshot(self, probes, daemon_head="abc123"): snapshot = load_system_health(probes=probes, daemon_head=daemon_head) patcher = mock.patch( "webui.app.load_system_health", return_value=snapshot, ) patcher.start() self.addCleanup(patcher.stop) return snapshot def test_versioned_route_is_registered(self): self.assertEqual(API_PATH, "/api/v1/system/health") self._patch_snapshot(_ALL_HEALTHY) response = self.client.get(API_PATH) self.assertEqual(response.status_code, 200) def test_healthy_payload_shape(self): self._patch_snapshot(_ALL_HEALTHY) data = self.client.get(API_PATH).json() self.assertEqual(data["status"], STATUS_OK) self.assertTrue(data["readiness"]["ready"]) self.assertTrue(data["readiness"]["complete"]) self.assertEqual(data["api"], API_PATH) self.assertEqual(len(data["dependencies"]), 3) for key in ("version", "process", "stale_runtime", "mcp_namespaces"): self.assertIn(key, data) self.assertIn("uptime_seconds", data["process"]) self.assertIn("mutation_safe", data["stale_runtime"]) def test_degraded_dependency_returns_503(self): probes = ( _probe("control_plane_db", STATUS_DOWN, detail="missing", kind="sqlite"), _probe("repository", STATUS_OK, kind="git"), ) self._patch_snapshot(probes) response = self.client.get(API_PATH) self.assertEqual(response.status_code, 503) data = response.json() self.assertFalse(data["readiness"]["ready"]) self.assertTrue(data["readiness"]["reasons"]) def test_dependency_entries_expose_status_and_latency(self): self._patch_snapshot(_ALL_HEALTHY) data = self.client.get(API_PATH).json() names = {entry["name"] for entry in data["dependencies"]} self.assertEqual(names, {"control_plane_db", "repository", "gitea"}) for entry in data["dependencies"]: with self.subTest(dependency=entry["name"]): self.assertIn("status", entry) self.assertIn("required", entry) self.assertIn("latency_ms", entry) def test_response_body_carries_no_client_secrets(self): self._patch_snapshot(_ALL_HEALTHY) body = self.client.get(API_PATH).text self.assertEqual(scan_text_for_client_secrets(body), []) def test_deep_flag_is_forwarded(self): snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc") with mock.patch( "webui.app.load_system_health", return_value=snapshot ) as loader: self.client.get(f"{API_PATH}?deep=1") loader.assert_called_once_with(deep=True) def test_shallow_is_the_default(self): snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc") with mock.patch( "webui.app.load_system_health", return_value=snapshot ) as loader: self.client.get(API_PATH) loader.assert_called_once_with(deep=False) def test_route_rejects_mutation_methods(self): for method in ("POST", "PUT", "PATCH", "DELETE"): with self.subTest(method=method): response = self.client.request(method, API_PATH) self.assertEqual(response.status_code, 405) self.assertEqual(response.json()["error"], "read-only-mvp") def test_default_shallow_call_skips_the_network_probe(self): """The expensive probe must not run unless it was asked for.""" with mock.patch("webui.system_health.probe_gitea") as probe: snapshot = load_system_health(deep=False) probe.assert_not_called() gitea = next(p for p in snapshot.dependencies if p.name == "gitea") self.assertEqual(gitea.status, STATUS_SKIPPED) class TestHealthRouteBackwardCompatibility(unittest.TestCase): """`/health` is expanded additively; MVP consumers must keep working.""" def setUp(self): self.client = TestClient(create_app()) def test_mvp_keys_are_unchanged(self): data = self.client.get("/health").json() self.assertEqual(data["status"], "ok") self.assertEqual(data["service"], "mcp-control-plane-webui") self.assertEqual(data["mode"], "read-only-mvp") self.assertIn("timestamp", data) self.assertEqual(data["deployment"]["mode"], "internal-operator-console") def test_health_points_at_the_versioned_api(self): data = self.client.get("/health").json() self.assertEqual(data["system_health_api"], API_PATH) self.assertIn("uptime_seconds", data) self.assertIn("started_at", data) def test_health_runs_no_dependency_probe(self): """Liveness must stay cheap: no probe, no snapshot assembly.""" with mock.patch("webui.app.load_system_health") as loader: response = self.client.get("/health") self.assertEqual(response.status_code, 200) loader.assert_not_called() class TestSnapshotSerialisation(CleanParityMixin, unittest.TestCase): def test_snapshot_dict_is_json_serialisable(self): snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123") encoded = json.dumps(snapshot_to_dict(snapshot)) self.assertIn("readiness", encoded) if __name__ == "__main__": unittest.main()