File size: 18,662 Bytes
4a8ceaa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
#!/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())