| """ |
| Tests for Stage 46 — `GET /admin/ops-summary` + service refactor. |
| |
| Two layers: |
| - svc.ops_summary() — the pure method now shared by CLI + HTTP. The |
| Stage 44 CLI tests already exercise it via the CLI path; here we |
| test it directly so a future CLI-removal wouldn't lose coverage. |
| - GET /admin/ops-summary — admin-only HTTP route, same shape. |
| """ |
| import json |
| import os |
| from datetime import datetime, timedelta, timezone |
|
|
| import pytest |
|
|
| from infra import OrgStateService |
|
|
| |
|
|
| @pytest.fixture |
| def svc(): |
| s = OrgStateService(":memory:") |
| yield s |
| s.close() |
|
|
|
|
| def test_ops_summary_empty_platform(svc): |
| """A fresh DB returns zeros for every count plus the schema/db |
| metadata. Total invariant: tenants.total == healthy + unhealthy.""" |
| out = svc.ops_summary() |
| assert out["tenants"]["total"] == 0 |
| assert out["tenants"]["healthy"] == 0 |
| assert out["tenants"]["unhealthy"] == 0 |
| assert out["schedules"]["total"] == 0 |
| assert out["runs"]["total_all_time"] == 0 |
| assert out["runs"]["last_24h"] == 0 |
| assert out["calibrations"] == {"total": 0, "stale": 0} |
| assert out["locks_held"] == 0 |
| assert out["audit_log_entries"] == 0 |
| assert out["thresholds"] == {"max_age_days": 90.0, "max_gap_hours": 48.0} |
|
|
|
|
| def test_ops_summary_thresholds_propagate(svc): |
| """Custom thresholds change the stale count without changing |
| anything else.""" |
| svc.register_tenant("acme", "ACME") |
| now = datetime.now(timezone.utc) |
| svc.db.execute( |
| """INSERT INTO calibrations |
| (tenant_id, entity_type, vertical, calibration_json, |
| sample_size, derived_at) |
| VALUES ('acme', 'warehouse', 'logistics', '{}', 42, ?)""", |
| ((now - timedelta(days=200)).isoformat(),), |
| ) |
| svc.db.execute( |
| """INSERT INTO runs |
| (run_id, tenant_id, entity_type, vertical, status, |
| n_states, n_issues, n_decisions, summary_json, |
| started_at, finished_at) |
| VALUES ('r1', 'acme', 'warehouse', 'logistics', 'completed', |
| 0, 0, 0, '{}', ?, ?)""", |
| ((now - timedelta(hours=5)).isoformat(), |
| (now - timedelta(hours=5)).isoformat()), |
| ) |
| tight = svc.ops_summary() |
| loose = svc.ops_summary(max_age_days=365) |
| assert tight["calibrations"]["stale"] == 1 |
| assert loose["calibrations"]["stale"] == 0 |
| assert loose["thresholds"]["max_age_days"] == 365 |
|
|
|
|
| def test_ops_summary_tenant_total_invariant(svc): |
| """tenants.total == tenants.healthy + tenants.unhealthy, always.""" |
| for tid in ("a", "b", "c"): |
| svc.register_tenant(tid, tid) |
| now = datetime.now(timezone.utc) |
| svc.db.execute( |
| """INSERT INTO calibrations |
| (tenant_id, entity_type, vertical, calibration_json, |
| sample_size, derived_at) |
| VALUES ('a', 'warehouse', 'logistics', '{}', 42, ?)""", |
| ((now - timedelta(days=200)).isoformat(),), |
| ) |
| out = svc.ops_summary() |
| assert out["tenants"]["total"] == \ |
| out["tenants"]["healthy"] + out["tenants"]["unhealthy"] |
|
|
|
|
| |
|
|
| def _client(dbfile=":memory:"): |
| pytest.importorskip("fastapi") |
| pytest.importorskip("httpx") |
| from fastapi.testclient import TestClient |
|
|
| from infra.api import create_app |
| return TestClient(create_app(dbfile)) |
|
|
|
|
| def test_admin_ops_summary_returns_200_with_admin_key(tmp_path): |
| dbfile = str(tmp_path / "admin_ops.sqlite3") |
| boot = OrgStateService(dbfile) |
| try: |
| boot.register_tenant("acme", "ACME") |
| admin = boot.create_admin_key() |
| finally: |
| boot.close() |
| client = _client(dbfile) |
| r = client.get("/admin/ops-summary", |
| headers={"Authorization": f"Bearer {admin.raw}"}) |
| assert r.status_code == 200 |
| body = r.json() |
| |
| assert set(body) >= {"schema_version", "db_backend", "tenants", |
| "schedules", "runs", "calibrations", |
| "locks_held", "audit_log_entries", |
| "thresholds"} |
| assert body["tenants"]["total"] == 1 |
|
|
|
|
| def test_admin_ops_summary_401_without_key(tmp_path): |
| """Once an admin key is configured, the route locks. Same pattern |
| as the other /admin/* routes.""" |
| dbfile = str(tmp_path / "noauth.sqlite3") |
| OrgStateService(dbfile).close() |
| os.environ["ORGSTATE_ADMIN_KEY"] = "admin_secret" |
| try: |
| client = _client(dbfile) |
| r = client.get("/admin/ops-summary") |
| assert r.status_code == 401 |
| finally: |
| os.environ.pop("ORGSTATE_ADMIN_KEY", None) |
|
|
|
|
| def test_admin_ops_summary_with_tenant_key_is_forbidden(tmp_path): |
| """A tenant key — even role=admin on its own tenant — must NOT |
| be able to call this route. ops-summary enumerates the platform.""" |
| dbfile = str(tmp_path / "tenant_key.sqlite3") |
| boot = OrgStateService(dbfile) |
| try: |
| boot.register_tenant("acme", "ACME") |
| boot.create_admin_key() |
| tk = boot.create_api_key("acme", role="admin") |
| finally: |
| boot.close() |
| client = _client(dbfile) |
| r = client.get("/admin/ops-summary", |
| headers={"Authorization": f"Bearer {tk.raw}"}) |
| assert r.status_code in (401, 403) |
|
|
|
|
| def test_admin_ops_summary_query_params_propagate(tmp_path): |
| dbfile = str(tmp_path / "qp.sqlite3") |
| boot = OrgStateService(dbfile) |
| try: |
| boot.register_tenant("acme", "ACME") |
| now = datetime.now(timezone.utc) |
| boot.db.execute( |
| """INSERT INTO calibrations |
| (tenant_id, entity_type, vertical, calibration_json, |
| sample_size, derived_at) |
| VALUES ('acme', 'warehouse', 'logistics', '{}', 42, ?)""", |
| ((now - timedelta(days=200)).isoformat(),), |
| ) |
| admin = boot.create_admin_key() |
| finally: |
| boot.close() |
| client = _client(dbfile) |
| |
| r = client.get("/admin/ops-summary", |
| headers={"Authorization": f"Bearer {admin.raw}"}) |
| assert r.json()["calibrations"]["stale"] == 1 |
| |
| r = client.get("/admin/ops-summary?max_age_days=365", |
| headers={"Authorization": f"Bearer {admin.raw}"}) |
| assert r.json()["calibrations"]["stale"] == 0 |
| assert r.json()["thresholds"]["max_age_days"] == 365 |
|
|
|
|
| |
|
|
| def test_cli_and_http_emit_identical_payloads(tmp_path): |
| """The whole point of the Stage 46 refactor: same numbers on |
| both surfaces, always. If they ever diverge, this test fires.""" |
| from infra.cli import main |
| dbfile = str(tmp_path / "parity.sqlite3") |
| main(["--db", dbfile, "tenant", "register", "acme", "ACME"]) |
| boot = OrgStateService(dbfile) |
| try: |
| admin = boot.create_admin_key() |
| finally: |
| boot.close() |
| |
| import contextlib |
| import io |
| buf = io.StringIO() |
| with contextlib.redirect_stdout(buf): |
| main(["--db", dbfile, "ops-summary"]) |
| |
| |
| chunks = buf.getvalue().strip().split("\n}\n") |
| cli_payload = json.loads(chunks[-1] if chunks[-1].startswith("{") |
| else chunks[-1] + "\n}") |
|
|
| client = _client(dbfile) |
| http_payload = client.get( |
| "/admin/ops-summary", |
| headers={"Authorization": f"Bearer {admin.raw}"}, |
| ).json() |
|
|
| |
| |
| |
| for k in ("tenants", "schedules", "calibrations", "locks_held", |
| "thresholds", "schema_version", "db_backend"): |
| assert cli_payload[k] == http_payload[k], ( |
| f"CLI and HTTP disagree on {k!r}: " |
| f"{cli_payload[k]!r} vs {http_payload[k]!r}" |
| ) |
|
|