feat: tamper-evident audit ledger (hash chain) + dual-column cost view

- Hash-chain ledger (cloud/ledger.py): prev_hash/entry_hash per record,
  SQLite append-only triggers, UNIQUE prev/entry hash with fail-closed
  retry at the transaction boundary; verify/export (incl. expected-head
  check for tail truncation); legacy audit_log rows marked pre-chain via
  a single ledger_genesis marker, never rewritten.
- AuthStore.audit() writes audit_log + chain entry in one transaction.
- Track-3 interface: audit_verdict / audit_pushback_round /
  audit_rebuild / third-failure assessment / test-change audit /
  meta-review / rebuild-notice entries; per-(module, contract-version)
  failure counts derived from the ledger (pass or completed rebuild
  resets; total > 3 triggers rebuild); acceptance-triggered rebuilds
  require a confirmed user notice.
- Admin read-only endpoints: GET /admin/audit-ledger/verify|export.
- Cost: dual_column_view (priced_usd_micros + billing note, wording
  reused verbatim from PR #212) wired into project/task cost endpoints;
  by-model and per-creator reporting stay in #212, not duplicated here.
- PG schema + migration lists updated for audit_ledger (incl. sequence
  setval alignment).
This commit is contained in:
host committed 2026-11-03 02:49:25 +00:00
1 parent c0ab995ccc
commit eeb10118d6
7 files changed
+2121 -10

No files matched your search

+596
View File
@@ -0,0 +1,596 @@
"""防篡改账本(哈希链)+ 成本双列视图单测。
不联网、不跑真实模型:账本走临时 SQLite 文件,篡改/删除
用 SQL 直写模拟(先 DROP 触发器,模拟有 DDL 权限的攻击者;
触发器本身另有单测),服务端点走本地临时端口。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.cost import CostStore, dual_column_view, price_usd_micros
from cloud.ledger import (
GENESIS_HASH, LedgerStore, append_pushback_round,
audit_timeline, export_chain, list_pushback_rounds,
module_failure_counts, verify_chain, verify_export)
from cloud.server import create_server, init_db
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.cost import CostStore, dual_column_view, price_usd_micros
from cloud.ledger import (
GENESIS_HASH, LedgerStore, append_pushback_round,
audit_timeline, export_chain, list_pushback_rounds,
module_failure_counts, verify_chain, verify_export)
from cloud.server import create_server, init_db
def _req(port, method, path, key=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
hdrs = {}
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
class TestLedgerChain(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = LedgerStore(os.path.join(self.tmp.name, "led.db"))
def tearDown(self):
self.store.close()
self.tmp.cleanup()
def test_append_and_verify_ok(self):
self.store.append("u1", "p1", "create_task", "t1")
self.store.append("u1", "p1", "approve_merge", "t1")
result = self.store.verify()
self.assertTrue(result["ok"])
self.assertEqual(result["count"], 2)
self.assertEqual(result["failures"], [])
self.assertNotEqual(result["head_hash"], GENESIS_HASH)
def test_append_only_interface(self):
# 接口层面即只追加:没有修改/删除方法(test_auth 同款断言)。
self.assertFalse(hasattr(LedgerStore, "delete_entry"))
self.assertFalse(hasattr(LedgerStore, "update_entry"))
self.assertFalse(hasattr(LedgerStore, "delete"))
self.assertFalse(hasattr(LedgerStore, "update"))
def test_sqlite_triggers_block_update_and_delete(self):
self.store.append("u1", "p1", "create_task", "t1")
with self.assertRaises(Exception):
self.store.conn.execute(
"UPDATE audit_ledger SET detail='x' WHERE id=1")
with self.assertRaises(Exception):
self.store.conn.execute(
"DELETE FROM audit_ledger WHERE id=1")
self.store.conn.rollback()
self.assertTrue(self.store.verify()["ok"])
def test_tamper_detail_fails_verify(self):
self.store.append("u1", "p1", "create_task", "t1")
self.store.append("u1", "p1", "approve_merge", "t1")
# 模拟有 DDL 权限的攻击者:先卸触发器再改历史。
self.store.conn.execute(
"DROP TRIGGER IF EXISTS audit_ledger_no_update")
self.store.conn.execute(
"UPDATE audit_ledger SET detail='forged' WHERE id=1")
self.store.conn.commit()
result = self.store.verify()
self.assertFalse(result["ok"])
self.assertTrue(any("tampered" in f for f in result["failures"]))
def test_delete_middle_breaks_chain(self):
for i in range(3):
self.store.append("u1", "p1", "act-%d" % i, "d%d" % i)
self.store.conn.execute(
"DROP TRIGGER IF EXISTS audit_ledger_no_delete")
self.store.conn.execute("DELETE FROM audit_ledger WHERE id=2")
self.store.conn.commit()
result = self.store.verify()
self.assertFalse(result["ok"])
self.assertTrue(any("broken" in f for f in result["failures"]))
def test_delete_tail_detected_by_old_head(self):
for i in range(3):
self.store.append("u1", "p1", "act-%d" % i, "d%d" % i)
old_export = self.store.export()
old_head = old_export["head_hash"]
self.store.conn.execute(
"DROP TRIGGER IF EXISTS audit_ledger_no_delete")
self.store.conn.execute(
"DELETE FROM audit_ledger WHERE id=3")
self.store.conn.commit()
# 截断后的链自洽(固有局限,如实断言),但旧头对不上。
self.assertTrue(self.store.verify()["ok"])
self.assertFalse(
self.store.verify(expected_head=old_head)["ok"])
self.assertFalse(
verify_export(self.store.export(),
expected_head=old_head)["ok"])
def test_export_round_trip_and_tampered_export(self):
self.store.append("u1", "p1", "create_task", "t1")
self.store.append("u1", "p1", "approve_merge", "t1")
data = self.store.export()
self.assertTrue(verify_export(data)["ok"])
self.assertEqual(data["count"], 2)
data["entries"][0]["detail"] = "forged"
self.assertFalse(verify_export(data)["ok"])
data2 = self.store.export()
data2["head_hash"] = "0" * 64
self.assertFalse(verify_export(data2)["ok"])
def test_legacy_migration_marks_pre_chain_without_rewrite(self):
# 老 audit_log 行(直接 INSERT,模拟链上线前的历史)。
conn = sqlite3.connect(os.path.join(self.tmp.name, "led.db"))
try:
conn.execute(
"CREATE TABLE IF NOT EXISTS audit_log("
"id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT,"
" project_id TEXT, action TEXT, detail TEXT,"
" created_at REAL)")
conn.execute(
"INSERT INTO audit_log(user_id, project_id, action,"
" detail, created_at) VALUES(?,?,?,?,?)",
("u1", "p1", "create_task", "t0", 1000.0))
conn.execute(
"INSERT INTO audit_log(user_id, project_id, action,"
" detail, created_at) VALUES(?,?,?,?,?)",
("u1", "p1", "approve_merge", "t0", 1001.0))
conn.commit()
finally:
conn.close()
# 链已空但老表有行:verify 必须 fail-closed,不认账。
fresh = LedgerStore(os.path.join(self.tmp.name, "led.db"))
try:
result = fresh.verify()
self.assertFalse(result["ok"])
self.assertTrue(any("legacy_not_migrated" in f
for f in result["failures"]))
migrated = fresh.migrate_legacy()
self.assertTrue(migrated["migrated"])
self.assertEqual(
migrated["marker"]["action"], "ledger_genesis")
after = fresh.verify()
self.assertTrue(after["ok"])
self.assertIsNotNone(after["genesis_marker"])
self.assertEqual(
after["genesis_marker"]["legacy_audit_rows"], 2)
# 迁移后链已启动,再跑迁移必须拒绝,不补不改。
self.assertFalse(fresh.migrate_legacy()["migrated"])
# 老行原样未动:仍 2 行、无哈希列。
raw = sqlite3.connect(os.path.join(self.tmp.name, "led.db"))
try:
rows = raw.execute(
"SELECT COUNT(*) FROM audit_log").fetchone()
cols = [c[1] for c in raw.execute(
"PRAGMA table_info(audit_log)").fetchall()]
finally:
raw.close()
self.assertEqual(rows[0], 2)
self.assertNotIn("entry_hash", cols)
finally:
fresh.close()
def test_auth_audit_writes_chain_same_transaction(self):
db_path = os.path.join(self.tmp.name, "auth.db")
auth = AuthStore(db_path)
try:
owner_id, _key = auth.create_user("owner")
auth.create_project("p1", owner_id)
auth.audit(owner_id, "p1", "create_task", "task-1")
auth.audit(owner_id, "p1", "approve_merge", "task-1")
self.assertEqual(len(auth.list_audit("p1")), 2)
finally:
auth.conn.close()
led = LedgerStore(db_path)
try:
result = led.verify()
self.assertTrue(result["ok"])
self.assertEqual(result["count"], 2)
actions = [e["action"] for e in
led.export()["entries"]]
self.assertEqual(actions, ["create_task", "approve_merge"])
finally:
led.close()
class TestPushbackAndRebuild(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = LedgerStore(os.path.join(self.tmp.name, "led.db"))
def tearDown(self):
self.store.close()
self.tmp.cleanup()
def _verdict(self, verdict, source, task="task-1"):
return self.store.append_audit_verdict(
task, "p1", "mod-a", "v1", verdict, source,
failures=["f1"] if verdict == "fail" else [])
def test_failure_counts_split_sources_and_reset_on_pass(self):
self._verdict("fail", "audit")
self._verdict("fail", "acceptance")
counts = self.store.module_failure_counts("mod-a", "v1")
self.assertEqual(counts["audit_fails"], 1)
self.assertEqual(counts["acceptance_fails"], 1)
self.assertEqual(counts["total_fails"], 2)
self.assertFalse(counts["rebuild_triggered"])
self._verdict("pass", "audit")
counts = self.store.module_failure_counts("mod-a", "v1")
self.assertEqual(counts["total_fails"], 0)
def test_contract_version_change_restarts_count(self):
self._verdict("fail", "audit")
self._verdict("fail", "audit")
self.assertEqual(
self.store.module_failure_counts("mod-a", "v1")["total_fails"],
2)
self.assertEqual(
self.store.module_failure_counts("mod-a", "v2")["total_fails"],
0)
def test_rebuild_trigger_and_rebuild_resets_baseline(self):
for source in ("audit", "acceptance", "audit"):
self._verdict("fail", source)
counts = self.store.module_failure_counts("mod-a", "v1")
self.assertEqual(counts["total_fails"], 3)
self.assertFalse(counts["rebuild_triggered"])
self._verdict("fail", "audit") # 第 4 次:推倒触发点
counts = self.store.module_failure_counts("mod-a", "v1")
self.assertTrue(counts["rebuild_triggered"])
self.store.append_rebuild_decision(
"task-1", "p1", "mod-a", "v1", 1,
{"commit": "abc123"}, root_cause="implementation",
status="decided", prior_failure_total=4)
with self.assertRaises(ValueError):
# rebuilt 必须带新实现引用。
self.store.append_rebuild_decision(
"task-1", "p1", "mod-a", "v1", 1,
{"commit": "abc123"}, root_cause="implementation",
status="rebuilt")
self.store.append_rebuild_decision(
"task-1", "p1", "mod-a", "v1", 1,
{"commit": "abc123"}, new_impl_ref={"commit": "def456"},
root_cause="implementation", status="rebuilt",
prior_failure_total=4)
counts = self.store.module_failure_counts("mod-a", "v1")
self.assertEqual(counts["total_fails"], 0)
# 重做后再超 3 次:同样触发(不再自动重开是升级语义,
# 账本只负责计数与触发信号,不替第 3 轨做升级决策)。
for _ in range(4):
self._verdict("fail", "audit")
self.assertTrue(
self.store.module_failure_counts(
"mod-a", "v1")["rebuild_triggered"])
def test_contract_design_root_cause_recorded_not_rebuild(self):
entry = self.store.append_rebuild_decision(
"task-1", "p1", "mod-a", "v1", 1,
{"tag": "v1-old"}, root_cause="contract_design",
status="decided")
self.assertEqual(entry["action"], "audit_rebuild")
with self.assertRaises(ValueError):
self.store.append_rebuild_decision(
"task-1", "p1", "mod-a", "v1", 2,
{"commit": "x"}, root_cause="something_else")
def test_timeline_reconstructs_full_path_without_erasure(self):
# fail → 推回 1-3 轮 → 第 4 次 fail → 推倒 → 重做 → 再审 pass
self._verdict("fail", "audit")
for round_no in (1, 2, 3):
self.store.append_pushback_round(
"task-1", "p1", round_no, ["f1"],
{"failures": ["f1"]},
module_id="mod-a", contract_version="v1",
failure_source="audit")
self._verdict("fail", "audit")
# 此时累计:首 fail + 3 轮后各 1 fail = 4 次,已触发推倒。
self.assertTrue(
self.store.module_failure_counts(
"mod-a", "v1")["rebuild_triggered"])
self.store.append_rebuild_decision(
"task-1", "p1", "mod-a", "v1", 1,
{"commit": "old"}, new_impl_ref={"commit": "new"},
root_cause="implementation", status="rebuilt",
prior_failure_total=4)
self._verdict("pass", "audit")
timeline = self.store.audit_timeline(task_id="task-1")
actions = [e["action"] for e in timeline["entries"]]
self.assertEqual(actions.count("audit_verdict"), 5)
self.assertEqual(actions.count("audit_pushback_round"), 3)
self.assertEqual(actions.count("audit_rebuild"), 1)
# 最终 pass 在末尾,前面 4 次 fail 一个不少。
self.assertEqual(timeline["entries"][-1]["parsed"]["verdict"],
"pass")
self.assertEqual(timeline["summary"]["verdict_fail"], 4)
self.assertEqual(timeline["summary"]["verdict_pass"], 1)
self.assertEqual(timeline["summary"]["rebuilds_completed"], 1)
self.assertTrue(self.store.verify()["ok"])
def test_pushback_round_over_3_flagged_in_timeline(self):
self.store.append_pushback_round(
"task-1", "p1", 4, ["f1"], {"failures": ["f1"]},
module_id="mod-a", contract_version="v1")
timeline = self.store.audit_timeline(task_id="task-1")
self.assertEqual(len(timeline["summary"]["anomalies"]), 1)
def test_pushback_rounds_list_and_validation(self):
append_pushback_round(
self.store.conn, "task-1", "p1", 1, ["f1"],
{"failures": ["f1"], "evidence": "e"})
self.store.conn.commit()
rounds = list_pushback_rounds(self.store.conn, task_id="task-1")
self.assertEqual(len(rounds), 1)
self.assertEqual(rounds[0]["pushback"]["round_no"], 1)
with self.assertRaises(ValueError):
append_pushback_round(
self.store.conn, "task-1", "p1", 1, [],
{"failures": []})
class TestTightenedCaliber(unittest.TestCase):
"""三条收紧:第三次失败判定、保留资产、测试单审、
审计的审计、重开前用户确认。"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = LedgerStore(os.path.join(self.tmp.name, "led.db"))
def tearDown(self):
self.store.close()
self.tmp.cleanup()
def _verdict(self, verdict, source="audit", task="task-1"):
return self.store.append_audit_verdict(
task, "p1", "mod-a", "v1", verdict, source,
failures=["f1"] if verdict == "fail" else [])
def test_third_failure_assessment_kinds(self):
entry = self.store.append_third_failure_assessment(
"task-1", "p1", "mod-a", "v1", "early_rebuild",
{"audit_conclusion": "structural",
"failure_items": ["f1"],
"diff_scope": "mod-a/*",
"passed_items": ["a1"],
"continuation_counting": "extra rounds count as fails"})
self.assertEqual(entry["action"],
"audit_third_failure_assessment")
cont = self.store.append_third_failure_assessment(
"task-1", "p1", "mod-a", "v1", "continuation_round",
{"audit_conclusion": "nearly done"})
parsed = json.loads(cont["detail"])
self.assertEqual(parsed["max_extra_rounds"], 1)
with self.assertRaises(ValueError):
self.store.append_third_failure_assessment(
"task-1", "p1", "mod-a", "v1", "early_rebuild",
{"audit_conclusion": "x"}, max_extra_rounds=1)
with self.assertRaises(ValueError):
self.store.append_third_failure_assessment(
"task-1", "p1", "mod-a", "v1", "continuation_round",
{})
timeline = self.store.audit_timeline(task_id="task-1")
self.assertEqual(timeline["summary"]["third_failure_assessments"],
2)
def test_rebuild_retained_assets(self):
entry = self.store.append_rebuild_decision(
"task-1", "p1", "mod-a", "v1", 1,
{"commit": "old"}, new_impl_ref={"commit": "new"},
root_cause="implementation", status="rebuilt",
retained_assets={
"old_test_set_ref": "tests/mod-a@old",
"passed_acceptance_items": ["a1", "a2"],
"old_new_impl_mapping": {"old": "new"},
"old_tests_status": "pass"})
parsed = json.loads(entry["detail"])
self.assertEqual(parsed["retained_assets"]["old_tests_status"],
"pass")
with self.assertRaises(ValueError):
self.store.append_rebuild_decision(
"task-1", "p1", "mod-a", "v1", 2,
{"commit": "old"}, retained_assets={
"old_tests_status": "mostly"})
def test_test_change_audit_gate(self):
# 纯新增不走重新冻结门。
self.store.append_test_change_audit(
"task-1", "p1", "mod-a", "v1",
{"added": ["test_new"]}, auditor="aud-1",
contract_refrozen=False)
# 删改不走重新冻结:拒绝写入。
with self.assertRaises(ValueError):
self.store.append_test_change_audit(
"task-1", "p1", "mod-a", "v1",
{"deleted": ["test_old"]}, auditor="aud-1",
contract_refrozen=False)
# 删改 + 重新冻结 + 引用:通过。
entry = self.store.append_test_change_audit(
"task-1", "p1", "mod-a", "v1",
{"modified": ["test_m"], "deleted": ["test_old"]},
auditor="aud-1", contract_refrozen=True,
refreeze_ref="v2", reason="contract change")
self.assertEqual(entry["action"], "audit_test_change")
with self.assertRaises(ValueError):
# refrozen 时引用必填。
self.store.append_test_change_audit(
"task-1", "p1", "mod-a", "v1",
{"modified": ["test_m"]}, auditor="aud-1",
contract_refrozen=True)
def test_meta_review_correction_changes_effective_count(self):
first = self._verdict("fail")
self._verdict("fail")
third = self._verdict("fail")
self.assertEqual(
self.store.module_failure_counts(
"mod-a", "v1")["total_fails"], 3)
# 纠正最新一条 fail → pass:计数清零(pass 即基线)。
self.store.append_audit_of_audit(
"task-1", "p1", "mod-a", "v1", third["id"], "fail",
"pass", True, reviewer="meta-1",
findings=["evidence misread"], triggered_process="none")
self.assertEqual(
self.store.module_failure_counts(
"mod-a", "v1")["total_fails"], 0)
# 但链上原 fail 与纠正都保留,时间线不抹。
timeline = self.store.audit_timeline(task_id="task-1")
self.assertEqual(timeline["summary"]["verdict_fail"], 3)
self.assertEqual(timeline["summary"]["meta_reviews"], 1)
self.assertTrue(self.store.verify()["ok"])
def test_meta_review_first_fail_correction_semantics(self):
first = self._verdict("fail")
self._verdict("fail")
self._verdict("fail")
# 纠正第一条 fail → pass:pass 清零后,后两条 fail 重计为 2。
self.store.append_audit_of_audit(
"task-1", "p1", "mod-a", "v1", first["id"], "fail",
"pass", True, reviewer="meta-1",
findings=["wrong item"], triggered_process="re-review")
self.assertEqual(
self.store.module_failure_counts(
"mod-a", "v1")["total_fails"], 2)
def test_acceptance_rebuild_requires_confirmed_notice(self):
# 无通知:拒绝。
with self.assertRaises(ValueError):
self.store.append_rebuild_decision(
"task-1", "p1", "mod-a", "v1", 1,
{"commit": "old"}, root_cause="implementation",
trigger_source="acceptance")
# 仅 pending 通知:仍拒绝。
self.store.append_rebuild_notice(
"task-1", "p1", "mod-a", "v1", 1,
"new direction: rebuild mod-a", notified_party="user-1")
with self.assertRaises(ValueError):
self.store.append_rebuild_decision(
"task-1", "p1", "mod-a", "v1", 1,
{"commit": "old"}, root_cause="implementation",
trigger_source="acceptance")
# confirmed 后:通过;audit 触发本来就不走此门。
self.store.append_rebuild_notice(
"task-1", "p1", "mod-a", "v1", 1,
"new direction: rebuild mod-a", notified_party="user-1",
confirmation_status="confirmed", confirmed_by="user-1")
entry = self.store.append_rebuild_decision(
"task-1", "p1", "mod-a", "v1", 1,
{"commit": "old"}, root_cause="implementation",
trigger_source="acceptance")
self.assertEqual(entry["action"], "audit_rebuild")
self.store.append_rebuild_decision(
"task-1", "p1", "mod-a", "v1", 2,
{"commit": "old"}, root_cause="implementation",
trigger_source="audit")
timeline = self.store.audit_timeline(task_id="task-1")
self.assertEqual(timeline["summary"]["rebuild_notices"], 2)
class TestCostDualColumn(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = CostStore(os.path.join(self.tmp.name, "cost.db"))
def tearDown(self):
try:
self.store._conn.close()
except Exception:
pass
self.tmp.cleanup()
def test_unpriced_never_priced_as_zero(self):
priced = price_usd_micros("gpt-6.1-sol", 1000000, 0, 0)
self.assertIsNotNone(priced)
self.store.record("t1", "p1", "gpt-6.1-sol",
input_tokens=1000000, output_tokens=0,
usd_micros=priced, source="actual")
# 无价模型:usd_micros=None,绝不写成 0。
self.store.record("t1", "p1", "unpriced-model",
input_tokens=5000, output_tokens=0,
usd_micros=None, source="actual")
view = dual_column_view(self.store.report_by_task("t1"))
self.assertEqual(view["priced_usd_micros"], priced)
self.assertEqual(view["usd_micros"], priced)
self.assertEqual(view["unpriced_actual_tokens"], 5000)
self.assertIn("billing_note", view)
self.assertIn("不等于免费", view["billing_note"])
class TestLedgerServerEndpoints(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "srv.db")
auth = AuthStore(self.db_path)
try:
self.admin_id, self.admin_key = auth.create_user("admin")
self.user_id, self.user_key = auth.create_user("plain")
auth.create_project("p1", self.admin_id)
finally:
auth.conn.close()
init_db(self.db_path)
self._old_admins = os.environ.get("CODEX_GLOBAL_ADMINS")
os.environ["CODEX_GLOBAL_ADMINS"] = self.admin_id
self.server = create_server(self.db_path, port=0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self):
try:
self.server.shutdown()
self.server.server_close()
except Exception:
pass
if self._old_admins is None:
os.environ.pop("CODEX_GLOBAL_ADMINS", None)
else:
os.environ["CODEX_GLOBAL_ADMINS"] = self._old_admins
self.tmp.cleanup()
def test_verify_and_export_admin_only(self):
status, body = _req(self.port, "GET",
"/admin/audit-ledger/verify",
key=self.admin_key)
self.assertEqual(status, 200)
self.assertTrue(body["ok"])
status, body = _req(self.port, "GET",
"/admin/audit-ledger/export?limit=10",
key=self.admin_key)
self.assertEqual(status, 200)
self.assertEqual(body["version"], 1)
status, _body = _req(self.port, "GET",
"/admin/audit-ledger/verify",
key=self.user_key)
self.assertEqual(status, 403)
status, _body = _req(self.port, "GET",
"/admin/audit-ledger/export?limit=xx",
key=self.admin_key)
self.assertEqual(status, 400)
if __name__ == "__main__":
unittest.main()