#!/usr/bin/env python3 """ CI smoke — docs/HARDENING.md Phase 5 (login → 6 agent screens render). Boots the real stack the way the demo runs it, then drives a browser through the frontend AND exercises the hardened API paths end-to-end: 1. uvicorn api.server:app — fresh tmp sqlite DATABASE_URL, BU_AUTH_DISABLED=1 (dev demo mode) 2. python -m http.server design/ — the zero-build frontend, exactly as published (in-browser Babel; the CI run IS the dev-mode regression test) 3. API smoke (urllib, no browser): login → dry-run exam e2e (POST /atp/exams/run → poll /jobs/{id} → award) → evidence + chain verify → license lifecycle (issue → gated /experts chat → revoke → 403) 4. Playwright chromium: login gate (admin/password) → all 6 agent-view tabs render non-empty main content → zero console errors → Human view toggle renders the trailhead. Runnable locally and in CI (identical behavior): pip install playwright && playwright install chromium python3 scripts/ci_smoke.py Exit 0 = green. Any assertion prints a ✗ line and exits 1. """ from __future__ import annotations import json import os import socket import subprocess import sys import tempfile import time import urllib.error import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DESIGN_DIR = ROOT / "design" INDEX = "Brain%20University.html" EXAM_CERT = "atp-l4-math" # seed cert with a commissioned item bank LICENSED_AGENT = "gemma-math" # licensable marketplace agent (seed) AGENT_TABS = ["university", "agents", "certs", "marketplace", "governance", "vault"] #: Resource URLs that may 4xx without failing the smoke. Keep this SHORT and #: explained: #: favicon.ico — not shipped; browsers auto-request it #: .compiled/manifest.json — the prod-build probe (HARDENING.md Phase 5); #: 404 IS the zero-build dev mode working as #: designed (page falls back to in-browser Babel) RESOURCE_404_ALLOWLIST = ("favicon.ico", ".compiled/manifest.json") _STEPS: list[str] = [] def ok(msg: str) -> None: _STEPS.append(msg) print(f" ✓ {msg}") def die(msg: str) -> None: print(f" ✗ {msg}", file=sys.stderr) sys.exit(1) def free_port() -> int: with socket.socket() as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] # ── HTTP helpers (stdlib only — the API smoke needs no client deps) ──────── def request(method: str, url: str, body: dict | None = None, headers: dict | None = None) -> tuple[int, dict]: data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Content-Type", "application/json") for k, v in (headers or {}).items(): req.add_header(k, v) try: with urllib.request.urlopen(req, timeout=30) as resp: return resp.status, json.loads(resp.read() or b"{}") except urllib.error.HTTPError as e: try: payload = json.loads(e.read() or b"{}") except json.JSONDecodeError: payload = {} return e.code, payload def wait_for(url: str, timeout_s: float = 60.0, label: str = "") -> None: deadline = time.monotonic() + timeout_s last_err: Exception | None = None while time.monotonic() < deadline: try: with urllib.request.urlopen(url, timeout=3): return except urllib.error.HTTPError: return # server is up; status is the endpoint's business except Exception as e: # noqa: BLE001 — conn refused while booting last_err = e time.sleep(0.4) die(f"{label or url} did not come up within {timeout_s:.0f}s ({last_err})") # ── API smoke ──────────────────────────────────────────────────────────────── def poll_job(api: str, job_id: str, auth: dict, timeout_s: float = 180.0) -> dict: """Poll /jobs/{id} until done/error; die on timeout. Returns job result.""" if not job_id: die("poll_job called without a job id") deadline = time.monotonic() + timeout_s job: dict = {} while time.monotonic() < deadline: status, job = request("GET", f"{api}/jobs/{job_id}", headers=auth) if status != 200: die(f"GET /jobs/{job_id} → {status} {job}") if job.get("status") in ("done", "error"): break time.sleep(0.5) if job.get("status") != "done": die(f"job {job_id} did not finish: {job}") return job.get("result") or {} def api_smoke(api: str) -> None: print("API smoke:") status, body = request("POST", f"{api}/login", {"username": "admin", "password": "password"}) if status != 200 or not body.get("token"): die(f"POST /login admin/password → {status} {body}") auth = {"Authorization": f"Bearer {body['token']}"} ok("login admin/password → bearer token") # dry-run certification exam, end to end through the job queue status, body = request("POST", f"{api}/atp/exams/run", {"certId": EXAM_CERT, "dryRun": True}, auth) if status != 200 or not body.get("jobId"): die(f"POST /atp/exams/run → {status} {body}") job_id = body["jobId"] deadline = time.monotonic() + 180 job: dict = {} while time.monotonic() < deadline: status, job = request("GET", f"{api}/jobs/{job_id}", headers=auth) if status != 200: die(f"GET /jobs/{job_id} → {status} {job}") if job.get("status") in ("done", "error"): break time.sleep(0.5) if job.get("status") != "done": die(f"exam job did not finish: {job}") award = job.get("result") or {} if award.get("certId") != EXAM_CERT or award.get("verdict") not in ("pass", "fail"): die(f"exam award malformed: {award}") evidence_ids = award.get("evidenceIds") or [] if not evidence_ids: die(f"exam award carries no evidence: {award}") ok(f"dry-run exam {EXAM_CERT} → verdict={award['verdict']} " f"score={award.get('score')} ({len(evidence_ids)} evidence rows)") status, body = request( "GET", f"{api}/atp/evidence/{evidence_ids[0]}/verify", headers=auth) if status != 200 or not body.get("ok") or not body.get("sigValid"): die(f"evidence verify → {status} {body}") status, body = request("GET", f"{api}/atp/chain/verify", headers=auth) if status != 200 or not body.get("ok") or body.get("length", 0) < len(evidence_ids): die(f"chain verify → {status} {body}") ok(f"evidence signature + org chain verify (length={body.get('length')})") # license lifecycle: issue → gated chat → revoke → 403 status, body = request("POST", f"{api}/billing/licenses", {"agentId": LICENSED_AGENT}, auth) if status != 200 or not body.get("licenseKey") or not (body.get("license") or {}).get("id"): die(f"POST /billing/licenses → {status} {body}") license_id, key = body["license"]["id"], body["licenseKey"] ok(f"license issued for {LICENSED_AGENT} (id={license_id})") chat_body = {"messages": [{"role": "user", "content": "What is 2 + 2?"}]} chat_url = f"{api}/experts/{LICENSED_AGENT}/chat" status, body = request("POST", chat_url, chat_body, {"X-ATP-License-Key": key}) if status != 200 or not body.get("reply"): die(f"licensed chat → {status} {body}") ok("licensed /experts chat replied (usage metered)") status, body = request("POST", chat_url, chat_body, {"X-ATP-License-Key": "atpk_forged.deadbeef"}) if status != 401: die(f"forged key must 401, got {status} {body}") status, body = request("POST", f"{api}/billing/licenses/{license_id}/revoke", {"reason": "ci smoke"}, auth) if status != 200: die(f"revoke → {status} {body}") status, body = request("POST", chat_url, chat_body, {"X-ATP-License-Key": key}) if status != 403: die(f"revoked key must 403 on the very next call, got {status} {body}") ok("revoked license → 403 on the next chat call (forged key → 401)") # ── Phase 6: report-card PDF + knowledge ingestion → SME sign-off → exam ── pdf_req = urllib.request.Request(f"{api}/atp/agents/atlas-ops/report.pdf", headers=auth) with urllib.request.urlopen(pdf_req, timeout=60) as resp: pdf_bytes = resp.read() if resp.status != 200 or not pdf_bytes.startswith(b"%PDF"): die(f"report.pdf → {resp.status}, magic {pdf_bytes[:8]!r}") ok(f"report-card PDF renders ({len(pdf_bytes)} bytes)") # generate_l5_draft needs >= 2 docs (blueprint sections = doc titles) boundary = "ciSmokeBoundary" docs_md = { # >= 2 headings per doc: the generator yields ~2 items per heading # and requires >= 8 items overall. "runbook.md": ( "# Incident Runbook\n\n" "## Acknowledge\n\nAcknowledge the page within five minutes " "and open an incident channel named for the alert.\n\n" "## Escalate\n\nEscalate to the on-call lead when customer " "impact lasts longer than fifteen minutes.\n"), "rollback.md": ( "# Rollback Policy\n\n" "## Snapshot first\n\nAlways snapshot the database before any " "rollback and record the snapshot id in the channel.\n\n" "## Notify\n\nNotify the on-call lead and the release owner " "before starting the rollback.\n"), } doc_ids = [] for fname, doc_md in docs_md.items(): mp = (f"--{boundary}\r\nContent-Disposition: form-data; " f"name=\"file\"; filename=\"{fname}\"\r\n" f"Content-Type: text/markdown\r\n\r\n" f"{doc_md}\r\n--{boundary}--\r\n").encode() up_req = urllib.request.Request(f"{api}/knowledge/docs", data=mp, method="POST", headers=auth) up_req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") with urllib.request.urlopen(up_req, timeout=30) as resp: doc = json.loads(resp.read()) doc_ids.append(doc.get("id") or die(f"knowledge upload returned {doc}")) status, draft = request("POST", f"{api}/knowledge/drafts", {"docIds": doc_ids, "title": "CI L5 draft"}, headers=auth) if status != 200 or len(draft.get("items", [])) < 8: die(f"draft → {status}, items {len(draft.get('items', []))}") status, appr = request("POST", f"{api}/knowledge/drafts/{draft['id']}/approve", {"note": "ci smoke sign-off"}, headers=auth) if status != 200 or not appr.get("evidenceId"): die(f"approve → {status} {appr}") status, job = request("POST", f"{api}/knowledge/drafts/{draft['id']}/exam", {"dryRun": True}, headers=auth) if status != 200: die(f"draft exam → {status} {job}") award = poll_job(api, job.get("jobId") or job.get("job_id"), auth) if not award.get("verdict"): die(f"draft-exam award missing verdict: {award}") status, chain = request("GET", f"{api}/atp/chain/verify", headers=auth) if status != 200 or not chain.get("ok"): die(f"chain broken after knowledge flow: {chain}") ok("knowledge e2e: upload → draft → SME sign-off → dry-run exam → chain ok") # ── Browser smoke ──────────────────────────────────────────────────────────── def browser_smoke(api: str, web: str) -> None: from playwright.sync_api import sync_playwright from playwright.sync_api import TimeoutError as PWTimeoutError print("Browser smoke (playwright chromium):") console_errors: list[str] = [] page_errors: list[str] = [] bad_responses: list[str] = [] def note_response(resp) -> None: if resp.status >= 400 and not any( allowed in resp.url for allowed in RESOURCE_404_ALLOWLIST): bad_responses.append(f"HTTP {resp.status} {resp.url}") with sync_playwright() as pw: browser = pw.chromium.launch() context = browser.new_context() page = context.new_page() page.set_default_timeout(120_000) # The deployed design/config.js points at the hosted backend; serve # the CI backend instead without touching the working tree. page.route("**/config.js", lambda route: route.fulfill( status=200, content_type="application/javascript", body=f'window.BU_API_BASE = "{api}";\n')) # 'Failed to load resource' console lines carry no resource URL, so # judge those via the response listener (which has status + URL and # can honor RESOURCE_404_ALLOWLIST); keep every other console error. page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" and not m.text.startswith("Failed to load resource") else None) page.on("pageerror", lambda e: page_errors.append(str(e))) page.on("response", note_response) page.goto(f"{web}/{INDEX}") # Login gate (BU_AUTH_DISABLED=1 dev creds — api/auth.py) page.fill("#bu-gate-u", "admin") page.fill("#bu-gate-p", "password") page.click("#bu-gate-form button[type=submit]") page.wait_for_selector("#bu-gate", state="hidden") ok("login gate accepted admin/password") # App shell up = in-browser Babel compiled every .jsx (zero-build # dev mode contract). Fresh profile defaults to the agent view. # One reload retry: the pinned React/Babel/three CDN fetches are the # only non-local resources and occasionally stall on a cold edge. try: page.wait_for_selector('nav.nav button[data-nav="university"]') except PWTimeoutError: print(" … app shell slow (CDN?) — reloading once", file=sys.stderr) page.reload() page.wait_for_selector('nav.nav button[data-nav="university"]') for tab in AGENT_TABS: page.click(f'button[data-nav="{tab}"]') page.wait_for_function( """(tab) => { const btn = document.querySelector(`button[data-nav="${tab}"]`); const main = document.querySelector('main'); return btn && btn.classList.contains('active') && main && main.innerText.trim().length > 80; }""", arg=tab) chars = page.evaluate( "document.querySelector('main').innerText.trim().length") ok(f"agent tab '{tab}' rendered non-empty main ({chars} chars)") # Human ◦ Agent toggle → trailhead page.click('.view-toggle button:has-text("Human")') page.wait_for_function( """() => { const btn = document.querySelector('button[data-nav="trailhead"]'); const main = document.querySelector('main'); return btn && btn.classList.contains('active') && main && main.innerText.trim().length > 40; }""") ok("human view toggle → trailhead rendered") page.wait_for_timeout(1500) # let late async fetches surface errors browser.close() real_errors = console_errors + page_errors + bad_responses if real_errors: for e in real_errors: print(f" console/network error: {e}", file=sys.stderr) die(f"{len(real_errors)} console/network error(s) during the browser smoke") ok("zero console errors across login + 7 screens") def main() -> int: api_port, web_port = free_port(), free_port() api = f"http://127.0.0.1:{api_port}" web = f"http://127.0.0.1:{web_port}" procs: list[subprocess.Popen] = [] with tempfile.TemporaryDirectory(prefix="bu-ci-smoke-") as tmp: env = os.environ.copy() env.update({ "BU_AUTH_DISABLED": "1", # demo mode contract "DATABASE_URL": f"sqlite:///{tmp}/ci.db", # fresh throwaway DB "PYTHONUNBUFFERED": "1", # This is a functional smoke, not a rate-limit test: the exam # poll loop + browser traffic share one (IP, subject) bucket and # can exceed api/ops.py's default 120/60s. Explicitly off, per # its own env-tuning contract (BU_RATE_LIMIT=0 disables). "BU_RATE_LIMIT": "0", # Phase 6 surfaces: T2 encryption + evidence signing need keys # even in demo mode (knowledge routes 503 without them). "DATA_ENCRYPTION_KEY": "ci-smoke-data-key-0123456789abcdef", "EVIDENCE_SIGNING_KEY": "ci-smoke-evidence-key-0123456789ab", }) try: procs.append(subprocess.Popen( [sys.executable, "-m", "uvicorn", "api.server:app", "--host", "127.0.0.1", "--port", str(api_port)], cwd=ROOT, env=env)) procs.append(subprocess.Popen( [sys.executable, "-m", "http.server", str(web_port), "--bind", "127.0.0.1", "--directory", str(DESIGN_DIR)], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) wait_for(f"{api}/health", 90, "uvicorn api.server:app") wait_for(f"{web}/{INDEX}", 30, "http.server design/") print(f"servers up: api={api} web={web}") api_smoke(api) browser_smoke(api, web) finally: for p in procs: p.terminate() for p in procs: try: p.wait(timeout=10) except subprocess.TimeoutExpired: p.kill() print(f"ci_smoke: OK — {len(_STEPS)} checks green.") return 0 if __name__ == "__main__": sys.exit(main())