# ============================================================ # round-39 黄金测试:trace 断链检测 + 可视化暴露(矩阵 114 剩余项) # 覆盖: # 1) verify_audit_trace:构造断链(哈希篡改/计数不一致/条目非法/格式非法)→ 检出 expected/actual # 2) 端到端:schedule.run 真实审计 → 校验通过;篡改 traceChainHash → 检出 # 3) API:/api/gov/audit traceAlerts(断链列表/空数组)+ /api/gov/trace/verify 汇总 # ============================================================ from __future__ import annotations import asyncio import hashlib import json import pytest from fastapi.testclient import TestClient from server.agent_core.evidence import EvidenceItem, trace_chain, verify_audit_trace from server.aps_domain.workflow import handle_intent from server.contracts import IntentResult from server.state.seed import seed_world class _MemStore: def __init__(self, data): self.data = data def next_id(self, kind: str) -> int: key = "_c_" + kind self.data[key] = self.data.get(key, 0) + 1 return self.data[key] def save(self): pass def _mk_items(): return [ EvidenceItem(kind="schedule-version", ref="1", version="1"), EvidenceItem(kind="run", ref="run-1", runId="1", engine="RULE", meta={"strategy": "COMPREHENSIVE", "solveStatus": "OPTIMAL", "gap": 0.0}), EvidenceItem(kind="algorithm", ref="RULE", version="RULE", inputsHash="abc123"), ] def _trace_rationale(items, **overrides): chain = trace_chain(items) rat = { "traceChainHash": chain["chainHash"], "traceCount": chain["count"], "traceSummary": [{"kind": it["kind"], "ref": it["ref"]} for it in chain["items"]], "traceItems": chain["items"], } rat.update(overrides) return rat def _algo_event(eid: int, rationale: dict, **overrides) -> dict: ev = {"id": eid, "at": "2026-08-02T10:00:00+08:00", "ts": "2026-08-02T10:00:00+08:00", "actor": "tester", "category": "ALGO_RUN", "action": "schedule.run", "target": {"type": "SCHEDULE_VERSION", "id": eid}, "power": "P1", "rationale": rationale, "prevHash": "GENESIS", "hash": "0" * 64} ev.update(overrides) return ev # ---------------- 1) verify_audit_trace 纯函数 ---------------- def test_verify_audit_trace_ok_on_self_describing_record(): ev = _algo_event(1, _trace_rationale(_mk_items())) result = verify_audit_trace([ev]) assert result["ok"] is True assert result["checked"] == 1 assert result["broken"] == [] def test_verify_audit_trace_detects_hash_tamper(): items = _mk_items() ev = _algo_event(1, _trace_rationale(items, traceChainHash="f" * 64)) result = verify_audit_trace([ev]) assert result["ok"] is False (b,) = result["broken"] assert b["eventId"] == 1 assert b["expected"] == "f" * 64 assert b["actual"] == trace_chain(items)["chainHash"] # 重算出的真实链哈希 assert b["reason"] == "chain-hash-mismatch" def test_verify_audit_trace_detects_count_mismatch(): ev = _algo_event(2, _trace_rationale(_mk_items(), traceCount=2)) result = verify_audit_trace([ev]) assert result["ok"] is False b = result["broken"][0] assert b["eventId"] == 2 assert b["reason"] == "count-mismatch" assert b["expected"] == "2" assert b["actual"] == "3" def test_verify_audit_trace_detects_bad_summary_entry(): rat = _trace_rationale(_mk_items()) rat["traceSummary"][1] = {"kind": "hack", "ref": "x"} ev = _algo_event(3, rat) result = verify_audit_trace([ev]) assert result["ok"] is False assert result["broken"][0]["reason"] == "bad-summary-entry" def test_verify_audit_trace_detects_bad_hash_format(): ev = _algo_event(4, _trace_rationale(_mk_items(), traceChainHash="zzz")) result = verify_audit_trace([ev]) assert result["ok"] is False assert result["broken"][0]["reason"] == "bad-hash-format" def test_verify_audit_trace_legacy_record_no_false_positive(): """旧记录(无 traceItems,只有 traceChainHash/count/summary)仅结构校验,不误报。""" rat = _trace_rationale(_mk_items()) rat.pop("traceItems") ev = _algo_event(5, rat) result = verify_audit_trace([ev]) assert result["ok"] is True assert result["broken"] == [] # ---------------- 2) 端到端:schedule.run 真实审计 ---------------- def test_schedule_run_audit_verifies_then_tamper_detected(): store = _MemStore(seed_world()) async def _run(): await handle_intent(store, "t", IntentResult(intent="schedule.run", params={"strategy": "COMPREHENSIVE", "engine": "RULE"}, confidence=1.0, source="RULE_FAST")) asyncio.run(_run()) runs = [e for e in store.data["auditEvents"] if e["category"] == "ALGO_RUN" and e["action"] == "schedule.run"] assert runs ev = runs[-1] assert isinstance(ev["rationale"].get("traceItems"), list), "traceItems 应落盘以便精确重算" result = verify_audit_trace(store.data["auditEvents"]) assert result["ok"] is True tampered = json.loads(json.dumps(ev)) tampered["rationale"]["traceChainHash"] = "0" * 64 result2 = verify_audit_trace([tampered]) assert result2["ok"] is False (b,) = result2["broken"] assert b["eventId"] == ev["id"] assert b["expected"] == "0" * 64 assert b["actual"] == ev["rationale"]["traceChainHash"] assert b["reason"] == "chain-hash-mismatch" # ---------------- 3) API:/api/gov/audit traceAlerts + /api/gov/trace/verify ---------------- class _ApiStore: def __init__(self, events): self.tenant_uuid = "platform" self.world_key = "default" self.data = {"auditEvents": events} def next_id(self, _kind: str) -> int: return 999 def save(self) -> None: pass def _signed_events(events): """把测试事件签名成哈希链一致(避免链告警干扰 traceAlerts 断言)。""" prev = "GENESIS" for ev in events: body = {k: v for k, v in ev.items() if k != "hash"} body["prevHash"] = prev ev["prevHash"] = prev ev["hash"] = hashlib.sha256( (prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode("utf-8") ).hexdigest() prev = ev["hash"] return events @pytest.fixture def gov_client(monkeypatch, tmp_path): import os import server.gateway.app as gateway_module from tests.auth_provider import install_test_auth install_test_auth(monkeypatch, "tenant-trace-visual") os.environ["APS_AUDIT_LEDGER_DIR"] = str(tmp_path / "ledger") def _make(events): store = _ApiStore(_signed_events(events)) monkeypatch.setattr(gateway_module, "get_store", lambda: store) c = TestClient(gateway_module.create_app()) login = c.post("/api/auth/login", json={"username": "planner", "password": "test"}) assert login.status_code == 200, login.text return c yield _make os.environ.pop("APS_AUDIT_LEDGER_DIR", None) def test_gov_audit_trace_alerts_empty_when_clean(gov_client): c = gov_client([_algo_event(1, _trace_rationale(_mk_items()))]) r = c.get("/api/gov/audit") assert r.status_code == 200, r.text body = r.json() assert body["traceAlerts"] == [], "无断链时 traceAlerts 应为空数组" assert body["traceOk"] is True assert body["traceChecked"] == 1 r2 = c.get("/api/gov/trace/verify") assert r2.status_code == 200, r2.text v = r2.json() assert v["ok"] is True and v["checked"] == 1 and v["broken"] == [] assert v["summary"]["broken"] == 0 def test_gov_audit_trace_alerts_nonempty_when_broken(gov_client): items = _mk_items() ev = _algo_event(1, _trace_rationale(items, traceChainHash="f" * 64)) c = gov_client([ev]) r = c.get("/api/gov/audit") assert r.status_code == 200, r.text body = r.json() assert body["traceOk"] is False assert len(body["traceAlerts"]) == 1 b = body["traceAlerts"][0] assert b["eventId"] == 1 assert b["expected"] == "f" * 64 assert b["actual"] == trace_chain(items)["chainHash"] assert b["reason"] == "chain-hash-mismatch" r2 = c.get("/api/gov/trace/verify") assert r2.status_code == 200, r2.text v = r2.json() assert v["ok"] is False assert v["summary"]["broken"] == 1