Files
Cyrene_v2/internal/services/gateway/admin_write_channel_config_test.go
T
Aska aedae2190b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config): 运行时配置体系②——渠道配置落 channels.json + 面板落盘 + 热更新
- runtimeconfig/channels.go: channels.json 承载 map[platform][]ChannelAccount(token/baseURL/黑白名单/群聊限流), LoadChannels 优先文件/回退env, SaveChannels(0600+tmp原子写), Upsert/Delete/Masked脱敏
- channelbridge/reload.go: adapterManager 热替换——工厂重建新adapter先连接, 再原子断旧adapter(独立readCtx/进行中不中断); Bridge.Reload(platform) 逐账号增删
- 面板 PUT/DELETE /admin/channels/{platform}/config(owner+审计) 落盘channels.json+Reload热更新; GET /admin/channels 附账号掩码不泄裸token
- 前端 Channels.tsx 配置表单(id/token/baseURL/黑白名单/限流+config JSON)+保存热更新提示+掩码展示/删除
- .gitignore 加 channels.json; .env 退兜底(.env.example注明); token不打印/Config()脱敏
- channelbridge.go 拆 logic→reload.go(742→731); 全仓 go build/vet/test绿+前端build绿
- 未做真token端到端(需渠道凭据,不读.env)——如实标注待凭据
2026-09-07 13:29:41 +08:00

150 lines
5.5 KiB
Go

package gateway
import (
"encoding/json"
"net/http"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// stubChannelConfigStore is a ChannelConfigStore backed by an in-memory map and a
// real temp channels.json path, recording Reload calls.
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
}
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)
}
// The account landed in channels.json with the real token and the black-list.
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 was triggered for the saved platform.
if len(store.reloaded) != 1 || store.reloaded[0] != "kook" {
t.Fatalf("reloaded = %v, want [kook]", store.reloaded)
}
// GET /admin/channels reflects the account and never leaks the raw 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)
}
// No reload should have been triggered.
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{}) // no channel config 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": "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)
}
// The file is rewritten without the account; the platform key is dropped.
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)
}
}