feat(#67): 配置台↔内核配置推送契约(JSON manifest+SHA256 校验和+幂等推送/撤回)
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""配置推送契约测试(issue #67)。
|
||||
|
||||
覆盖:
|
||||
1. manifest 构建(版本/快照/校验和/来源);
|
||||
2. checksum 稳定性 + 完整性校验(verify);
|
||||
3. 推送幂等(同版本不重复写文件、返回原 manifest);
|
||||
4. force 强制重推;
|
||||
5. retract 撤回 + 重新推送;
|
||||
6. 模拟传输损坏(篡改 snapshot → verify 失败);
|
||||
7. 推送日志与查询。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import _bootstrap # noqa: F401
|
||||
|
||||
from template_console.config_store import ConfigKind, ConfigStore # noqa: E402
|
||||
from template_console.push_channel import ( # noqa: E402
|
||||
PushChannel,
|
||||
PushManifest,
|
||||
PushRecord,
|
||||
checksum,
|
||||
)
|
||||
from template_console.release import Release, ReleaseManager # noqa: E402
|
||||
|
||||
|
||||
def _make_release(version: str = "1.0.0") -> Release:
|
||||
"""构造一个带快照的 Release(不走文件系统,直接内存构造)。"""
|
||||
snap = {
|
||||
"schema_version": 1,
|
||||
"captured_at": "2026-01-01T00:00:00Z",
|
||||
"kinds": {"model_param": [
|
||||
{"key": "lr", "value": 0.001, "kind": "model_param",
|
||||
"meaning": "学习率", "updated_by": "li", "reason": "init",
|
||||
"updated_at": "2026-01-01T00:00:00Z"}]},
|
||||
}
|
||||
return Release(version=version, created_at="2026-01-01T00:00:00Z",
|
||||
snapshot=snap, released_by="admin", changelog="t", reason="r")
|
||||
|
||||
|
||||
class _Tmp:
|
||||
def __init__(self):
|
||||
self._tmp = tempfile.mkdtemp()
|
||||
self.inbox = os.path.join(self._tmp, "inbox")
|
||||
|
||||
def cleanup(self):
|
||||
import shutil
|
||||
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||
|
||||
|
||||
class ChecksumTest(unittest.TestCase):
|
||||
"""校验和稳定性 + 完整性。"""
|
||||
|
||||
def test_checksum_stable(self):
|
||||
snap = {"kinds": {"a": [1, 2, 3]}}
|
||||
self.assertEqual(checksum(snap), checksum(snap))
|
||||
|
||||
def test_checksum_key_order_independent(self):
|
||||
# 键序不影响校验和(规范序列化)
|
||||
a = checksum({"x": 1, "y": 2})
|
||||
b = checksum({"y": 2, "x": 1})
|
||||
self.assertEqual(a, b)
|
||||
|
||||
def test_checksum_changes_on_value_change(self):
|
||||
self.assertNotEqual(checksum({"v": 1}), checksum({"v": 2}))
|
||||
|
||||
def test_checksum_is_sha256_hex(self):
|
||||
cs = checksum({"v": 1})
|
||||
self.assertEqual(len(cs), 64)
|
||||
self.assertTrue(all(c in "0123456789abcdef" for c in cs))
|
||||
|
||||
|
||||
class ManifestTest(unittest.TestCase):
|
||||
"""manifest 构建。"""
|
||||
|
||||
def test_build_manifest_has_checksum(self):
|
||||
ch = PushChannel(inbox=tempfile.mkdtemp())
|
||||
rel = _make_release()
|
||||
m = ch.build_manifest(rel)
|
||||
self.assertEqual(m.version, "1.0.0")
|
||||
self.assertTrue(m.checksum)
|
||||
self.assertEqual(m.source, "template-console")
|
||||
self.assertTrue(m.generated_at)
|
||||
|
||||
def test_manifest_roundtrip(self):
|
||||
m = PushManifest(version="1.0.0", snapshot={"a": 1},
|
||||
checksum=checksum({"a": 1}), generated_at="t")
|
||||
text = m.to_json()
|
||||
m2 = PushManifest.from_dict(json.loads(text))
|
||||
self.assertEqual(m2.version, "1.0.0")
|
||||
self.assertEqual(m2.checksum, m.checksum)
|
||||
|
||||
|
||||
class PushIdempotencyTest(unittest.TestCase):
|
||||
"""推送幂等。"""
|
||||
|
||||
def setUp(self):
|
||||
self.ctx = _Tmp()
|
||||
self.ch = PushChannel(inbox=self.ctx.inbox)
|
||||
self.rel = _make_release()
|
||||
|
||||
def tearDown(self):
|
||||
self.ctx.cleanup()
|
||||
|
||||
def test_push_writes_manifest_file(self):
|
||||
self.ch.push(self.rel, pushed_by="admin")
|
||||
path = os.path.join(self.ctx.inbox, "manifest-1.0.0.json")
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
|
||||
def test_push_is_idempotent(self):
|
||||
m1 = self.ch.push(self.rel, pushed_by="a")
|
||||
m2 = self.ch.push(self.rel, pushed_by="b") # 重复推送
|
||||
# 同版本返回同一 manifest(校验和一致)
|
||||
self.assertEqual(m1.checksum, m2.checksum)
|
||||
# 推送日志只有一条记录
|
||||
self.assertEqual(len(self.ch.pushed_versions()), 1)
|
||||
|
||||
def test_force_overrides_idempotency(self):
|
||||
self.ch.push(self.rel, pushed_by="a")
|
||||
before = self.ch.pushed_versions()[0]["pushed_at"]
|
||||
# force 重推(时间戳可能更新)
|
||||
self.ch.push(self.rel, pushed_by="b", force=True)
|
||||
records = self.ch.pushed_versions()
|
||||
self.assertEqual(len(records), 1)
|
||||
self.assertEqual(records[0]["pushed_by"], "b")
|
||||
|
||||
def test_push_log_records_pushed_by(self):
|
||||
self.ch.push(self.rel, pushed_by="admin_zhang")
|
||||
rec = self.ch.pushed_versions()[0]
|
||||
self.assertEqual(rec["pushed_by"], "admin_zhang")
|
||||
self.assertEqual(rec["status"], "pushed")
|
||||
|
||||
|
||||
class RetractTest(unittest.TestCase):
|
||||
"""撤回 + 重新推送。"""
|
||||
|
||||
def setUp(self):
|
||||
self.ctx = _Tmp()
|
||||
self.ch = PushChannel(inbox=self.ctx.inbox)
|
||||
self.rel = _make_release()
|
||||
|
||||
def tearDown(self):
|
||||
self.ctx.cleanup()
|
||||
|
||||
def test_retract_marks_status(self):
|
||||
self.ch.push(self.rel)
|
||||
self.assertTrue(self.ch.retract("1.0.0", by="admin", reason="有问题"))
|
||||
rec = self.ch.pushed_versions()[0]
|
||||
self.assertEqual(rec["status"], "retracted")
|
||||
|
||||
def test_retract_unknown_returns_false(self):
|
||||
self.assertFalse(self.ch.retract("9.9.9"))
|
||||
|
||||
def test_retract_allows_repush(self):
|
||||
self.ch.push(self.rel)
|
||||
self.ch.retract("1.0.0")
|
||||
# 撤回后可重新推送(幂等解除)
|
||||
m = self.ch.push(self.rel, pushed_by="admin2")
|
||||
rec = self.ch.pushed_versions()[0]
|
||||
self.assertEqual(rec["status"], "pushed")
|
||||
self.assertEqual(rec["pushed_by"], "admin2")
|
||||
|
||||
|
||||
class VerifyTest(unittest.TestCase):
|
||||
"""完整性校验。"""
|
||||
|
||||
def test_verify_valid_manifest(self):
|
||||
ch = PushChannel(inbox=tempfile.mkdtemp())
|
||||
m = ch.build_manifest(_make_release())
|
||||
self.assertTrue(PushChannel.verify(m))
|
||||
|
||||
def test_verify_tampered_snapshot_fails(self):
|
||||
ch = PushChannel(inbox=tempfile.mkdtemp())
|
||||
m = ch.build_manifest(_make_release())
|
||||
# 篡改 snapshot 但不改 checksum → 校验失败
|
||||
m.snapshot["kinds"]["model_param"][0]["value"] = 0.999
|
||||
self.assertFalse(PushChannel.verify(m))
|
||||
|
||||
def test_verify_payload_dict(self):
|
||||
ch = PushChannel(inbox=tempfile.mkdtemp())
|
||||
m = ch.build_manifest(_make_release())
|
||||
self.assertTrue(PushChannel.verify_payload(m.to_dict()))
|
||||
|
||||
def test_verify_payload_bad_dict(self):
|
||||
self.assertFalse(PushChannel.verify_payload({"nope": 1}))
|
||||
|
||||
|
||||
class IntegrationTest(unittest.TestCase):
|
||||
"""端到端:store → publish → push → verify。"""
|
||||
|
||||
def test_store_publish_push_flow(self):
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
store = ConfigStore(tmp)
|
||||
store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
|
||||
rm = ReleaseManager(store)
|
||||
rel = rm.publish("1.0.0", released_by="admin", changelog="首发")
|
||||
inbox = os.path.join(tmp, "inbox")
|
||||
ch = PushChannel(inbox=inbox)
|
||||
m = ch.push(rel, pushed_by="admin")
|
||||
# 内核侧校验通过
|
||||
self.assertTrue(PushChannel.verify(m))
|
||||
self.assertEqual(len(ch.pushed_versions()), 1)
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user