# ============================================================ # 审计 append-only 守护黄金测试(plan.md §3.6 / 矩阵 116 行剩余项) # 覆盖:跨进程锁并发写不丢行、清理策略(保留窗口归档不删除证据)。 # ============================================================ from __future__ import annotations import json import threading from pathlib import Path from server.agent_core.audit_filelock import locked_append from server.agent_core.audit_ledger import AnchorLedger from server.agent_core.audit_mirror import AuditMirror 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": {}, "evidenceRefs": [], "beforeSnapshot": None, "result": "SUCCESS", "prevHash": "GENESIS", "hash": "0" * 64} def test_locked_append_concurrent_no_lost_lines(tmp_path: Path): """跨进程锁:4 线程×50 次并发 append 同一 JSONL,200 行全保留、无交错损坏。""" target = tmp_path / "shared.jsonl" def worker(n: int) -> None: for i in range(50): with locked_append(target) as fh: fh.write(json.dumps({"n": n, "i": i}, sort_keys=True) + "\n") threads = [threading.Thread(target=worker, args=(n,)) for n in range(4)] for t in threads: t.start() for t in threads: t.join() lines = target.read_text(encoding="utf-8").splitlines() assert len(lines) == 200, f"expected 200 lines, got {len(lines)}" for ln in lines: assert json.loads(ln), "行应为合法 JSON(无交错损坏)" def test_ledger_archive_old_keeps_window_and_evidence(tmp_path: Path): """清理策略:超过保留窗口的旧锚定记录移入归档目录,主文件保留窗口;证据不丢失。""" ledger = AnchorLedger("tenant-a", "world-1", ledger_dir=str(tmp_path)) evs = [] for i in range(5): evs.append(_event(i + 1)) ledger.append(evs, reason="test") assert ledger.status(evs)["records"] == 5 r = ledger.archive_old(keep_last=2) assert r["archived"] == 3 assert r["kept"] == 2 assert len(ledger.read()) == 2 archive = Path(r["archive"]) assert archive.exists() archived_lines = archive.read_text(encoding="utf-8").splitlines() assert len(archived_lines) == 3 # 归档记录仍是合法 JSON(分级存储,非删除) for ln in archived_lines: rec = json.loads(ln) assert rec.get("root") and rec.get("count") def test_ledger_archive_under_window_is_noop(tmp_path: Path): """保留窗口内不归档(no-op),主文件不变。""" ledger = AnchorLedger("tenant-a", "world-1", ledger_dir=str(tmp_path)) evs = [] for i in range(3): evs.append(_event(i + 1)) ledger.append(evs, reason="test") r = ledger.archive_old(keep_last=10) assert r["archived"] == 0 assert r["kept"] == 3 assert len(ledger.read()) == 3 def test_mirror_append_uses_cross_process_lock(tmp_path: Path): """镜像 append 经 locked_append 落盘:并发下仍完整(回归保护)。""" m = AuditMirror("tenant-a", "world-1", mirror_dir=str(tmp_path)) threads = [ threading.Thread(target=m.append_event, args=(_event(i + 1),)) for i in range(5) ] for t in threads: t.start() for t in threads: t.join() assert m.count() == 5