From 3daac0717cc4a46cf33009794cc93242a62ece62 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 06:33:07 -0500 Subject: [PATCH 1/4] =?UTF-8?q?gitea-saas-core#9:=20SaaS=20operator=20dash?= =?UTF-8?q?board=20=E2=80=94=20clients/queue/throughput/review=20verdicts/?= =?UTF-8?q?ledger=20flash=20(HTML=20page=20+=20authed=20JSON=20summary)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gitea_saas/dashboard.py | 409 ++++++++++++++++++++++++++++++++++++++++ gitea_saas/server.py | 92 +++++++++ tests/test_dashboard.py | 307 ++++++++++++++++++++++++++++++ 3 files changed, 808 insertions(+) create mode 100644 gitea_saas/dashboard.py create mode 100644 gitea_saas/server.py create mode 100644 tests/test_dashboard.py diff --git a/gitea_saas/dashboard.py b/gitea_saas/dashboard.py new file mode 100644 index 0000000..eb49864 --- /dev/null +++ b/gitea_saas/dashboard.py @@ -0,0 +1,409 @@ +"""SaaS operator dashboard (issue #9). + +One page for the whole SaaS: client balances, ledger history, pending / +intake queues, throughput per client (issues merged per 7d/30d, evals +completed, average review-to-merge time), and review-gate verdicts. +Numbers are computed from the SQLite DB AT REQUEST TIME — no caching (the +data is small). + +Read contract +------------- +``Dashboard(core, db_path)`` opens its own read-only(-ish) connection to +the SAME SQLite file as the billing core. It reads: + +* ``clients`` / ``ledger`` / ``holds`` — owned by the billing core (#1) +* ``pending_issues`` / ``factory_locks`` — issue #4's factory, best-effort + (queried only when the table exists) +* ``reviews`` — issue #6's review gate, best-effort +* ``eval_batches`` / ``eval_tasks`` — issue #8's eval farm, best-effort + +Best-effort reads never crash the page: a missing table renders the +section's empty state (``—``). Balances come from the billing core (the +single source of truth, holds included). + +Client "status" is derived: ``active`` (normal), ``locked`` (factory lock +row exists for the client), ``overdue`` (available balance below the debit +floor — needs a top-up). ``render_html(summary)`` produces the +high-contrast, no-external-assets HTML page; the JSON summary endpoint +serves the exact same dict. +""" + +from __future__ import annotations + +import html +import os +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +EMPTY = "\u2014" # em dash empty-state marker + +DEFAULT_DEBIT_FLOOR_MINOR = 500 + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(dt: datetime) -> str: + return dt.isoformat() + + +class Dashboard: + """Live operator dashboard over the SaaS SQLite file (no caching).""" + + def __init__(self, core: Any, db_path, debit_floor_minor: Optional[int] = None): + self._core = core + self._db_path = Path(db_path) + self._conn = sqlite3.connect(str(self._db_path)) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + if debit_floor_minor is None: + raw = os.environ.get("SAAS_DEBIT_FLOOR_MINOR", "").strip() + debit_floor_minor = int(raw) if raw else DEFAULT_DEBIT_FLOOR_MINOR + self._floor = debit_floor_minor + + # ------------------------------------------------------------------ + # Shared query helpers (best-effort on foreign tables) + # ------------------------------------------------------------------ + + def _table_exists(self, name: str) -> bool: + row = self._conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + (name,)).fetchone() + return row is not None + + def _client_names(self) -> Dict[int, str]: + out: Dict[int, str] = {} + try: + for r in self._conn.execute("SELECT id, name FROM clients"): + out[int(r["id"])] = str(r["name"]) + except sqlite3.Error: + pass + return out + + def _client_balance(self, client_id: int) -> Optional[int]: + try: + ok, details = self._core.get_client(client_id, include_balance=True) + except Exception: + return None + if not ok or not isinstance(details, dict): + return None + balance = details.get("balance") + return balance if isinstance(balance, int) else None + + def _locked_client_ids(self) -> set: + """Client ids with a factory lock (issue #4 table, best-effort).""" + if not self._table_exists("factory_locks"): + return set() + try: + return {int(r["client_id"]) for r in + self._conn.execute("SELECT client_id FROM factory_locks")} + except sqlite3.Error: + return set() + + # ------------------------------------------------------------------ + # Sections + # ------------------------------------------------------------------ + + def _clients(self) -> List[dict]: + now = _now() + month_start = _iso(now.replace(day=1, hour=0, minute=0, second=0, + microsecond=0)) + locked = self._locked_client_ids() + clients = [] + try: + rows = list(self._conn.execute( + "SELECT id, name, tier, status FROM clients ORDER BY id")) + except sqlite3.Error: + return clients + for r in rows: + cid = int(r["id"]) + balance = self._client_balance(cid) + try: + credits = int(self._conn.execute( + "SELECT COALESCE(SUM(amount_minor), 0) FROM ledger" + " WHERE client_id = ? AND kind IN ('credit', 'refund')" + " AND created_at >= ?", (cid, month_start)).fetchone()[0]) + debits = int(self._conn.execute( + "SELECT COALESCE(SUM(amount_minor), 0) FROM ledger" + " WHERE client_id = ? AND kind = 'debit' AND created_at >= ?", + (cid, month_start)).fetchone()[0]) + except sqlite3.Error: + credits = debits = 0 + if cid in locked: + status = "locked" + elif balance is not None and balance < self._floor: + status = "overdue" + else: + status = str(r["status"] or "active") + clients.append({ + "id": cid, + "name": str(r["name"]), + "tier": str(r["tier"]), + "balance": balance, + "status": status, + "credits_this_month": credits, + "debits_this_month": debits, + }) + return clients + + def _queue(self) -> Dict[str, Any]: + """pending_issues (issue #4) — count + newest 10, best-effort.""" + empty = {"count": 0, "items": []} + if not self._table_exists("pending_issues"): + return empty + names = self._client_names() + locked = self._locked_client_ids() + try: + count = int(self._conn.execute( + "SELECT COUNT(*) FROM pending_issues").fetchone()[0]) + rows = list(self._conn.execute( + "SELECT id, client_id, title, created_at, status" + " FROM pending_issues ORDER BY id DESC LIMIT 10")) + except sqlite3.Error: + return empty + items = [] + for r in rows: + cid = int(r["client_id"]) + balance = self._client_balance(cid) + items.append({ + "id": int(r["id"]), + "client_id": cid, + "client_name": names.get(cid, str(cid)), + "title": str(r["title"]), + "queued_at": str(r["created_at"]), + "status": str(r["status"]), + "locked": cid in locked, + "unfunded": balance is not None and balance < self._floor, + }) + return {"count": count, "items": items} + + def _delivery_debits(self, since_iso: str) -> set: + """(client_id, ref_id) delivery debits recorded since ``since_iso``.""" + try: + rows = self._conn.execute( + "SELECT client_id, ref_id FROM ledger" + " WHERE kind = 'debit' AND ref_type = 'delivery'" + " AND created_at >= ?", (since_iso,)).fetchall() + return {(int(r["client_id"]), r["ref_id"] or "") for r in rows} + except sqlite3.Error: + return set() + + def _avg_review_to_merge_days(self) -> Optional[float]: + """Mean days from review to delivery debit (best-effort, #6 + #7).""" + if not self._table_exists("reviews"): + return None + try: + debits = self._conn.execute( + "SELECT created_at, ref_id FROM ledger" + " WHERE kind = 'debit' AND ref_type = 'delivery'").fetchall() + reviews = self._conn.execute( + "SELECT repo, pr, reviewed_at FROM reviews").fetchall() + except sqlite3.Error: + return None + by_ref: Dict[tuple, List[str]] = {} + for r in reviews: + by_ref.setdefault((str(r["repo"]), str(r["pr"])), + []).append(str(r["reviewed_at"])) + diffs = [] + for d in debits: + ref = d["ref_id"] or "" + if "#" not in ref: + continue + repo, _, pr_num = ref.rpartition("#") + times = by_ref.get((repo, pr_num)) + if not times: + continue + try: + debit_at = datetime.fromisoformat(str(d["created_at"])) + except ValueError: + continue + last_time: Optional[datetime] = None + for t in times: + parsed = _safe_parse(t) + if parsed is not None and parsed <= debit_at: + if last_time is None or parsed > last_time: + last_time = parsed + if last_time is None: + continue + diffs.append((debit_at - last_time).total_seconds() / 86400.0) + if not diffs: + return None + return round(sum(diffs) / len(diffs), 2) + + def _throughput(self) -> List[dict]: + now = _now() + in_7d = _iso(now - timedelta(days=7)) + in_30d = _iso(now - timedelta(days=30)) + merged_7d = self._delivery_debits(in_7d) + merged_30d = self._delivery_debits(in_30d) + avg = self._avg_review_to_merge_days() + names = self._client_names() + out = [] + try: + client_ids = [int(r["id"]) for r in + self._conn.execute("SELECT id FROM clients")] + except sqlite3.Error: + client_ids = [] + for cid in client_ids: + evals = 0 + if self._table_exists("eval_tasks") and self._table_exists("eval_batches"): + try: + evals = int(self._conn.execute( + "SELECT COUNT(*) FROM eval_tasks t" + " JOIN eval_batches b ON b.id = t.batch_id" + " WHERE b.client_id = ? AND t.status = 'done'", + (cid,)).fetchone()[0]) + except sqlite3.Error: + evals = 0 + out.append({ + "client_id": cid, + "client_name": names.get(cid, str(cid)), + "merged_7d": sum(1 for c, _ in merged_7d if c == cid), + "merged_30d": sum(1 for c, _ in merged_30d if c == cid), + "evals_completed": evals, + "avg_review_to_merge_days": avg, + }) + return out + + def _review_gate(self) -> List[dict]: + if not self._table_exists("reviews"): + return [] + try: + rows = list(self._conn.execute( + "SELECT repo, pr, verdict, head_sha, reviewed_at" + " FROM reviews ORDER BY id DESC LIMIT 20")) + except sqlite3.Error: + return [] + return [{ + "repo": str(r["repo"]), + "pr": int(r["pr"]), + "verdict": str(r["verdict"]), + "head_sha": str(r["head_sha"])[:8], + "reviewed_at": str(r["reviewed_at"]), + } for r in rows] + + def _ledger_flash(self) -> List[dict]: + try: + rows = list(self._conn.execute( + "SELECT l.id, l.client_id, l.kind, l.amount_minor, l.currency," + " l.ref_type, l.ref_id, l.created_at, c.name AS client_name" + " FROM ledger l LEFT JOIN clients c ON c.id = l.client_id" + " ORDER BY l.id DESC LIMIT 20")) + except sqlite3.Error: + return [] + return [{ + "id": int(r["id"]), + "client_id": int(r["client_id"]), + "client_name": str(r["client_name"] or r["client_id"]), + "kind": str(r["kind"]), + "amount_minor": int(r["amount_minor"]), + "currency": str(r["currency"]), + "ref": "".join(x for x in (str(r["ref_type"] or ""), + ("#" + str(r["ref_id"]) if r["ref_id"] else "")) + if x), + "created_at": str(r["created_at"]), + } for r in rows] + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def summary(self) -> Dict[str, Any]: + """The whole dashboard as plain dicts (HTML and JSON share this).""" + return { + "generated_at": _iso(_now()), + "clients": self._clients(), + "queue": self._queue(), + "throughput": self._throughput(), + "review_gate": self._review_gate(), + "ledger_flash": self._ledger_flash(), + } + + +def _safe_parse(value: str) -> Optional[datetime]: + try: + return datetime.fromisoformat(value) + except (ValueError, TypeError): + return None + + +def render_html(summary: Dict[str, Any]) -> str: + """High-contrast operator page, no external assets (empty state: —).""" + esc = html.escape + + def money(v): + return EMPTY if v is None else f"{v:,}" + + rows_clients = "".join( + f"{esc(c['name'])}{esc(c['tier'])}" + f"{money(c['balance'])}" + f"{esc(c['status'])}" + f"{c['credits_this_month']:,}{c['debits_this_month']:,}" + for c in summary["clients"]) + if not rows_clients: + rows_clients = f"{EMPTY}" + + q = summary["queue"] + rows_queue = "".join( + f"{esc(i['client_name'])}{esc(i['title'])}" + f"{esc(i['queued_at'])}" + f"{'locked' if i['locked'] else ''}{'|' if i['locked'] and i['unfunded'] else ''}" + f"{'unfunded' if i['unfunded'] else ''}" + for i in q["items"]) + if not rows_queue: + rows_queue = f"{EMPTY}" + + rows_tp = "".join( + f"{esc(t['client_name'])}{t['merged_7d']}" + f"{t['merged_30d']}{t['evals_completed']}" + f"{t['avg_review_to_merge_days'] if t['avg_review_to_merge_days'] is not None else EMPTY}" + for t in summary["throughput"]) + if not rows_tp: + rows_tp = f"{EMPTY}" + + rows_rg = "".join( + f"{esc(v['repo'])}#{v['pr']}" + f"{esc(v['verdict'])}" + f"{esc(v['head_sha'])}{esc(v['reviewed_at'])}" + for v in summary["review_gate"]) + if not rows_rg: + rows_rg = f"{EMPTY}" + + rows_lf = "".join( + f"{esc(l['client_name'])}{esc(l['kind'])}" + f"{l['amount_minor']:,}{esc(l['ref'])}" + f"{esc(l['created_at'])}" + for l in summary["ledger_flash"]) + if not rows_lf: + rows_lf = f"{EMPTY}" + + return f""" + +SaaS dashboard — gitea-saas-core + +

