Spaces:
Sleeping
Sleeping
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())
|