224 lines
9.6 KiB
Python
224 lines
9.6 KiB
Python
# ============================================================
|
||
# WORM 归档模拟 + 完整性验证黄金测试(矩阵 104/112/116 的 WORM 剩余项)
|
||
# 覆盖:归档生成 manifest(sha256 + merkle root + 归档时间 + 覆盖范围)、
|
||
# 归档后写拒绝(只读位 + manifest 在册)、同月二次归档不改写旧件、
|
||
# 篡改检出(tampered expected/actual)、缺失/无清单显式报告、
|
||
# merkle 复算校验、verify 汇总结构、网关端点接线(P0 verify / P1 run)。
|
||
# 说明:物理 WORM 介质(光盘/WORM 盘)为外部验收范围,本测试验证逻辑 WORM 语义。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import stat
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from server.agent_core.audit_ledger import WORM_MANIFEST, WORM_NOTE, AnchorLedger
|
||
from server.agent_core.audit_merkle import merkle_root
|
||
|
||
|
||
def _event(i: int) -> dict:
|
||
return {"id": i, "at": "2026-08-01T00:00:00+08:00", "actor": "tester",
|
||
"category": "TEST", "action": f"test.{i}", "target": {"type": "TEST"},
|
||
"power": "P0", "rationale": {}, "prevHash": "GENESIS", "hash": "0" * 64}
|
||
|
||
|
||
def _seed_ledger(ledger: AnchorLedger, n: int = 5) -> None:
|
||
evs: list[dict] = []
|
||
for i in range(n):
|
||
evs.append(_event(i + 1))
|
||
ledger.append(evs, reason="test")
|
||
|
||
|
||
def _sha256(path: Path) -> str:
|
||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||
|
||
|
||
def _manifest(ledger: AnchorLedger) -> dict:
|
||
return json.loads((ledger.archive_dir / WORM_MANIFEST).read_text(encoding="utf-8"))
|
||
|
||
|
||
def test_archive_run_generates_worm_manifest(tmp_path: Path):
|
||
"""归档生成 WORM 清单:每文件 sha256 + merkleRoot + 归档时间 + 覆盖范围 + 只读位。"""
|
||
ledger = AnchorLedger("tenant-w", "world-w", ledger_dir=str(tmp_path))
|
||
_seed_ledger(ledger)
|
||
r = ledger.archive_old(keep_last=2)
|
||
assert r["archived"] == 3
|
||
assert r["worm"]["sealed"] is True
|
||
assert r["worm"]["mode"] == "worm-simulated"
|
||
|
||
manifest = _manifest(ledger)
|
||
assert manifest["mode"] == "worm-simulated"
|
||
assert WORM_NOTE in manifest["note"] # 文档化:物理介质为外部验收
|
||
fname = Path(r["archive"]).name
|
||
entry = manifest["files"][fname]
|
||
assert entry["sha256"] == _sha256(Path(r["archive"]))
|
||
assert entry["merkleRoot"] # merkle root 摘要
|
||
assert entry["archivedAt"] # 归档时间
|
||
assert entry["coverage"]["records"] == 3 # 覆盖范围
|
||
assert entry["coverage"]["firstEventId"] and entry["coverage"]["lastEventId"]
|
||
assert entry["readonly"] is True # WORM 只读位
|
||
|
||
|
||
def test_archive_file_write_refused_after_seal(tmp_path: Path):
|
||
"""归档后常规写路径拒绝改写:文件只读(直接写抛错)+ manifest 在册拒绝。"""
|
||
ledger = AnchorLedger("tenant-w", "world-w", ledger_dir=str(tmp_path))
|
||
_seed_ledger(ledger)
|
||
r = ledger.archive_old(keep_last=2)
|
||
archive = Path(r["archive"])
|
||
# 常规文件写路径被只读位拒绝(Windows 抛 PermissionError)
|
||
with pytest.raises((PermissionError, OSError)):
|
||
archive.write_text(archive.read_text(encoding="utf-8") + "tamper\n", encoding="utf-8")
|
||
# 账本写路径对已密封归档文件显式拒绝(manifest 在册 → ValueError)
|
||
with pytest.raises(ValueError):
|
||
ledger._guard_archive_sealed(archive)
|
||
|
||
|
||
def test_second_archive_same_month_never_rewrites_sealed(tmp_path: Path):
|
||
"""同月二次归档不改写已密封文件:改用 <month>.<n>.jsonl,旧件 sha256 不变。"""
|
||
ledger = AnchorLedger("tenant-w", "world-w", ledger_dir=str(tmp_path))
|
||
_seed_ledger(ledger, n=5)
|
||
r1 = ledger.archive_old(keep_last=2)
|
||
first = Path(r1["archive"])
|
||
first_sha = _sha256(first)
|
||
# 追加新锚定记录后再次归档(同月)→ 新文件而非覆写旧件
|
||
_seed_ledger(ledger, n=3)
|
||
r2 = ledger.archive_old(keep_last=2)
|
||
second = Path(r2["archive"])
|
||
assert second.name != first.name
|
||
assert second.name == f"{first.stem}.2.jsonl" or second.name.endswith(".2.jsonl")
|
||
assert _sha256(first) == first_sha # 旧件字节级未变
|
||
manifest = _manifest(ledger)
|
||
assert first.name in manifest["files"] and second.name in manifest["files"]
|
||
|
||
|
||
def test_verify_archive_detects_tampering(tmp_path: Path):
|
||
"""篡改检出:改归档文件内容 → tampered {file, expected, actual},ok=False。"""
|
||
ledger = AnchorLedger("tenant-w", "world-w", ledger_dir=str(tmp_path))
|
||
_seed_ledger(ledger)
|
||
r = ledger.archive_old(keep_last=2)
|
||
archive = Path(r["archive"])
|
||
assert ledger.verify_archive()["ok"] is True
|
||
|
||
os.chmod(archive, stat.S_IWRITE) # 模拟攻击者清除只读位
|
||
archive.write_text(
|
||
archive.read_text(encoding="utf-8")
|
||
+ json.dumps({"eventId": "EVIL", "root": "0" * 64, "count": 1,
|
||
"at": "2026-08-01 00:00", "reason": "tamper"}) + "\n",
|
||
encoding="utf-8")
|
||
result = ledger.verify_archive()
|
||
assert result["ok"] is False
|
||
assert result["summary"]["tampered"] == 1
|
||
item = result["tampered"][0]
|
||
assert item["file"] == archive.name
|
||
assert item["expected"] == r["worm"]["entry"]["sha256"]
|
||
assert item["actual"] == _sha256(archive)
|
||
assert item["actual"] != item["expected"]
|
||
|
||
|
||
def test_verify_archive_reports_missing_and_no_manifest(tmp_path: Path):
|
||
"""缺失文件显式报告;无归档(无清单)给出确定性结构且 ok=True。"""
|
||
ledger = AnchorLedger("tenant-w", "world-w", ledger_dir=str(tmp_path))
|
||
_seed_ledger(ledger)
|
||
r = ledger.archive_old(keep_last=2)
|
||
archive = Path(r["archive"])
|
||
os.chmod(archive, stat.S_IWRITE) # 清除只读位以便删除
|
||
archive.unlink()
|
||
result = ledger.verify_archive()
|
||
assert result["ok"] is False
|
||
assert result["summary"]["missing"] == 1
|
||
assert result["missing"][0]["file"] == archive.name
|
||
assert result["missing"][0]["expected"] # 清单中的期望 sha256
|
||
|
||
fresh = AnchorLedger("tenant-fresh", "fresh", ledger_dir=str(tmp_path / "other"))
|
||
empty = fresh.verify_archive()
|
||
assert empty["ok"] is True # 无归档 = 无篡改
|
||
assert empty["summary"]["noManifest"] is True
|
||
assert empty["manifest"] is None
|
||
|
||
|
||
def test_verify_archive_merkle_recompute_and_summary(tmp_path: Path):
|
||
"""merkle 复算一致 + verify 汇总字段齐全。"""
|
||
ledger = AnchorLedger("tenant-w", "world-w", ledger_dir=str(tmp_path))
|
||
_seed_ledger(ledger)
|
||
r = ledger.archive_old(keep_last=2)
|
||
archive = Path(r["archive"])
|
||
records = [json.loads(ln) for ln in
|
||
archive.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
||
manifest = _manifest(ledger)
|
||
assert manifest["files"][archive.name]["merkleRoot"] == merkle_root(records)
|
||
|
||
result = ledger.verify_archive()
|
||
assert result["ok"] is True
|
||
assert result["mode"] == "worm-simulated"
|
||
s = result["summary"]
|
||
for key in ("files", "verified", "tampered", "missing", "merkleMismatch",
|
||
"unsealed", "noManifest"):
|
||
assert key in s
|
||
assert s["files"] == 1 and s["verified"] == 1
|
||
assert s["tampered"] == 0 and s["missing"] == 0 and s["merkleMismatch"] == 0
|
||
assert result["tampered"] == [] and result["missing"] == []
|
||
|
||
|
||
class _Store:
|
||
def __init__(self):
|
||
self.tenant_uuid = "platform"
|
||
self.world_key = "default"
|
||
self.data = {"auditEvents": [
|
||
{"id": 1, "at": "2026-08-01T00:00:00+08:00", "ts": "2026-08-01T00:00:00+08:00",
|
||
"actor": "tester", "category": "TEST", "action": "test.one",
|
||
"target": {"type": "TEST"}, "power": "P0", "rationale": {},
|
||
"prevHash": "GENESIS", "hash": "0" * 64},
|
||
]}
|
||
|
||
def next_id(self, _kind: str) -> int:
|
||
return 2
|
||
|
||
def save(self) -> None:
|
||
pass
|
||
|
||
|
||
@pytest.fixture
|
||
def client(monkeypatch, tmp_path):
|
||
import server.gateway.app as gateway_module
|
||
from tests.auth_provider import install_test_auth
|
||
install_test_auth(monkeypatch, "tenant-worm-api")
|
||
store = _Store()
|
||
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, store
|
||
|
||
|
||
def test_gov_archive_endpoints_roundtrip(client, tmp_path):
|
||
"""网关接线:/api/gov/archive/run 触发归档 + 生成 manifest;/verify 只读返回汇总明细。"""
|
||
c, store = client
|
||
os.environ["APS_AUDIT_LEDGER_DIR"] = str(tmp_path / "ledger")
|
||
try:
|
||
v0 = c.get("/api/gov/archive/verify")
|
||
assert v0.status_code == 200
|
||
assert v0.json()["mode"] == "worm-simulated"
|
||
assert v0.json()["summary"]["noManifest"] is True
|
||
|
||
for _ in range(3): # 锚定 3 条账本记录
|
||
assert c.post("/api/gov/audit/anchor").status_code == 200
|
||
run = c.post("/api/gov/archive/run?keep_last=1")
|
||
assert run.status_code == 200, run.text
|
||
body = run.json()
|
||
assert body["archived"] == 2
|
||
assert body["worm"]["sealed"] is True
|
||
|
||
v = c.get("/api/gov/archive/verify")
|
||
assert v.status_code == 200
|
||
data = v.json()
|
||
assert data["ok"] is True
|
||
assert data["summary"]["verified"] == 1
|
||
assert data["tampered"] == [] and data["missing"] == []
|
||
assert data["summary"]["noManifest"] is False
|
||
finally:
|
||
os.environ.pop("APS_AUDIT_LEDGER_DIR", None) |