diff --git a/gitea_saas/dashboard.py b/gitea_saas/dashboard.py new file mode 100644 index 0000000..349e74b --- /dev/null +++ b/gitea_saas/dashboard.py @@ -0,0 +1,498 @@ +"""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 hmac +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 + +# 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 + + +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.

+""" + + +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(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) + 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/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..b6c9e1a --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,388 @@ +"""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() + assert client.get("/dashboard").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") + 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, 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 + # the HTML page stays open regardless (loopback-only page) + assert client.get("/dashboard").status_code == 200 + + def test_healthz(self, app): + r = app.test_client().get("/healthz") + assert r.status_code == 200 and r.get_json() == {"ok": True}