test: 按模块导出接口重写黑盒判据(批次 2:security/events/safeclose/kstate)

延续批次 1 的形态:只调模块**本来就有**的导出接口,输入 → 可观察输出。
4 个文件全部在 `<module>/test/`,源码目录仍零 `_test.go`。
共 24 条判据,每模块 1 个文件。

## 各模块的接口清单与判据

### internal/security —— NewCodec / Seal / Open / Domain / Enabled

    TestCodecSealOpenRoundTrip           Seal → Open 得回原文;Seal 不返回原文
    TestCodecDomainSeparation            不同 domain 的密文互不通用
    TestCodecWrongKeyCannotOpen          换钥后旧密文不可读
    TestCodecEnabledAndDomain            元信息输出;nil Codec 不报 Enabled
    TestCodecOldKeysStillDecrypt         NewCodecWithOldKeys 轮换后旧密文可读
    TestCodecOpenLegacyPlaintextPassthrough  旧明文原样透传
    TestCodecOpenRejectsCorruptedCiphertext  带密文前缀但内容坏掉必须报错

**判据写错过一次并修正**:第一版把 `Open("not-a-cipher")` 也要求报错,
实测红。查实现(codec.go:138)确认那是**明确的兼容契约** —— 加密是后来加的,
库里存在未加密的历史值,判成错误会让整库读不出来。改成两条:
旧明文透传 / 损坏密文报错。把差异显式写下来。

### internal/events —— NewBus / Subscribe / Publish / SetGuard

    TestPublishDeliversPayload          订阅后发布,handler 收到原 Payload
    TestUnsubscribeStopsDelivery        退订后不再收到
    TestOnlyMatchingTypeReceives        不同事件类型不串台
    TestPublisherIdentity               空 Name = 内核,非空 = 插件
    TestSetGuardRoundTrip               SetGuard → Guard 拿回同一守卫

`Guard` stub 按真实接口签名实现(AllowPublish 返回 `(bool, string)`、
Scrub* 返回 map),不是猜的。

### internal/safeclose —— Safe / SafeFunc

    TestSafeFuncIsIdempotent            连调 5 次不 panic,channel 确已关闭
    TestSafeReturnsFiredOnce            同一把 Once 只有第一次返回 true
    TestSafeRejectsNilInputs            nil once / nil chan 安全返回 false
    TestConcurrentCloseIsSafe           8 goroutine 并发关同一 channel

不用任何 `NewOnceForTest` 之类的测试钩子 —— `sync.Once` 由调用方自己造,
这正是模块本来的公开用法。

### internal/kstate —— NewSyncBus / Mount / Get / State

    TestSyncBusReadyLifecycle           MarkDeclared/MarkReady → IsReady/AllReady/Missing
    TestSyncBusReadyInOrderIsStable     就绪顺序可复现,重复标记不重复记录
    TestSyncBusKnownVsReady             声明过 ≠ 已就绪
    TestSyncBusKernelComplete           内核完成标记可观察
    TestSyncBusSnapshotIsCopy           外部改快照不影响总线
    TestMountGet                        Mount → Get 拿到同一份状态
    TestStateCloseAllInvokesClosers     AddCloser 注册的清理被调用

不用 `MountForTest`(上一轮已删除):直接用 `Mount` + `Get` 断言输出。

## 变异验证(改回旧实现必须红)

    security  Seal 直接返回原文        → ★ Seal 返回了原文 —— 没有加密
    events    退订函数变空操作         → ★ 退订后仍收到 1 次
    kstate    MarkReady 不记 order    → ★ ReadyInOrder = [],期望 [a b]
    safeclose Safe 不用 sync.Once     → panic: close of closed channel

前两条第一版变异因文本形态不匹配**没有变红**,重做后确认判据有效 ——
空变异不算变异验证,这一点按纪律写清楚。

## 验证

    go build ./...                            PASS
    go vet ./...                              PASS
    gofmt -l                                  干净
    python3 .github/scripts/check-ci-gates.py PASS
    go test ./... -count=1                    13 个测试包 / 100 包无测试 / 0 FAIL
    4 个新包 -race                            全绿
    源码目录 _test.go                         0
This commit is contained in:
JianFeeeee
2026-11-09 12:21:13 +08:00
parent 3384cee62a
commit 8dff678640
4 changed files with 426 additions and 0 deletions

View File

@ -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 设置的守卫")
}
}

View File

@ -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)
}
}

View File

@ -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 未关闭")
}
}
}

View File

@ -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)
}
}
}