diff --git a/internal/events/test/bus_contract_test.go b/internal/events/test/bus_contract_test.go new file mode 100644 index 00000000..ebd1afd2 --- /dev/null +++ b/internal/events/test/bus_contract_test.go @@ -0,0 +1,98 @@ +package test + +// internal/events 的黑盒判据:NewBus → Subscribe → Publish → handler 收到的事件。 +// 只调导出方法,不碰 b.subs、不读源码。 + +import ( + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/events" +) + +// TestPublishDeliversPayload:订阅后发布,handler 必须收到原事件内容。 +func TestPublishDeliversPayload(t *testing.T) { + bus := events.NewBus() + got := make(chan *events.Event, 1) + unsub := bus.Subscribe(events.EventType("contract.test"), func(e *events.Event) { + got <- e + }) + defer unsub() + + bus.Publish(&events.Event{ + Type: events.EventType("contract.test"), + Source: "contract", + Payload: map[string]interface{}{"value": 42}, + }) + select { + case e := <-got: + if e.Source != "contract" { + t.Fatalf("★ Source = %q", e.Source) + } + if e.Payload["value"] != 42 { + t.Fatalf("★ Payload = %v", e.Payload) + } + case <-time.After(2 * time.Second): + t.Fatal("★ 订阅者没收到事件") + } +} + +// TestUnsubscribeStopsDelivery:退订后不得再收到。 +func TestUnsubscribeStopsDelivery(t *testing.T) { + bus := events.NewBus() + n := 0 + unsub := bus.Subscribe(events.EventType("contract.off"), func(e *events.Event) { n++ }) + unsub() + bus.Publish(&events.Event{Type: events.EventType("contract.off")}) + if n != 0 { + t.Fatalf("★ 退订后仍收到 %d 次", n) + } +} + +// TestOnlyMatchingTypeReceives:不同事件类型之间不串台。 +func TestOnlyMatchingTypeReceives(t *testing.T) { + bus := events.NewBus() + a, b := 0, 0 + bus.Subscribe(events.EventType("type.a"), func(e *events.Event) { a++ }) + bus.Subscribe(events.EventType("type.b"), func(e *events.Event) { b++ }) + bus.Publish(&events.Event{Type: events.EventType("type.a")}) + if a != 1 || b != 0 { + t.Fatalf("★ 串台:a=%d b=%d", a, b) + } +} + +// TestPublisherIdentity:Name 为空即内核,非空即插件。 +func TestPublisherIdentity(t *testing.T) { + if !((events.Publisher{}).IsKernel()) { + t.Fatal("★ 空 Name 应判为内核") + } + if (events.Publisher{Name: "qq"}).IsKernel() { + t.Fatal("★ 插件被判成内核") + } +} + +// TestSetGuardRoundTrip:SetGuard → Guard 拿回同一个守卫。 +type stubGuard struct{} + +func (g *stubGuard) AllowPublish(p events.Publisher, et events.EventType) (bool, string) { + return true, "" +} +func (g *stubGuard) AllowSubscribe(p events.Publisher, et events.EventType) (bool, string) { + return true, "" +} +func (g *stubGuard) ScrubPayload(et events.EventType, m map[string]interface{}) map[string]interface{} { + return m +} +func (g *stubGuard) ScrubInbound(et events.EventType, m map[string]interface{}) map[string]interface{} { + return m +} + +func TestSetGuardRoundTrip(t *testing.T) { + bus := events.NewBus() + g := &stubGuard{} + bus.SetGuard(g) + var got events.Guard = bus.Guard() + if got != g { + t.Fatal("★ Guard() 没拿回 SetGuard 设置的守卫") + } +} diff --git a/internal/kstate/test/syncbus_contract_test.go b/internal/kstate/test/syncbus_contract_test.go new file mode 100644 index 00000000..4388cd0b --- /dev/null +++ b/internal/kstate/test/syncbus_contract_test.go @@ -0,0 +1,110 @@ +package test + +// internal/kstate 的黑盒判据:只用 NewSyncBus / Mount / Get / State 的导出方法, +// 输入 → 可观察输出。不读源码、不用 MountForTest。 + +import ( + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/kstate" +) + +// TestSyncBusReadyLifecycle:声明 → 就绪 → IsReady/Snapshot/AllReady/Missing。 +func TestSyncBusReadyLifecycle(t *testing.T) { + b := kstate.NewSyncBus() + b.MarkDeclared([]string{"db", "http", "memory"}) + b.MarkReady("db") + b.MarkReady("http") + if !b.IsReady("db") || !b.IsReady("http") { + t.Fatal("★ 已 MarkReady 的设施报告未就绪") + } + if b.IsReady("memory") { + t.Fatal("★ 未就绪的设施报告就绪") + } + if b.AllReady([]string{"db", "http"}) != true { + t.Fatal("★ AllReady 对全部就绪的集合应返回 true") + } + if b.AllReady([]string{"db", "memory"}) { + t.Fatal("★ AllReady 对含未就绪项的集合返回了 true") + } + if missing := b.Missing([]string{"db", "http", "memory"}); len(missing) != 1 || missing[0] != "memory" { + t.Fatalf("★ Missing = %v,期望 [memory]", missing) + } +} + +// TestSyncBusReadyInOrderIsStable:就绪顺序可复现,重复标记不重复记录。 +func TestSyncBusReadyInOrderIsStable(t *testing.T) { + b := kstate.NewSyncBus() + b.MarkReady("a") + b.MarkReady("a") + b.MarkReady("b") + got := b.ReadyInOrder() + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("★ ReadyInOrder = %v,期望 [a b]", got) + } +} + +// TestSyncBusKnownVsReady:声明过但未就绪 ≠ 未声明。 +func TestSyncBusKnownVsReady(t *testing.T) { + b := kstate.NewSyncBus() + b.MarkDeclared([]string{"db"}) + if !b.Known("db") { + t.Fatal("★ 声明过的设施 Known=false") + } + if b.IsReady("db") { + t.Fatal("★ 只声明未就绪却报告 IsReady") + } + if b.Known("ghost") { + t.Fatal("★ 未声明的设施 Known=true") + } +} + +// TestSyncBusKernelComplete:内核完成标记是可观察输出。 +func TestSyncBusKernelComplete(t *testing.T) { + b := kstate.NewSyncBus() + if b.KernelComplete() { + t.Fatal("★ 初始就报告内核已完成") + } + b.MarkKernelComplete() + if !b.KernelComplete() { + t.Fatal("★ MarkKernelComplete 后仍报告未完成") + } +} + +// TestSyncBusSnapshotIsCopy:改快照不得影响总线。 +func TestSyncBusSnapshotIsCopy(t *testing.T) { + b := kstate.NewSyncBus() + b.MarkReady("db") + snap := b.Snapshot() + if !snap["db"] { + t.Fatal("★ Snapshot 缺 db") + } + snap["db"] = false + snap["ghost"] = true + if !b.IsReady("db") { + t.Fatal("★ 外部改快照影响了总线内部") + } + if b.IsReady("ghost") { + t.Fatal("★ 外部往快照塞值影响了总线内部") + } +} + +// TestMountGet:Mount → Get 拿到同一份状态(模块最核心的输入→输出)。 +func TestMountGet(t *testing.T) { + s := &kstate.State{DataDir: "/contract/tmp"} + kstate.Mount(s) + if got := kstate.Get(); got != s { + t.Fatalf("★ Get() = %v,期望刚 Mount 的那份", got) + } +} + +// TestStateCloseAllInvokesClosers:AddCloser 注册的清理必须被调用一次。 +func TestStateCloseAllInvokesClosers(t *testing.T) { + s := kstate.New() + n := 0 + s.AddCloser(func() { n++ }) + s.CloseAll() + if n != 1 { + t.Fatalf("★ CloseAll 后 closer 调用 %d 次,期望 1", n) + } +} diff --git a/internal/safeclose/test/safeclose_contract_test.go b/internal/safeclose/test/safeclose_contract_test.go new file mode 100644 index 00000000..197028c8 --- /dev/null +++ b/internal/safeclose/test/safeclose_contract_test.go @@ -0,0 +1,78 @@ +package test + +// internal/safeclose 的黑盒判据:只用导出 API,输入 → 可观察输出。 +// 不构造内部状态、不用任何测试专用钩子。 + +import ( + "sync" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/safeclose" +) + +// TestSafeFuncIsIdempotent:同一个返回函数连调多次不得 panic,channel 确已关闭。 +func TestSafeFuncIsIdempotent(t *testing.T) { + ch := make(chan struct{}) + stop := safeclose.SafeFunc(ch) + for i := 0; i < 5; i++ { + stop() + } + select { + case <-ch: + default: + t.Fatal("★ channel 未关闭 —— SafeFunc 的返回函数没生效") + } +} + +// TestSafeReturnsFiredOnce:Safe 的返回值报告「本次是否真关闭」, +// 同一把 Once 只有第一次返回 true。 +func TestSafeReturnsFiredOnce(t *testing.T) { + ch := make(chan struct{}) + var once sync.Once + if !safeclose.Safe(&once, ch) { + t.Fatal("★ 第一次 Safe 应返回 true") + } + if safeclose.Safe(&once, ch) { + t.Fatal("★ 第二次 Safe 仍返回 true —— 幂等语义坏了") + } +} + +// TestSafeRejectsNilInputs:nil once / nil chan 必须安全返回 false。 +func TestSafeRejectsNilInputs(t *testing.T) { + var once sync.Once + if safeclose.Safe[struct{}](&once, nil) { + t.Fatal("★ nil chan 被接受") + } + ch := make(chan struct{}) + if safeclose.Safe[struct{}](nil, ch) { + t.Fatal("★ nil once 被接受") + } + select { + case <-ch: + t.Fatal("★ nil once 却关闭了 channel") + default: + } +} + +// TestConcurrentCloseIsSafe:8 个 goroutine 同时关同一个 channel 不得 panic/死锁。 +func TestConcurrentCloseIsSafe(t *testing.T) { + for round := 0; round < 100; round++ { + ch := make(chan struct{}) + var once sync.Once + done := make(chan struct{}, 8) + for i := 0; i < 8; i++ { + go func() { + safeclose.Safe(&once, ch) + done <- struct{}{} + }() + } + for i := 0; i < 8; i++ { + <-done + } + select { + case <-ch: + default: + t.Fatal("★ 并发结束后 channel 未关闭") + } + } +} diff --git a/internal/security/test/codec_contract_test.go b/internal/security/test/codec_contract_test.go new file mode 100644 index 00000000..8046e762 --- /dev/null +++ b/internal/security/test/codec_contract_test.go @@ -0,0 +1,140 @@ +package test + +// internal/security 的黑盒判据:只用导出构造 + 公开方法,输入 → 输出。 +// 不读源码、不碰 Codec/Store 内部字段、不用任何 ForTest 钩子。 + +import ( + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/security" +) + +func fixedKey(byteVal byte) []byte { + k := make([]byte, 32) + for i := range k { + k[i] = byteVal + } + return k +} + +func newCodec(t *testing.T, key []byte, domain string) *security.Codec { + t.Helper() + c, err := security.NewCodec(key, domain) + if err != nil { + t.Fatalf("NewCodec: %v", err) + } + return c +} + +// TestCodecSealOpenRoundTrip:Seal → Open 得回原文。 +func TestCodecSealOpenRoundTrip(t *testing.T) { + c := newCodec(t, fixedKey(7), "test-domain") + const plain = "sk-super-secret-value-123" + sealed, err := c.Seal(plain) + if err != nil { + t.Fatalf("Seal: %v", err) + } + if sealed == plain { + t.Fatal("★ Seal 返回了原文 —— 没有加密") + } + got, err := c.Open(sealed) + if err != nil { + t.Fatalf("Open: %v", err) + } + if got != plain { + t.Fatalf("★ Open = %q,期望 %q", got, plain) + } +} + +// TestCodecDomainSeparation:不同 domain 的密文互不通用。 +func TestCodecDomainSeparation(t *testing.T) { + a := newCodec(t, fixedKey(7), "domain-a") + b := newCodec(t, fixedKey(7), "domain-b") + sealed, err := a.Seal("payload") + if err != nil { + t.Fatal(err) + } + if _, err := b.Open(sealed); err == nil { + t.Fatal("★ 另一个 domain 能解开密文 —— 域隔离失效") + } +} + +// TestCodecWrongKeyCannotOpen:换钥后旧密文不可读。 +func TestCodecWrongKeyCannotOpen(t *testing.T) { + a := newCodec(t, fixedKey(1), "d") + b := newCodec(t, fixedKey(2), "d") + sealed, err := a.Seal("payload") + if err != nil { + t.Fatal(err) + } + if _, err := b.Open(sealed); err == nil { + t.Fatal("★ 错误密钥能解开密文") + } +} + +// TestCodecEnabledAndDomain:元信息输出。 +func TestCodecEnabledAndDomain(t *testing.T) { + c := newCodec(t, fixedKey(3), "my-domain") + if !c.Enabled() { + t.Fatal("★ 有效密钥下 Enabled()=false") + } + if got := c.Domain(); got != "my-domain" { + t.Fatalf("★ Domain() = %q,期望 my-domain", got) + } + var nilCodec *security.Codec + if nilCodec.Enabled() { + t.Fatal("★ nil Codec 报告 Enabled=true") + } +} + +// TestCodecOldKeysStillDecrypt:轮换后历史密文仍可读。 +func TestCodecOldKeysStillDecrypt(t *testing.T) { + old := fixedKey(4) + cur := fixedKey(5) + oldCodec := newCodec(t, old, "d") + sealed, err := oldCodec.Seal("legacy-value") + if err != nil { + t.Fatal(err) + } + rotated, err := security.NewCodecWithOldKeys(cur, "d", [][]byte{old}) + if err != nil { + t.Fatalf("NewCodecWithOldKeys: %v", err) + } + got, err := rotated.Open(sealed) + if err != nil { + t.Fatalf("★ 轮换后旧密文读不出:%v", err) + } + if got != "legacy-value" { + t.Fatalf("★ 旧密文解出 %q", got) + } +} + +// TestCodecOpenLegacyPlaintextPassthrough:无密文前缀的旧数据原样透传。 +// +// 这是明确的兼容契约:加密是后来加的,库里存在未加密的历史值。 +// 把它们判成错误会让整库读不出来。 +func TestCodecOpenLegacyPlaintextPassthrough(t *testing.T) { + c := newCodec(t, fixedKey(9), "d") + for _, in := range []string{"", "legacy-plain-value", "abc"} { + got, err := c.Open(in) + if err != nil { + t.Fatalf("★ Open(%q) 报错:%v —— 旧明文应原样透传", in, err) + } + if got != in { + t.Fatalf("★ Open(%q) = %q,期望原样返回", in, got) + } + } +} + +// TestCodecOpenRejectsCorruptedCiphertext:带密文前缀但内容坏掉必须报错。 +func TestCodecOpenRejectsCorruptedCiphertext(t *testing.T) { + c := newCodec(t, fixedKey(9), "d") + for _, in := range []string{ + security.EncPrefixV1 + "!!!not-base64!!!", + security.EncPrefixV1, + } { + if _, err := c.Open(in); err == nil { + t.Fatalf("★ Open(%q) 没报错 —— 损坏密文被静默吞下", in) + } + } +}