- 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).
597 lines
26 KiB
Python
597 lines
26 KiB
Python
"""防篡改账本(哈希链)+ 成本双列视图单测。
|
||
|
||
不联网、不跑真实模型:账本走临时 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()
|