aps-agent/tests/golden/test_trace_break_visual.py

242 lines
8.3 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 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