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
|