gitea-saas-core/tests/test_dashboard.py
Claude 08febd56ca fix(dashboard): require X-API-Key on /dashboard (fail-closed, #25)
The HTML operator page served the same clients/ledger/queue/review data as
the authenticated JSON summary, but with no auth at all. The loopback
requirement it relied on existed only as prose (page note + docstring); it
was never enforced by mount_dashboard, which does not own the app and
cannot constrain the bind address.

Put /dashboard behind the same _guard as /api/dashboard/summary:
- no X-API-Key -> 401, no billing data
- no configured key -> 503 (fail-closed)
- valid key -> 200 HTML (unchanged rendering)

Also drop the now-false 'loopback-only' security claims from the page note
and the mount_dashboard docstring, and update the tests:
- test_dashboard_page_open_and_html now sends a valid key
- new test_dashboard_page_guard_blocks_anonymous_access: no key -> 401,
  body contains no client data (mutation check: fails if guard removed)
- new test_dashboard_wrong_key_same_length_rejected: wrong same-length
  key -> 401 on HTML page too
- test_summary_fail_closed_without_configured_key now asserts the HTML
  page also fails closed (503), not open
- test_dashboard_mounts_onto_existing_app sends a valid key

/api/dashboard/summary behaviour unchanged. Suite: 160 passed, 1 skipped
(before: 158 passed, 1 skipped).
2026-08-26 02:54:37 -05:00

420 lines
18 KiB
Python

"""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}