主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
297 lines
12 KiB
Go
297 lines
12 KiB
Go
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)
|
||
}
|
||
}
|