File size: 19,256 Bytes
f74bce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
931c763
 
 
 
 
 
 
 
 
 
 
 
 
14a02b9
931c763
 
 
f74bce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7700633
 
 
 
 
 
 
413c5fe
 
 
 
 
 
 
 
 
 
2608b33
 
 
 
 
 
 
 
 
 
 
 
 
50a1c46
 
 
 
 
 
 
 
 
 
 
 
 
72e4ba4
 
 
 
931c763
 
72e4ba4
 
 
 
 
 
931c763
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72e4ba4
 
 
 
5b93402
 
 
 
 
 
a7a715a
 
 
 
 
 
5b93402
 
 
 
40cb975
 
 
 
 
 
 
 
 
 
bd6cbae
 
 
 
 
 
 
 
 
 
6609140
 
 
 
 
 
 
 
 
 
f74bce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7700633
 
413c5fe
 
 
 
2608b33
 
 
 
50a1c46
 
 
 
72e4ba4
 
931c763
72e4ba4
 
5b93402
 
 
a7a715a
5b93402
40cb975
 
 
 
bd6cbae
 
 
 
6609140
 
 
 
f74bce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Run an end-to-end ProofFrame API smoke test against a running server."""

from __future__ import annotations

import argparse
from io import BytesIO
import json
from pathlib import Path
import re
from typing import Any
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from zipfile import ZipFile


FORBIDDEN_EVIDENCE_KEYS = re.compile(
    r"(?i)(api[_-]?key|application[_-]?key|authorization|cookie|password|secret|token)"
)
FORBIDDEN_EVIDENCE_VALUES = [
    re.compile(r"(?i)authorization:\s*bearer\s+[A-Za-z0-9._\-]{20,}"),
    re.compile(r"(?i)(api[_-]?key|application[_-]?key|secret|token|cookie)=[^&\s]{8,}"),
    re.compile(r"(?i)x-amz-(credential|security-token|signature)=[^&\s]{8,}"),
    re.compile(r"(?i)gmi-[A-Za-z0-9_\-]{16,}"),
]
FINAL_CLOSEOUT_REQUIRED_GATES = {
    "credential_handoff",
    "b2_live_proof",
    "genblaze_live_proof",
    "public_video",
    "devpost_receipt",
    "final_control",
}
FINAL_CLOSEOUT_PREFINAL_MODES = {"waiting_for_credentials", "closeout_blocked"}
FINAL_CLOSEOUT_FINAL_MODE = "final_closeout_ready"
FINAL_CLOSEOUT_SECRET_POLICY_TERMS = {
    "never stores",
    "backblaze keys",
    "genblaze provider keys",
    "devpost cookies",
    "signed urls",
}


def request_json(method: str, url: str, payload: dict[str, Any] | None = None) -> Any:
    body = None if payload is None else json.dumps(payload).encode("utf-8")
    request = Request(
        url,
        data=body,
        method=method,
        headers={"Content-Type": "application/json"},
    )
    try:
        with urlopen(request, timeout=60) as response:
            return json.loads(response.read().decode("utf-8"))
    except HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise SystemExit(f"{method} {url} failed: {exc.code} {detail}") from exc


def request_bytes(method: str, url: str) -> tuple[bytes, str]:
    request = Request(url, method=method)
    try:
        with urlopen(request, timeout=60) as response:
            return response.read(), response.headers.get_content_type()
    except HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise SystemExit(f"{method} {url} failed: {exc.code} {detail}") from exc


def require_backend(field: str, actual: str, expected: str | None) -> None:
    if expected and actual != expected:
        raise SystemExit(f"Expected {field}={expected}, got {actual}")


def evidence_findings(value: Any, path: str = "$") -> list[str]:
    findings: list[str] = []
    if isinstance(value, dict):
        for key, item in value.items():
            key_path = f"{path}.{key}"
            if FORBIDDEN_EVIDENCE_KEYS.search(str(key)):
                findings.append(f"{key_path}: forbidden evidence key")
            findings.extend(evidence_findings(item, key_path))
    elif isinstance(value, list):
        for index, item in enumerate(value):
            findings.extend(evidence_findings(item, f"{path}[{index}]"))
    elif isinstance(value, str):
        for pattern in FORBIDDEN_EVIDENCE_VALUES:
            if pattern.search(value):
                findings.append(f"{path}: forbidden evidence value")
                break
    return findings


def assert_safe_evidence(evidence: dict[str, Any]) -> None:
    findings = evidence_findings(evidence)
    if findings:
        raise SystemExit("Unsafe evidence JSON refused:\n" + "\n".join(findings))


def write_evidence(path: Path, evidence: dict[str, Any]) -> None:
    assert_safe_evidence(evidence)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8")


def run_smoke(
    base_url: str,
    *,
    require_storage_backend: str | None = None,
    require_generation_backend: str | None = None,
) -> dict[str, Any]:
    base = base_url.rstrip("/")
    health = request_json("GET", f"{base}/api/health")
    if not health.get("ready"):
        raise SystemExit(f"App is not ready: {health}")
    require_backend("storage_backend", health["storage_backend"], require_storage_backend)
    require_backend("generation_backend", health["generation_backend"], require_generation_backend)

    judge_brief = request_json("GET", f"{base}/api/judge/brief")
    if judge_brief.get("schema") != "proofframe.judge_brief.v1":
        raise SystemExit("Judge brief endpoint did not return the expected schema.")
    safe_to_submit = judge_brief.get("status", {}).get("safe_to_submit")
    if not isinstance(safe_to_submit, bool):
        raise SystemExit("Judge brief endpoint did not include a boolean safe_to_submit flag.")

    judge_crosswalk = request_json("GET", f"{base}/api/judge/crosswalk")
    if judge_crosswalk.get("schema") != "proofframe.judge_crosswalk.v1":
        raise SystemExit("Judge crosswalk endpoint did not return the expected schema.")
    crosswalk_safe_to_submit = judge_crosswalk.get("safe_to_submit")
    if not isinstance(crosswalk_safe_to_submit, bool):
        raise SystemExit("Judge crosswalk endpoint did not include a boolean safe_to_submit flag.")
    crosswalk_rows = judge_crosswalk.get("rows")
    if not isinstance(crosswalk_rows, list) or len(crosswalk_rows) < 4:
        raise SystemExit("Judge crosswalk endpoint did not include the expected criteria rows.")

    decision_brief = request_json("GET", f"{base}/api/judge/decision-brief")
    if decision_brief.get("schema") != "proofframe.judge_decision_brief.v1":
        raise SystemExit("Judge decision brief endpoint did not return the expected schema.")
    decision_brief_safe_to_submit = decision_brief.get("safe_to_submit")
    if not isinstance(decision_brief_safe_to_submit, bool):
        raise SystemExit("Judge decision brief endpoint did not include a boolean safe_to_submit flag.")
    decision_checks = decision_brief.get("decision_checks")
    if not isinstance(decision_checks, list) or len(decision_checks) < 6:
        raise SystemExit("Judge decision brief endpoint did not include the expected decision checks.")
    if health["storage_backend"] == "local" and health["generation_backend"] == "mock":
        if decision_brief_safe_to_submit:
            raise SystemExit("Mock/local judge decision brief must remain fail closed.")

    evidence_index = request_json("GET", f"{base}/api/judge/evidence-index")
    if evidence_index.get("schema") != "proofframe.judge_evidence_index.v1":
        raise SystemExit("Judge evidence index endpoint did not return the expected schema.")
    evidence_links = evidence_index.get("links")
    if not isinstance(evidence_links, list) or len(evidence_links) < 10:
        raise SystemExit("Judge evidence index endpoint did not include the expected evidence links.")
    evidence_safe_to_submit = evidence_index.get("safe_to_submit")
    if not isinstance(evidence_safe_to_submit, bool):
        raise SystemExit("Judge evidence index endpoint did not include a boolean safe_to_submit flag.")
    evidence_index_status = evidence_index.get("status")
    if not isinstance(evidence_index_status, dict) or "final_blockers" not in evidence_index_status:
        raise SystemExit("Judge evidence index endpoint did not include final blocker status.")

    final_closeout = request_json("GET", f"{base}/api/judge/final-closeout")
    if final_closeout.get("schema") != "proofframe.final_closeout_status.v1":
        raise SystemExit("Judge final closeout endpoint did not return the expected schema.")
    closeout_health_ok = final_closeout.get("closeout_health_ok")
    if closeout_health_ok is not True:
        raise SystemExit("Judge final closeout endpoint did not report closeout_health_ok=true.")
    closeout_safe_to_submit = final_closeout.get("safe_to_submit")
    if not isinstance(closeout_safe_to_submit, bool):
        raise SystemExit("Judge final closeout endpoint did not include a boolean safe_to_submit flag.")
    closeout_gates = final_closeout.get("gates")
    if not isinstance(closeout_gates, list) or len(closeout_gates) < 6:
        raise SystemExit("Judge final closeout endpoint did not include the expected gate ledger.")
    closeout_gate_ids = {
        str(gate.get("id")) for gate in closeout_gates if isinstance(gate, dict) and isinstance(gate.get("id"), str)
    }
    if not FINAL_CLOSEOUT_REQUIRED_GATES <= closeout_gate_ids:
        missing = sorted(FINAL_CLOSEOUT_REQUIRED_GATES - closeout_gate_ids)
        raise SystemExit("Judge final closeout endpoint is missing required gates: " + ", ".join(missing))
    closeout_secret_policy = str(final_closeout.get("secret_policy") or "").lower()
    if not all(term in closeout_secret_policy for term in FINAL_CLOSEOUT_SECRET_POLICY_TERMS):
        raise SystemExit("Judge final closeout endpoint did not include the expected no-secret policy.")
    closeout_mode = final_closeout.get("mode")
    closeout_all_gates_ok = all(isinstance(gate, dict) and gate.get("ok") is True for gate in closeout_gates)
    closeout_has_blocker = any(isinstance(gate, dict) and gate.get("ok") is False for gate in closeout_gates)
    if closeout_safe_to_submit:
        if closeout_mode != FINAL_CLOSEOUT_FINAL_MODE or not closeout_all_gates_ok:
            raise SystemExit("Final closeout safe_to_submit=true requires final_closeout_ready and all gates green.")
    elif closeout_mode not in FINAL_CLOSEOUT_PREFINAL_MODES or not closeout_has_blocker:
        raise SystemExit("Pre-final closeout must remain blocked with a visible blocker gate.")
    if health["storage_backend"] == "local" and health["generation_backend"] == "mock":
        if closeout_safe_to_submit:
            raise SystemExit("Mock/local final closeout must remain fail closed.")

    video_publish_kit = request_json("GET", f"{base}/api/judge/video-publish-kit")
    if video_publish_kit.get("schema") != "proofframe.final_video_publish_kit.v1":
        raise SystemExit("Judge video publish kit endpoint did not return the expected schema.")
    video_publish_safe_to_submit = video_publish_kit.get("safe_to_submit")
    if not isinstance(video_publish_safe_to_submit, bool):
        raise SystemExit("Judge video publish kit endpoint did not include a boolean safe_to_submit flag.")
    video_publish_final_ready = video_publish_kit.get("final_video_ready")
    if not isinstance(video_publish_final_ready, bool):
        raise SystemExit("Judge video publish kit endpoint did not include a boolean final_video_ready flag.")
    if health["storage_backend"] == "local" and health["generation_backend"] == "mock":
        if video_publish_safe_to_submit or video_publish_final_ready:
            raise SystemExit("Mock/local judge video publish kit must remain pre-final and fail closed.")
    upload_checklist = video_publish_kit.get("upload_checklist")
    if not isinstance(upload_checklist, list) or len(upload_checklist) < 4:
        raise SystemExit("Judge video publish kit endpoint did not include the expected upload checklist.")

    judge_recording = request_json("GET", f"{base}/api/judge/recording")
    if judge_recording.get("schema") != "proofframe.recording_assets.v1":
        raise SystemExit("Judge recording endpoint did not return the expected schema.")
    recording_shots = judge_recording.get("shot_plan")
    if not isinstance(recording_shots, list) or len(recording_shots) < 3:
        raise SystemExit("Judge recording endpoint did not include the expected shot plan.")
    recording_final_ready = judge_recording.get("final_video_ready")
    if not isinstance(recording_final_ready, bool):
        raise SystemExit("Judge recording endpoint did not include a boolean final_video_ready flag.")

    judge_devpost = request_json("GET", f"{base}/api/judge/devpost")
    if judge_devpost.get("schema") != "proofframe.devpost_form_kit.v1":
        raise SystemExit("Judge Devpost endpoint did not return the expected schema.")
    devpost_fields = judge_devpost.get("fields")
    if not isinstance(devpost_fields, list) or len(devpost_fields) < 10:
        raise SystemExit("Judge Devpost endpoint did not include the expected form fields.")
    devpost_final_ready = judge_devpost.get("final_form_ready")
    if not isinstance(devpost_final_ready, bool):
        raise SystemExit("Judge Devpost endpoint did not include a boolean final_form_ready flag.")

    submission_checklist = request_json("GET", f"{base}/api/judge/submission-checklist")
    if submission_checklist.get("schema") != "proofframe.devpost_submission_checklist.v1":
        raise SystemExit("Judge submission checklist endpoint did not return the expected schema.")
    checklist_preflight = submission_checklist.get("preflight")
    if not isinstance(checklist_preflight, list) or len(checklist_preflight) < 3:
        raise SystemExit("Judge submission checklist endpoint did not include the expected preflight rows.")
    checklist_safe_to_submit = submission_checklist.get("safe_to_submit")
    if not isinstance(checklist_safe_to_submit, bool):
        raise SystemExit("Judge submission checklist endpoint did not include a boolean safe_to_submit flag.")

    campaign = request_json(
        "POST",
        f"{base}/api/campaigns",
        {
            "title": "Judge packet smoke",
            "brief": "Create one safe campaign visual for a product launch evidence packet.",
            "audience": "hackathon judges",
            "tone": "clear and production-ready",
        },
    )
    assets = request_json("POST", f"{base}/api/campaigns/{campaign['id']}/generate?count=1")
    if len(assets) != 1:
        raise SystemExit(f"Expected one generated asset, received {len(assets)}")
    asset = assets[0]
    approved = request_json(
        "POST",
        f"{base}/api/assets/{asset['id']}/status",
        {"status": "approved", "risk_note": "Approved by API smoke test."},
    )
    exported = request_json("POST", f"{base}/api/campaigns/{campaign['id']}/export")
    manifest_assets = exported["manifest"]["assets"]
    if len(manifest_assets) != 1:
        raise SystemExit("Manifest did not include the generated asset.")

    packet_bytes, packet_content_type = request_bytes(
        "GET",
        f"{base}/api/campaigns/{campaign['id']}/packet.zip",
    )
    if packet_content_type != "application/zip":
        raise SystemExit(f"Packet endpoint returned {packet_content_type}, not application/zip.")
    with ZipFile(BytesIO(packet_bytes)) as packet:
        packet_names = set(packet.namelist())
        if "manifest.json" not in packet_names or "README.txt" not in packet_names:
            raise SystemExit(f"Packet is missing required files: {packet_names}")

    demo = request_json("POST", f"{base}/api/demo/judge-packet")
    demo_assets = demo["assets"]
    if len(demo_assets) != 3 or demo_assets[0]["status"] != "approved":
        raise SystemExit("Judge demo packet did not return three assets with one approved asset.")

    return {
        "ok": True,
        "base_url": base,
        "generation_backend": health["generation_backend"],
        "storage_backend": health["storage_backend"],
        "judge_brief_schema": judge_brief["schema"],
        "judge_safe_to_submit": safe_to_submit,
        "judge_crosswalk_schema": judge_crosswalk["schema"],
        "judge_crosswalk_mode": judge_crosswalk.get("mode"),
        "judge_crosswalk_safe_to_submit": crosswalk_safe_to_submit,
        "judge_crosswalk_rows": len(crosswalk_rows),
        "judge_decision_brief_schema": decision_brief["schema"],
        "judge_decision_brief_mode": decision_brief.get("mode"),
        "judge_decision_brief_safe_to_submit": decision_brief_safe_to_submit,
        "judge_decision_brief_checks": len(decision_checks),
        "judge_evidence_index_schema": evidence_index["schema"],
        "judge_evidence_index_mode": evidence_index.get("mode"),
        "judge_evidence_index_safe_to_submit": evidence_safe_to_submit,
        "judge_evidence_index_links": len(evidence_links),
        "judge_final_closeout_schema": final_closeout["schema"],
        "judge_final_closeout_mode": final_closeout.get("mode"),
        "judge_final_closeout_health_ok": closeout_health_ok,
        "judge_final_closeout_safe_to_submit": closeout_safe_to_submit,
        "judge_final_closeout_gates": len(closeout_gates),
        "judge_video_publish_kit_schema": video_publish_kit["schema"],
        "judge_video_publish_kit_mode": video_publish_kit.get("mode"),
        "judge_video_publish_kit_safe_to_submit": video_publish_safe_to_submit,
        "judge_video_publish_kit_final_video_ready": video_publish_final_ready,
        "judge_video_publish_kit_upload_checks": len(upload_checklist),
        "judge_recording_schema": judge_recording["schema"],
        "judge_recording_mode": judge_recording.get("mode"),
        "judge_recording_final_video_ready": recording_final_ready,
        "judge_recording_shots": len(recording_shots),
        "judge_devpost_schema": judge_devpost["schema"],
        "judge_devpost_mode": judge_devpost.get("mode"),
        "judge_devpost_final_form_ready": devpost_final_ready,
        "judge_devpost_fields": len(devpost_fields),
        "judge_submit_checklist_schema": submission_checklist["schema"],
        "judge_submit_checklist_mode": submission_checklist.get("mode"),
        "judge_submit_checklist_safe_to_submit": checklist_safe_to_submit,
        "judge_submit_checklist_preflight": len(checklist_preflight),
        "campaign_id": campaign["id"],
        "asset_id": asset["id"],
        "asset_provider": asset["provider"],
        "asset_model": asset["model"],
        "asset_storage_backend": asset["storage_backend"],
        "asset_storage_key": asset["storage_key"],
        "asset_public_url_present": bool(asset.get("public_url")),
        "asset_status": approved["status"],
        "asset_sha256": asset["sha256"],
        "manifest_storage_backend": exported["stored_manifest"]["storage_backend"],
        "manifest_key": exported["stored_manifest"]["storage_key"],
        "manifest_sha256": exported["stored_manifest"]["sha256"],
        "packet_bytes": len(packet_bytes),
        "judge_demo_campaign_id": demo["campaign"]["id"],
    }


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Smoke test a running ProofFrame API.")
    parser.add_argument("--base-url", default="http://127.0.0.1:8088")
    parser.add_argument(
        "--require-storage-backend",
        choices=["local", "b2"],
        help="Fail unless /api/health reports this storage backend.",
    )
    parser.add_argument(
        "--require-generation-backend",
        choices=["mock", "genblaze"],
        help="Fail unless /api/health reports this generation backend.",
    )
    parser.add_argument(
        "--evidence-out",
        type=Path,
        help="Write the safe smoke-test evidence JSON to this path.",
    )
    return parser


def main() -> None:
    args = build_parser().parse_args()
    evidence = run_smoke(
        args.base_url,
        require_storage_backend=args.require_storage_backend,
        require_generation_backend=args.require_generation_backend,
    )
    if args.evidence_out:
        write_evidence(args.evidence_out, evidence)
    print(json.dumps(evidence, indent=2))


if __name__ == "__main__":
    main()