242 lines
8.3 KiB
Python
242 lines
8.3 KiB
Python
# ============================================================
|
||
# 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
|