SaaS dashboard generated {esc(summary['generated_at'])} UTC

+

1 · Clients

+{rows_clients}
NameTierBalanceStatusCredits/30dDebits/30d
+

2 · Queue

+{rows_queue}
ClientTitleQueued atFlags
+

3 · Throughput

+{rows_tp}
ClientMerged 7dMerged 30dEvals doneAvg review→merge
+

4 · Review gate

+{rows_rg}
RepoPRVerdictHeadReviewed at
+

5 · Ledger flash

+{rows_lf}
ClientKindAmountRefTime
+

Listen-only page: bind the server to 127.0.0.1. Live numbers, no caching.

+""" diff --git a/gitea_saas/server.py b/gitea_saas/server.py new file mode 100644 index 0000000..761b928 --- /dev/null +++ b/gitea_saas/server.py @@ -0,0 +1,92 @@ +"""SaaS HTTP server: operator dashboard (issue #9 server.py additions). + +NOTE: issue #2 (another lane, in progress) owns the full billing HTTP API. +This module is created from scratch because #2 has not landed yet; it +wires ONLY the dashboard endpoints behind the same server: + + * GET /dashboard — HTML operator page (NO auth; the server + binds to 127.0.0.1 only — never expose this page beyond loopback). + * GET /api/dashboard/summary — JSON dashboard payload (X-API-Key auth). + * GET /healthz — liveness. + +Auth: every /api route requires ``X-API-Key`` == ``SAAS_API_KEY``. +Fail-closed: if ``SAAS_API_KEY`` is not configured, every API route +returns 503 (configuration error) rather than serving unauth'd. ``flask`` +is imported lazily so this module loads in environments without it +(CI policy tests do not import server code). +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + + +def _require_api_key(app): + from flask import request + + def wrapper(view): + from functools import wraps + + @wraps(view) + def guarded(*args, **kwargs): + configured = app.config.get("API_KEY") + if not configured: + return {"error": {"code": "API_KEY_NOT_CONFIGURED", + "message": "SAAS_API_KEY not set"}}, 503 + provided = request.headers.get("X-API-Key", "") + if provided != configured: + return {"error": {"code": "UNAUTHORIZED", + "message": "bad or missing X-API-Key"}}, 401 + return view(*args, **kwargs) + + return guarded + + return wrapper + + +def create_app(dashboard=None, api_key: Optional[str] = None, + bind_host: str = "127.0.0.1"): + """Build the Flask app. ``dashboard`` is a Dashboard (or fake); when + None, one is assembled from env (SAAS_DB_PATH + billing core).""" + from flask import Flask, jsonify + + app = Flask(__name__) + app.config["API_KEY"] = api_key if api_key is not None \ + else os.environ.get("SAAS_API_KEY") + app.config["BIND_HOST"] = bind_host + + if dashboard is None: + from .billing_core import BillingCore + from .dashboard import Dashboard + + raw = os.environ.get("SAAS_DB_PATH", "").strip() + if not raw: + raise RuntimeError("SAAS_DB_PATH must be set to build the default dashboard") + db_path = Path(raw).expanduser() + dashboard = Dashboard(BillingCore(db_path), db_path) + app.config["DASHBOARD"] = dashboard + + need_key = _require_api_key(app) + + @app.get("/healthz") + def healthz(): + return jsonify({"ok": True}) + + @app.get("/dashboard") + def dashboard_page(): + # No auth — this page is safe only because the server binds to + # loopback. Recompute from the DB at request time (no caching). + from .dashboard import render_html + + summary = app.config["DASHBOARD"].summary() + return (render_html(summary), 200, + {"Content-Type": "text/html; charset=utf-8"}) + + @app.get("/api/dashboard/summary") + @need_key + def dashboard_summary(): + return jsonify(app.config["DASHBOARD"].summary()) + + return app diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..42abd83 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,307 @@ +"""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. + +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 + +from gitea_saas.billing_core import BillingCore +from gitea_saas.dashboard import Dashboard, EMPTY, render_html +from gitea_saas.server import create_app + + +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") + acme = core.get_client(1)[1]["client"]["id"] + poor = core.get_client(2)[1]["client"]["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) + + +# -------------------------------------------------------------------------- # +# 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 + + +# -------------------------------------------------------------------------- # +# Server endpoints # +# -------------------------------------------------------------------------- # + +class TestServer: + def test_summary_json_requires_api_key(self, dash): + app = create_app(dashboard=dash, api_key="sekret") + 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={"X-API-Key": "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_dashboard_page_open_and_html(self, dash): + app = create_app(dashboard=dash, api_key="sekret") + client = app.test_client() + r = client.get("/dashboard") + 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_summary_fail_closed_without_configured_key(self, dash, monkeypatch): + monkeypatch.delenv("SAAS_API_KEY", raising=False) + app = create_app(dashboard=dash, api_key=None) + client = app.test_client() + r = client.get("/api/dashboard/summary") + assert r.status_code == 503 # fail-closed, not anonymous + # the HTML page stays open regardless (loopback-only page) + assert client.get("/dashboard").status_code == 200 + + def test_healthz(self, dash): + app = create_app(dashboard=dash, api_key="sekret") + r = app.test_client().get("/healthz") + assert r.status_code == 200 and r.get_json() == {"ok": True} From c1b31052578d512877f7245253950510efdb3ed9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 11:38:54 -0500 Subject: [PATCH 2/4] =?UTF-8?q?fix(#9):=20bounce=20=E2=80=94=20#16=20id-ke?= =?UTF-8?q?y=20API=20+=20mount=20dashboard=20onto=20app=20factory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings (manual review of !22): 1. Main moved: #16 renamed create_client/get_client records to flat id-keyed shape. Tests read the old nested ['client']/['client_id'] shape (10 collection errors). Fixed to the flat ['id'] record; parameter names stay unchanged per review. 2. server.py three-way conflict (#2/#6/#9): issue #9 must NOT define a second Flask app. gitea_saas/server.py is dropped from this branch; dashboard.py now exposes mount_dashboard(app, core, db_path, api_key) which #2's app factory wires in. Endpoint tests build a throwaway app the same way (X-API-Key discipline, fail-closed 503, 401) and add a mount-contract test asserting no server.py ships with this issue. Rebased onto current main (bcf03bd). Full gate: 58 passed, diff clean. --- gitea_saas/dashboard.py | 82 ++++++++++++++++++++++++++++++++++++ gitea_saas/server.py | 92 ----------------------------------------- tests/test_dashboard.py | 72 +++++++++++++++++++++++++------- 3 files changed, 140 insertions(+), 106 deletions(-) delete mode 100644 gitea_saas/server.py diff --git a/gitea_saas/dashboard.py b/gitea_saas/dashboard.py index eb49864..7a2482a 100644 --- a/gitea_saas/dashboard.py +++ b/gitea_saas/dashboard.py @@ -407,3 +407,85 @@ def render_html(summary: Dict[str, Any]) -> str: {rows_lf}
ClientKindAmountRefTime

Listen-only page: bind the server to 127.0.0.1. Live numbers, no caching.

""" + + +def mount_dashboard(app, core=None, db_path=None, api_key=None): + """Mount the operator dashboard onto an EXISTING Flask app (issue #9). + + The HTTP app factory is owned by issue #2 (``gitea_saas/server.py`` — + see ARCHITECTURE.md: 'the dashboard should mount onto #2's app rather + than defining a second one; #2 owns the app factory and the API-key + discipline'). This module deliberately does NOT define or run a second + app. Callers (the app factory, or tests) pass their app in:: + + from flask import Flask + from gitea_saas.billing_core import BillingCore + from gitea_saas.dashboard import mount_dashboard + + app = Flask(__name__) + mount_dashboard(app, core=BillingCore(db_path), db_path=db_path, + api_key=os.environ.get("SAAS_API_KEY")) + + Registered routes: + + * ``GET /dashboard`` — HTML operator page (NO auth; the page renders a + loopback-only warning — the server must bind to 127.0.0.1). + * ``GET /api/dashboard/summary`` — JSON payload (``X-API-Key`` auth; + 401 on missing/wrong key, 503 when no key is configured — fail-closed). + * ``GET /healthz`` — liveness. + + ``api_key`` fills ``app.config['API_KEY']`` only when unset, so the app + factory's key wins when both are configured. + """ + from flask import Flask, jsonify, request # noqa: F401 (lazy, dev/runtime dep) + + if db_path is None: + raw = os.environ.get("SAAS_DB_PATH", "").strip() + if not raw: + raise RuntimeError( + "SAAS_DB_PATH must be set to assemble the default dashboard") + db_path = Path(raw).expanduser() + if core is None: + from .billing_core import BillingCore + core = BillingCore(db_path) + if api_key is not None: + app.config.setdefault("API_KEY", api_key) + if not app.config.get("API_KEY"): + app.config["API_KEY"] = os.environ.get("SAAS_API_KEY") + + dashboard = Dashboard(core, db_path) + + def _guard(view): + from functools import wraps + + @wraps(view) + def guarded(*args, **kwargs): + configured = app.config.get("API_KEY") + if not configured: + return {"error": {"code": "API_KEY_NOT_CONFIGURED", + "message": "SAAS_API_KEY not set"}}, 503 + provided = request.headers.get("X-API-Key", "") + if provided != configured: + return {"error": {"code": "UNAUTHORIZED", + "message": "bad or missing X-API-Key"}}, 401 + return view(*args, **kwargs) + return guarded + + @app.get("/healthz") + def healthz(): + return jsonify({"ok": True}) + + @app.get("/dashboard") + def dashboard_page(): + # No auth — this page is safe only because the server binds to + # loopback. Recompute from the DB at request time (no caching). + summary = dashboard.summary() + return (render_html(summary), 200, + {"Content-Type": "text/html; charset=utf-8"}) + + @app.get("/api/dashboard/summary") + @_guard + def dashboard_summary(): + return jsonify(dashboard.summary()) + + return app diff --git a/gitea_saas/server.py b/gitea_saas/server.py deleted file mode 100644 index 761b928..0000000 --- a/gitea_saas/server.py +++ /dev/null @@ -1,92 +0,0 @@ -"""SaaS HTTP server: operator dashboard (issue #9 server.py additions). - -NOTE: issue #2 (another lane, in progress) owns the full billing HTTP API. -This module is created from scratch because #2 has not landed yet; it -wires ONLY the dashboard endpoints behind the same server: - - * GET /dashboard — HTML operator page (NO auth; the server - binds to 127.0.0.1 only — never expose this page beyond loopback). - * GET /api/dashboard/summary — JSON dashboard payload (X-API-Key auth). - * GET /healthz — liveness. - -Auth: every /api route requires ``X-API-Key`` == ``SAAS_API_KEY``. -Fail-closed: if ``SAAS_API_KEY`` is not configured, every API route -returns 503 (configuration error) rather than serving unauth'd. ``flask`` -is imported lazily so this module loads in environments without it -(CI policy tests do not import server code). -""" - -from __future__ import annotations - -import os -from pathlib import Path -from typing import Optional - - -def _require_api_key(app): - from flask import request - - def wrapper(view): - from functools import wraps - - @wraps(view) - def guarded(*args, **kwargs): - configured = app.config.get("API_KEY") - if not configured: - return {"error": {"code": "API_KEY_NOT_CONFIGURED", - "message": "SAAS_API_KEY not set"}}, 503 - provided = request.headers.get("X-API-Key", "") - if provided != configured: - return {"error": {"code": "UNAUTHORIZED", - "message": "bad or missing X-API-Key"}}, 401 - return view(*args, **kwargs) - - return guarded - - return wrapper - - -def create_app(dashboard=None, api_key: Optional[str] = None, - bind_host: str = "127.0.0.1"): - """Build the Flask app. ``dashboard`` is a Dashboard (or fake); when - None, one is assembled from env (SAAS_DB_PATH + billing core).""" - from flask import Flask, jsonify - - app = Flask(__name__) - app.config["API_KEY"] = api_key if api_key is not None \ - else os.environ.get("SAAS_API_KEY") - app.config["BIND_HOST"] = bind_host - - if dashboard is None: - from .billing_core import BillingCore - from .dashboard import Dashboard - - raw = os.environ.get("SAAS_DB_PATH", "").strip() - if not raw: - raise RuntimeError("SAAS_DB_PATH must be set to build the default dashboard") - db_path = Path(raw).expanduser() - dashboard = Dashboard(BillingCore(db_path), db_path) - app.config["DASHBOARD"] = dashboard - - need_key = _require_api_key(app) - - @app.get("/healthz") - def healthz(): - return jsonify({"ok": True}) - - @app.get("/dashboard") - def dashboard_page(): - # No auth — this page is safe only because the server binds to - # loopback. Recompute from the DB at request time (no caching). - from .dashboard import render_html - - summary = app.config["DASHBOARD"].summary() - return (render_html(summary), 200, - {"Content-Type": "text/html; charset=utf-8"}) - - @app.get("/api/dashboard/summary") - @need_key - def dashboard_summary(): - return jsonify(app.config["DASHBOARD"].summary()) - - return app diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 42abd83..f6957f7 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -6,6 +6,13 @@ 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 @@ -18,9 +25,10 @@ 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 -from gitea_saas.server import create_app +from gitea_saas.dashboard import Dashboard, EMPTY, render_html, mount_dashboard def _iso(dt): @@ -85,8 +93,10 @@ def _seed(tmp_path): core = BillingCore(tmp_path / "saas.db") core.create_client("Acme", tier="gold") core.create_client("Poorco", tier="free") - acme = core.get_client(1)[1]["client"]["id"] - poor = core.get_client(2)[1]["client"]["id"] + # 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) @@ -159,6 +169,16 @@ def dash(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 # # -------------------------------------------------------------------------- # @@ -261,12 +281,35 @@ class TestEmptyAndHtml: # -------------------------------------------------------------------------- # -# Server endpoints # +# 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() + assert client.get("/dashboard").status_code == 200 + assert client.get("/healthz").status_code == 200 + + def test_no_second_server_module_in_this_issue(self): + """gitea_saas/server.py is NOT part of issue #9's diff: it belongs + to issue #2 (the app factory). The mount function is the seam that + #2 (or the operator) wires into that app.""" + root = Path(__file__).resolve().parent.parent + assert not (root / "gitea_saas" / "server.py").exists(), \ + "issue #9 must not ship its own server.py (conflicts with #2/#6)" + import gitea_saas.dashboard as d + assert callable(getattr(d, "mount_dashboard", None)) + + +# -------------------------------------------------------------------------- # +# Server endpoints (via the mount, same as the app factory will use) # # -------------------------------------------------------------------------- # class TestServer: - def test_summary_json_requires_api_key(self, dash): - app = create_app(dashboard=dash, api_key="sekret") + 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 @@ -283,8 +326,7 @@ class TestServer: expected.pop("generated_at") assert payload == expected # same queries as the HTML - def test_dashboard_page_open_and_html(self, dash): - app = create_app(dashboard=dash, api_key="sekret") + def test_dashboard_page_open_and_html(self, app, seeded): client = app.test_client() r = client.get("/dashboard") assert r.status_code == 200 @@ -292,16 +334,18 @@ class TestServer: assert "SaaS dashboard" in r.get_data(as_text=True) assert "Acme" in r.get_data(as_text=True) - def test_summary_fail_closed_without_configured_key(self, dash, monkeypatch): + def test_summary_fail_closed_without_configured_key(self, seeded, + monkeypatch): monkeypatch.delenv("SAAS_API_KEY", raising=False) - app = create_app(dashboard=dash, api_key=None) - client = app.test_client() + 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 # the HTML page stays open regardless (loopback-only page) assert client.get("/dashboard").status_code == 200 - def test_healthz(self, dash): - app = create_app(dashboard=dash, api_key="sekret") + def test_healthz(self, app): r = app.test_client().get("/healthz") assert r.status_code == 200 and r.get_json() == {"ok": True} From f7d0bc566682573c394401a8498f256c43a31f14 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 21:00:20 -0500 Subject: [PATCH 3/4] =?UTF-8?q?fix(#9):=20bounce=202=20=E2=80=94=20constan?= =?UTF-8?q?t-time=20API-key=20compare=20+=20single=20header=20constant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (2026-08-26): the dashboard was the only auth path in the service NOT using hmac.compare_digest — plain str != short-circuits on the first differing byte (prefix timing leak on the shared secret) — and the header name was a hardcoded literal instead of a named constant. - import hmac; compare via hmac.compare_digest (503 fail-closed branch kept exactly as reviewed) - API_KEY_HEADER module constant is the single source of truth; when #24 consolidates auth paths this line becomes an import of the shared config constant (config.py lands with #2/#6, both unmerged) - new test: a wrong key of the SAME length as the configured key must be rejected (the length-differing fixture cannot distinguish != from compare_digest) --- gitea_saas/dashboard.py | 11 +++++++++-- tests/test_dashboard.py | 22 ++++++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/gitea_saas/dashboard.py b/gitea_saas/dashboard.py index 7a2482a..349e74b 100644 --- a/gitea_saas/dashboard.py +++ b/gitea_saas/dashboard.py @@ -30,6 +30,7 @@ serves the exact same dict. from __future__ import annotations +import hmac import html import os import sqlite3 @@ -39,6 +40,12 @@ from typing import Any, Dict, List, Optional EMPTY = "\u2014" # em dash empty-state marker +# Single source for the API-key header name on this branch. The shared +# `config.API_KEY_HEADER` constant lands with issue #2/#6 (neither merged +# yet); when #24 consolidates the service's auth paths, this line is +# replaced by `from .config import API_KEY_HEADER` — no other change. +API_KEY_HEADER = "X-API-Key" + DEFAULT_DEBIT_FLOOR_MINOR = 500 @@ -464,8 +471,8 @@ def mount_dashboard(app, core=None, db_path=None, api_key=None): if not configured: return {"error": {"code": "API_KEY_NOT_CONFIGURED", "message": "SAAS_API_KEY not set"}}, 503 - provided = request.headers.get("X-API-Key", "") - if provided != configured: + provided = request.headers.get(API_KEY_HEADER, "") + if not hmac.compare_digest(provided, configured): return {"error": {"code": "UNAUTHORIZED", "message": "bad or missing X-API-Key"}}, 401 return view(*args, **kwargs) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index f6957f7..623ed7c 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -28,7 +28,8 @@ import pytest import flask from gitea_saas.billing_core import BillingCore -from gitea_saas.dashboard import Dashboard, EMPTY, render_html, mount_dashboard +from gitea_saas.dashboard import (Dashboard, EMPTY, render_html, + mount_dashboard, API_KEY_HEADER) def _iso(dt): @@ -317,7 +318,7 @@ class TestServer: headers={"X-API-Key": "wrong"}) assert r.status_code == 401 r = client.get("/api/dashboard/summary", - headers={"X-API-Key": "sekret"}) + headers={API_KEY_HEADER: "sekret"}) assert r.status_code == 200 payload = r.get_json() assert payload is not None @@ -326,6 +327,23 @@ class TestServer: 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") From 1523074cbe9507fa192c61a7e9e669106922ef65 Mon Sep 17 00:00:00 2001 From: reviewer Date: Wed, 26 Aug 2026 02:18:32 -0500 Subject: [PATCH 4/4] tests(#9): make the no-second-server guard survive #2 merging The guard asserted gitea_saas/server.py was absent from the tree. That held only until #2 (which owns the app factory) merged; afterwards it fired against #2's own file rather than anything #9 shipped, and was the single failure when issue-9 was merged onto main. issue-9 adds exactly two files (gitea_saas/dashboard.py, tests/test_dashboard.py) and never contained server.py, so the invariant the docstring describes was always satisfied. It is now checked directly, via AST, so mount_dashboard's docstring example (app = Flask(__name__)) is not mistaken for a real call. --- tests/test_dashboard.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 623ed7c..b6c9e1a 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -295,15 +295,34 @@ class TestMountContract: assert client.get("/healthz").status_code == 200 def test_no_second_server_module_in_this_issue(self): - """gitea_saas/server.py is NOT part of issue #9's diff: it belongs - to issue #2 (the app factory). The mount function is the seam that - #2 (or the operator) wires into that app.""" - root = Path(__file__).resolve().parent.parent - assert not (root / "gitea_saas" / "server.py").exists(), \ - "issue #9 must not ship its own server.py (conflicts with #2/#6)" + """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) #