Files
Cyrene_v2/cmd/cyrene/wire_channels_adminids_test.go
AskaEth af8591b78a fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)
- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner
- session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝
- 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效)
- 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测)
- 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
2026-10-04 12:07:40 +08:00

119 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}