package main import ( "context" "os" "path/filepath" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // wireTestChatter / wireTestSessions 是构造真 *channelbridge.Bridge 用的最小桩: // 本文件的用例只关心「白名单播种的顺序」,不跑消息流水线,所以两个接口各给一个空实现。 type wireTestChatter struct{} func (wireTestChatter) Run(context.Context, string, []model.Message) (*loop.Result, error) { return &loop.Result{}, nil } type wireTestSessions struct{} func (wireTestSessions) GetOrCreate(context.Context, adapter.UnifiedMessage, *user.OwnerProfile) (session.Session, error) { return session.Session{}, nil } // newTestRuntimeChannels 造一个指向临时 channels.json 的运行时渠道视图,外加一个 Accounts // 白名单为空的活主人档案——这样断言命中的就只可能是「每渠道管理员层」这一条来源。 func newTestRuntimeChannels(t *testing.T, bridge *channelbridge.Bridge) (*runtimeChannels, *user.OwnerProfile, string) { t.Helper() path := filepath.Join(t.TempDir(), "channels.json") profile := &user.OwnerProfile{ID: "owner"} rc := &runtimeChannels{path: path, bridge: bridge, owner: profile, log: testLog()} return rc, profile, path } func writeTestChannels(t *testing.T, path, body string) { t.Helper() if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("写 channels.json 失败: %v", err) } } // 裁决 06.4.2:文件里声明的 adminIds 必须经 Load 灌进活主人档案并成为判主依据。 func TestRuntimeChannelsLoadSeedsPlatformAdmins(t *testing.T) { rc, profile, path := newTestRuntimeChannels(t, nil) writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"adminIds":["admin-1"," admin-1 ",""]}]}`) rc.Load() if !profile.IsOwner("kook", "admin-1") { t.Fatal("Load 后 kook:admin-1 应被判为主人(adminIds 未生效)") } if profile.IsOwner("kook", "someone-else") { t.Fatal("未声明的账号不应被判为主人") } if profile.IsOwner("qq_obv11", "admin-1") { t.Fatal("管理员层必须按平台隔离:kook 声明的 id 不应在别的平台生效") } } // 裁决 06.4.2:整体替换语义——热重载后文件里删掉的管理员 id 必须立即失效,不残留悬挂管理员。 func TestRuntimeChannelsLoadReplacesRemovedAdmin(t *testing.T) { rc, profile, path := newTestRuntimeChannels(t, nil) writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"adminIds":["admin-1","admin-2"]}]}`) rc.Load() if !profile.IsOwner("kook", "admin-2") { t.Fatal("首次 Load 后 kook:admin-2 应被判为主人") } writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"}}]}`) rc.Load() if profile.IsOwner("kook", "admin-2") || profile.IsOwner("kook", "admin-1") { t.Fatal("重载后已从配置移除的管理员 id 仍然是主人(整体替换语义失效)") } } // 裁决 06.4.2:Reload 必须先把白名单灌进活档案、再委托 bridge 重载适配器——面板一次保存 // (adminIds + 凭据)两条路径同时生效;这里用未注入 factory 的 bridge 证明「顺序」而非「重载成功」。 func TestRuntimeChannelsReloadSeedsAdminsBeforeDelegating(t *testing.T) { bridge, err := channelbridge.New(channelbridge.Options{ Log: testLog(), Loop: wireTestChatter{}, Sessions: wireTestSessions{}, }) if err != nil { t.Fatalf("channelbridge.New: %v", err) } rc, profile, path := newTestRuntimeChannels(t, bridge) writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"adminIds":["admin-9"]}]}`) if err := rc.Reload("kook"); err != channelbridge.ErrNilFactory { t.Fatalf("Reload err = %v, 期望 ErrNilFactory(测试前提:bridge 无 factory)", err) } if !profile.IsOwner("kook", "admin-9") { t.Fatal("Reload 应先把 adminIds 灌进活档案再委托 bridge,实际被跳过") } } // 裁决 10.4.5:组合根必须交出渠道 bridge 判主用的**同一个**活档案指针,热替换才真的生效; // 档案未加载时返回 nil,让 gateway 退回「真实落盘 + 提示重启」而不是假装成功。 func TestOwnerRegistrarLiveOwnerProfile(t *testing.T) { profile := &user.OwnerProfile{ID: "owner"} r := &ownerRegistrar{path: filepath.Join(t.TempDir(), "owner.yaml"), owner: profile, log: testLog()} if got := r.LiveOwnerProfile(); got != profile { t.Fatalf("LiveOwnerProfile 必须返回同一个指针: got %p want %p", got, profile) } var orphan ownerRegistrar if orphan.LiveOwnerProfile() != nil { t.Fatal("档案未加载时必须返回 nil") } }