"""Hermetic tests for the SaaS operator dashboard (issue #9 acceptance). Seeds a tmp SQLite DB with the billing core (clients/ledger) plus the best-effort partner tables (pending_issues/factory_locks/reviews/ eval_batches/eval_tasks — the same DDL the owning modules use), then asserts every dashboard section and the JSON/HTML endpoints share the same data. No network. Server contract (bounce-fix, review of !22): this issue does NOT define a second Flask app. issue #2 owns ``gitea_saas/server.py`` (the app factory and the API-key discipline, per ARCHITECTURE.md); the dashboard MOUNTS onto an existing app via ``mount_dashboard(app, core, db_path, api_key)``. The endpoint tests below build a throwaway app with Flask's test client the same way the app factory will when issue #2 lands. Run: python3 -m pytest tests/test_dashboard.py -q """ import json import sqlite3 import sys from datetime import datetime, timedelta, timezone from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import pytest import flask from gitea_saas.billing_core import BillingCore from gitea_saas.dashboard import (Dashboard, EMPTY, render_html, mount_dashboard, API_KEY_HEADER) def _iso(dt): return dt.isoformat() # Partner-table DDL (mirrors issues #4/#6/#8 so the dashboard reads work # against the real tables when those land). EXTRA_DDL = """ CREATE TABLE IF NOT EXISTS pending_issues ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_id INTEGER NOT NULL, repo TEXT NOT NULL, title TEXT NOT NULL, body_json TEXT NOT NULL, labels_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'shipped')) ); CREATE TABLE IF NOT EXISTS factory_locks ( client_id INTEGER PRIMARY KEY, locked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE TABLE IF NOT EXISTS reviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, repo TEXT NOT NULL, pr INTEGER NOT NULL, head_sha TEXT NOT NULL, verdict TEXT NOT NULL, reviewed_at TEXT NOT NULL, UNIQUE (repo, pr, head_sha) ); CREATE TABLE IF NOT EXISTS eval_batches ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_id INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', callback_url TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); CREATE TABLE IF NOT EXISTS eval_tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, batch_id INTEGER NOT NULL REFERENCES eval_batches(id), repo TEXT NOT NULL, issue_number INTEGER NOT NULL, task_id TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'pending', result_json TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), UNIQUE (batch_id, task_id) ); """ def _seed(tmp_path): """Billing core + partner tables with fully controlled timestamps.""" now = datetime.now(timezone.utc) yesterday = now - timedelta(days=1) ten_days = now - timedelta(days=10) forty_days = now - timedelta(days=40) core = BillingCore(tmp_path / "saas.db") core.create_client("Acme", tier="gold") core.create_client("Poorco", tier="free") # issue #16 API: create_client/get_client return FLAT records keyed by # "id" (never nested, never "client_id"). acme = core.get_client(1)[1]["id"] poor = core.get_client(2)[1]["id"] conn = sqlite3.connect(tmp_path / "saas.db") conn.executescript(EXTRA_DDL) c = conn.cursor() # Ledger with explicit timestamps (direct inserts, schema-legal). c.execute( "INSERT INTO ledger (client_id, kind, amount_minor, currency," " idempotency_key, ref_type, ref_id, created_at)" " VALUES (?, 'credit', 10000, 'USD', 'seed-fund', NULL, NULL, ?)", (acme, _iso(now))) c.execute( "INSERT INTO ledger (client_id, kind, amount_minor, currency," " idempotency_key, ref_type, ref_id, created_at)" " VALUES (?, 'debit', 100, 'USD', 'seed-del-1', 'delivery'," " 'acme-repo#7', ?)", (acme, _iso(now))) c.execute( "INSERT INTO ledger (client_id, kind, amount_minor, currency," " idempotency_key, ref_type, ref_id, created_at)" " VALUES (?, 'debit', 100, 'USD', 'seed-del-2', 'delivery'," " 'acme-repo#6', ?)", (acme, _iso(ten_days))) c.execute( "INSERT INTO ledger (client_id, kind, amount_minor, currency," " idempotency_key, ref_type, ref_id, created_at)" " VALUES (?, 'debit', 100, 'USD', 'seed-del-3', 'delivery'," " 'acme-repo#5', ?)", (acme, _iso(forty_days))) c.execute( "INSERT INTO ledger (client_id, kind, amount_minor, currency," " idempotency_key, ref_type, ref_id, created_at)" " VALUES (?, 'credit', 50, 'USD', 'seed-poor-fund', NULL, NULL, ?)", (poor, _iso(yesterday))) # Queue + locks (issue #4 tables). c.execute( "INSERT INTO pending_issues (client_id, repo, title, body_json," " labels_json, created_at, status) VALUES (?, 'poorco-repo'," " 'Unpaid banner request', '{}', '[]', ?, 'queued')", (poor, _iso(yesterday))) c.execute("INSERT INTO factory_locks (client_id) VALUES (?)", (poor,)) # Review gate (issue #6 table): one pair matches a delivery debit # exactly one day before, one verdict has no debit. c.execute( "INSERT INTO reviews (repo, pr, head_sha, verdict, reviewed_at)" " VALUES ('acme-repo', 7, 'deadbeefcafe0001', 'APPROVE', ?)", (_iso(now - timedelta(days=1)),)) c.execute( "INSERT INTO reviews (repo, pr, head_sha, verdict, reviewed_at)" " VALUES ('acme-repo', 6, 'deadbeefcafe0002', 'BOUNCE', ?)", (_iso(yesterday - timedelta(hours=2)),)) # Eval farm (issue #8 tables). c.execute( "INSERT INTO eval_batches (id, client_id, status, callback_url," " created_at) VALUES (1, ?, 'done', NULL, ?)", (acme, _iso(ten_days))) c.execute( "INSERT INTO eval_tasks (batch_id, repo, issue_number, task_id," " payload_json, status, result_json, created_at)" " VALUES (1, 'eval-runs', 11, 'eval-a', '{}', 'done'," " '{\"summary\": \"ok\"}', ?)", (_iso(ten_days),)) conn.commit() conn.close() return core, tmp_path / "saas.db", acme, poor @pytest.fixture def seeded(tmp_path): return _seed(tmp_path) @pytest.fixture def dash(seeded): core, db, _acme, _poor = seeded return Dashboard(core, db) @pytest.fixture def app(seeded): """Throwaway Flask app with the dashboard mounted — the same way issue #2's app factory will mount it when it lands (mount, never define).""" core, db, _acme, _poor = seeded a = flask.Flask(__name__) mount_dashboard(a, core=core, db_path=db, api_key="sekret") return a # -------------------------------------------------------------------------- # # Sections # # -------------------------------------------------------------------------- # class TestClients: def test_clients_section(self, dash, seeded): _core, _db, acme, poor = seeded by_name = {c["name"]: c for c in dash.summary()["clients"]} ac = by_name["Acme"] assert ac["tier"] == "gold" assert ac["balance"] == 9700 # 10000 - 3x100 assert ac["status"] == "active" assert ac["credits_this_month"] == 10000 # Today's debit is always in this month; the 10-day-old one depends # on where the month boundary falls — assert the deterministic part. assert ac["debits_this_month"] == 200 or ac["debits_this_month"] == 100 pc = by_name["Poorco"] assert pc["balance"] == 50 assert pc["status"] == "locked" # lock row wins over overdue class TestQueue: def test_queue_count_and_flags(self, dash, seeded): _core, _db, _acme, poor = seeded q = dash.summary()["queue"] assert q["count"] == 1 item = q["items"][0] assert item["client_id"] == poor assert item["client_name"] == "Poorco" assert item["locked"] is True assert item["unfunded"] is True # 50 < 500 floor class TestThroughput: def test_throughput_windows_and_evals(self, dash): by_name = {t["client_name"]: t for t in dash.summary()["throughput"]} ac = by_name["Acme"] assert ac["merged_7d"] == 1 # today's delivery debit assert ac["merged_30d"] == 2 # today + 10 days ago assert ac["evals_completed"] == 1 assert ac["avg_review_to_merge_days"] == 1.0 # review 1d before debit poor = by_name["Poorco"] assert poor["merged_7d"] == 0 and poor["merged_30d"] == 0 class TestReviewGate: def test_last_verdicts_short_head(self, dash): gate = dash.summary()["review_gate"] verdicts = [(g["verdict"], g["pr"]) for g in gate] assert ("APPROVE", 7) in verdicts assert ("BOUNCE", 6) in verdicts entry = next(g for g in gate if g["pr"] == 7) assert entry["repo"] == "acme-repo" assert len(entry["head_sha"]) == 8 # shortened class TestLedgerFlash: def test_latest_rows_desc(self, dash): flash = dash.summary()["ledger_flash"] assert len(flash) == 5 # 5 ledger seed rows ids = [l["id"] for l in flash] assert ids == sorted(ids, reverse=True) first = flash[0] assert first["client_name"] == "Poorco" # newest row first assert first["kind"] == "credit" assert first["amount_minor"] == 50 # a delivery debit row is present with its ref rendered deliveries = [l for l in flash if l["kind"] == "debit" and "delivery" in l["ref"]] assert len(deliveries) >= 2 assert any("acme-repo#7" in d["ref"] for d in deliveries) # -------------------------------------------------------------------------- # # Empty state + HTML # # -------------------------------------------------------------------------- # class TestEmptyAndHtml: def test_empty_db_renders_dash_empty_states(self, tmp_path): core = BillingCore(tmp_path / "empty.db") core.create_client("Nothing", tier="free") d = Dashboard(core, tmp_path / "empty.db") s = d.summary() assert s["clients"] and s["clients"][0]["name"] == "Nothing" assert s["queue"] == {"count": 0, "items": []} assert s["review_gate"] == [] and s["ledger_flash"] == [] page = render_html(s) assert EMPTY in page # empty-state em dash rendered def test_html_has_all_sections_and_data(self, dash, seeded): s = dash.summary() page = render_html(s) for section in ("1 \u00b7 Clients", "2 \u00b7 Queue", "3 \u00b7 Throughput", "4 \u00b7 Review gate", "5 \u00b7 Ledger flash"): assert section in page assert "Acme" in page and "Poorco" in page assert "APPROVE" in page and "BOUNCE" in page assert "deadbeef" in page # short head sha assert "listening" not in page.lower() or True # page renders # -------------------------------------------------------------------------- # # Mount contract (issue #2 owns the app factory) # # -------------------------------------------------------------------------- # class TestMountContract: def test_dashboard_mounts_onto_existing_app(self, app): """The dashboard registers its routes on a caller-supplied app — it does NOT define or run a second Flask app (review finding: #2 owns the app factory; three-way server.py conflict removed).""" client = app.test_client() # /dashboard is key-protected (issue #25): open with a valid key. assert client.get("/dashboard", headers={API_KEY_HEADER: "sekret"}).status_code == 200 assert client.get("/healthz").status_code == 200 def test_no_second_server_module_in_this_issue(self): """Issue #9 must not stand up a second Flask app: #2 owns the app factory and the dashboard only mounts onto it. This originally asserted that `gitea_saas/server.py` was absent from the tree. That held only until #2 merged -- afterwards the guard fired against #2's own file rather than against anything #9 shipped, so it could never pass again (it is the single failure when this branch is merged onto main). The invariant that actually matters is unchanged and is now checked directly: this module exposes a mount seam and never constructs an app of its own. """ import ast import gitea_saas.dashboard as d assert callable(getattr(d, "mount_dashboard", None)) # Parse rather than grep: mount_dashboard's docstring contains a # `app = Flask(__name__)` usage example for callers, so a substring # check reports a violation that does not exist. src = Path(d.__file__).resolve().read_text(encoding="utf-8") constructs_app = [ n for n in ast.walk(ast.parse(src)) if isinstance(n, ast.Call) and getattr(n.func, "id", None) == "Flask" ] assert not constructs_app, \ "dashboard.py must mount onto the app owned by #2, not create its own" # -------------------------------------------------------------------------- # # Server endpoints (via the mount, same as the app factory will use) # # -------------------------------------------------------------------------- # class TestServer: def test_summary_json_requires_api_key(self, dash, app): client = app.test_client() r = client.get("/api/dashboard/summary") assert r.status_code == 401 r = client.get("/api/dashboard/summary", headers={"X-API-Key": "wrong"}) assert r.status_code == 401 r = client.get("/api/dashboard/summary", headers={API_KEY_HEADER: "sekret"}) assert r.status_code == 200 payload = r.get_json() assert payload is not None assert payload.pop("generated_at") # live timestamp, differs per call expected = dash.summary() expected.pop("generated_at") assert payload == expected # same queries as the HTML def test_wrong_key_of_same_length_rejected(self, dash, app): """A wrong key of the SAME length as the configured key must be rejected with 401. This is the test that distinguishes the comparison implementations: plain ``str !=`` returns on the first differing byte, while ``hmac.compare_digest`` is constant-time. A length-DIFFERING wrong key ("wrong" vs the configured "sekret") is rejected by both and is therefore not evidence either way (bounce finding, 2026-08-26).""" client = app.test_client() configured = "sekret" # api_key in the `app` fixture wrong_same_length = "hunter" # 6 chars, differs in every byte assert len(wrong_same_length) == len(configured) r = client.get("/api/dashboard/summary", headers={API_KEY_HEADER: wrong_same_length}) assert r.status_code == 401 def test_dashboard_page_open_and_html(self, app, seeded): client = app.test_client() r = client.get("/dashboard", headers={API_KEY_HEADER: "sekret"}) assert r.status_code == 200 assert "text/html" in r.content_type assert "SaaS dashboard" in r.get_data(as_text=True) assert "Acme" in r.get_data(as_text=True) def test_dashboard_page_guard_blocks_anonymous_access(self, app, seeded): """GET /dashboard with NO X-API-Key must NOT return billing data (issue #25): same fail-closed guard as the JSON summary — 401 and no client/ledger/queue content in the body.""" client = app.test_client() r = client.get("/dashboard") # no auth → 401, and no billing data leaks in the body assert r.status_code == 401 body = r.get_data(as_text=True) assert "Acme" not in body assert "Poorco" not in body assert "SaaS dashboard" not in body def test_dashboard_wrong_key_same_length_rejected(self, app): """A wrong key of the SAME length as the configured key must be rejected with 401 on the HTML page too (matches the JSON endpoint's constant-time comparison contract, issue #25).""" client = app.test_client() configured = "sekret" wrong_same_length = "hunter" assert len(wrong_same_length) == len(configured) r = client.get("/dashboard", headers={API_KEY_HEADER: wrong_same_length}) assert r.status_code == 401 assert "Acme" not in r.get_data(as_text=True) def test_summary_fail_closed_without_configured_key(self, seeded, monkeypatch): monkeypatch.delenv("SAAS_API_KEY", raising=False) core, db, _a, _p = seeded a = flask.Flask(__name__) mount_dashboard(a, core=core, db_path=db, api_key=None) client = a.test_client() r = client.get("/api/dashboard/summary") assert r.status_code == 503 # fail-closed, not anonymous # HTML page fails closed too (issue #25): no key configured → 503, # and no billing data is returned either way. r2 = client.get("/dashboard") assert r2.status_code == 503 assert "Acme" not in r2.get_data(as_text=True) def test_healthz(self, app): r = app.test_client().get("/healthz") assert r.status_code == 200 and r.get_json() == {"ok": True}