Files
Cyrene_v2/internal/services/gateway/admin_write_channel_config_test.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

297 lines
12 KiB
Go
Raw 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 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)
}
}