package gateway import ( "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // stubChannelConfigStore 是一个 ChannelConfigStore,后端是内存 map 与 // 真实临时 channels.json 路径,并记录 Reload 调用。 type stubChannelConfigStore struct { path string accounts map[string][]config.ChannelAccount reloaded []string reloadErr error } func (s *stubChannelConfigStore) Path() string { return s.path } func (s *stubChannelConfigStore) Load() map[string][]config.ChannelAccount { if s.accounts == nil { return map[string][]config.ChannelAccount{} } return s.accounts } func (s *stubChannelConfigStore) Reload(platform string) error { s.reloaded = append(s.reloaded, platform) return s.reloadErr } // stubNamedChannel 是一个 sdk.Channel,适配器名由调用方选定, // 使测试能注册一个多账号适配器(kook:3fc8),与它的裸平台 // 兄弟区分开,并观察配置变更后哪些条目存活。 type stubNamedChannel struct { name string } func (s stubNamedChannel) Name() string { return s.name } func (s stubNamedChannel) Connect(ctx context.Context) error { return nil } func (s stubNamedChannel) Disconnect(ctx context.Context) error { return nil } func (s stubNamedChannel) IsConnected() bool { return true } func (s stubNamedChannel) HealthCheck(ctx context.Context) error { return nil } var _ sdk.Channel = stubNamedChannel{} func TestAdminSaveChannelConfigPersistsAndReloads(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config", map[string]any{ "token": "bot-tok", "baseURL": "https://kook.example", "groupMsgPerMin": 3, "denyGroups": []string{"g-9"}, }, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body) } var res struct { Persisted bool `json:"persisted"` Path string `json:"path"` Platform string `json:"platform"` AccountID string `json:"accountId"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode save: %v (body=%s)", err, body) } if !res.Persisted || res.Path != path || res.Platform != "kook" || res.AccountID == "" { t.Fatalf("save res = %+v", res) } // 该账号带着真实 token 与黑名单落到了 channels.json。 accounts, warn := runtimeconfig.LoadChannels(path, nil) if warn != nil { t.Fatalf("load channels warn = %v", warn) } kook := accounts["kook"] if len(kook) != 1 || kook[0].Config["bot_token"] != "bot-tok" { t.Fatalf("channels.json kook = %+v, want one account with bot-tok", kook) } if len(kook[0].DenyGroups) != 1 || kook[0].DenyGroups[0] != "g-9" { t.Fatalf("denyGroups = %v, want [g-9]", kook[0].DenyGroups) } if kook[0].GroupMsgPerMin != 3 { t.Fatalf("groupMsgPerMin = %d, want 3", kook[0].GroupMsgPerMin) } // 已为保存的平台触发了 Reload。 if len(store.reloaded) != 1 || store.reloaded[0] != "kook" { t.Fatalf("reloaded = %v, want [kook]", store.reloaded) } // GET /admin/channels 反映了该账号,且绝不泄露原始 token。 gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok) if gResp.StatusCode != http.StatusOK { t.Fatalf("get status = %d, want 200 (body=%s)", gResp.StatusCode, gBody) } if strings.Contains(string(gBody), "bot-tok") { t.Fatalf("raw token leaked in GET /admin/channels: %s", gBody) } if !strings.Contains(string(gBody), "kook") { t.Fatalf("GET /admin/channels missing the kook account: %s", gBody) } } func TestAdminSaveChannelConfigRejectsBlank(t *testing.T) { t.Parallel() store := &stubChannelConfigStore{path: filepath.Join(t.TempDir(), "channels.json"), accounts: map[string][]config.ChannelAccount{}} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config", map[string]any{"id": "a"}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("blank config status = %d, want 400 (body=%s)", resp.StatusCode, body) } // 不应触发任何重载。 if len(store.reloaded) != 0 { t.Fatalf("reloaded = %v, want none", store.reloaded) } } func TestAdminSaveChannelConfigUnwired500(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无渠道配置存储 tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config", map[string]any{"token": "x"}, tok) if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("unwired status = %d, want 500 (body=%s)", resp.StatusCode, body) } } func TestAdminDeleteChannelConfig(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ "kook": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}}, }} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/kook/config?id=a", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body) } if len(store.reloaded) != 1 || store.reloaded[0] != "kook" { t.Fatalf("reloaded = %v, want [kook]", store.reloaded) } // 文件被重写且不含该账号;平台键被删除。 accounts, warn := runtimeconfig.LoadChannels(path, nil) if warn == nil { t.Fatalf("expected a warn after deleting the last account (empty map is invalid)") } if _, ok := accounts["kook"]; ok && len(accounts["kook"]) > 0 { t.Fatalf("kook account still present after delete: %+v", accounts) } } // adminChannelsList 是一个小的读辅助函数:发起 GET /admin/channels, // 解码 data 数组,同时返回原始响应体,使调用方能断言 // 秘密从未泄露。任何传输/解析错误都会让测试失败。 func adminChannelsList(t *testing.T, ts *httptest.Server, token string) ([]ChannelSummary, []byte) { t.Helper() resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, token) if resp.StatusCode != http.StatusOK { t.Fatalf("get status = %d, want 200 (body=%s)", resp.StatusCode, body) } var items []ChannelSummary if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil { t.Fatalf("decode list: %v", err) } return items, body } // TestAdminDeleteChannelAccountRemovesFromList 是陈旧适配器 bug 的 // 回归:删除账号会把它从 channels.json 移除,但适配器 // 仍注册在 caps 中。列表必须仍然丢弃它(channels.json 是 // 唯一事实来源),因此已删除账号绝不在刷新时复现。 func TestAdminDeleteChannelAccountRemovesFromList(t *testing.T) { t.Parallel() caps := capreg.New() if err := caps.RegisterChannel(stubNamedChannel{name: "kook:3fc8"}); err != nil { t.Fatalf("register channel: %v", err) } store := &stubChannelConfigStore{ path: filepath.Join(t.TempDir(), "channels.json"), accounts: map[string][]config.ChannelAccount{ "kook": {{ID: "3fc8", Config: map[string]any{"bot_token": "tk"}}}, }, } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) // 删除前该账号会出现(聚合进单个 "kook" 行)。 items, _ := adminChannelsList(t, ts, tok) if len(items) != 1 || items[0].Name != "kook" || len(items[0].Accounts) != 1 || items[0].Accounts[0].ID != "3fc8" { t.Fatalf("before delete items = %+v, want one kook row with account 3fc8", items) } resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/kook/config?id=3fc8", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body) } if len(store.reloaded) != 1 || store.reloaded[0] != "kook" { t.Fatalf("reloaded = %v, want [kook]", store.reloaded) } // 陈旧适配器仍留在 caps 中(bridge 重载不触碰注册表), // 但已删除的账号不得在列表中复现。 if items, _ := adminChannelsList(t, ts, tok); len(items) != 0 { t.Fatalf("after delete items = %+v, want none (deleted account must vanish)", items) } } // TestAdminDeleteChannelAccountKeepsSibling 验证删除多账号平台的 // 一个账号只丢弃该账号:兄弟适配器及其 // 账号保留,被删除的那个消失。 func TestAdminDeleteChannelAccountKeepsSibling(t *testing.T) { t.Parallel() caps := capreg.New() for _, name := range []string{"kook:3fc8", "kook:conf1"} { if err := caps.RegisterChannel(stubNamedChannel{name: name}); err != nil { t.Fatalf("register %s: %v", name, err) } } store := &stubChannelConfigStore{ path: filepath.Join(t.TempDir(), "channels.json"), accounts: map[string][]config.ChannelAccount{ "kook": { {ID: "3fc8", Config: map[string]any{"bot_token": "a"}}, {ID: "conf1", Config: map[string]any{"bot_token": "b"}}, }, }, } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/kook/config?id=3fc8", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body) } items, _ := adminChannelsList(t, ts, tok) if len(items) != 1 || items[0].Name != "kook" { t.Fatalf("items = %+v, want one kook row (deleted 3fc8 must vanish)", items) } if len(items[0].Accounts) != 1 || items[0].Accounts[0].ID != "conf1" { t.Fatalf("accounts = %+v, want only conf1", items[0].Accounts) } } // TestAdminSaveChannelConfigListMirrorsChannelsJSON 验证保存路径: // 持久化账号后列表会反映它,而针对不同账号 id(这里是 // kook:oldid,从未出现在 channels.json)的陈旧 caps 适配器被丢弃。该 // 账号的 token 从不泄露。 func TestAdminSaveChannelConfigListMirrorsChannelsJSON(t *testing.T) { t.Parallel() caps := capreg.New() if err := caps.RegisterChannel(stubNamedChannel{name: "kook:oldid"}); err != nil { t.Fatalf("register channel: %v", err) } store := &stubChannelConfigStore{ path: filepath.Join(t.TempDir(), "channels.json"), accounts: map[string][]config.ChannelAccount{}, } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config", map[string]any{"id": "saved0", "token": "new-tok"}, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body) } items, listBody := adminChannelsList(t, ts, tok) if len(items) != 1 || items[0].Name != "kook" { t.Fatalf("items = %+v, want a single kook platform from channels.json", items) } if len(items[0].Accounts) != 1 || items[0].Accounts[0].ID != "saved0" { t.Fatalf("accounts = %+v, want just saved0", items[0].Accounts) } if strings.Contains(string(listBody), "new-tok") { t.Fatalf("raw token leaked in list: %s", listBody) } }