- 根因: caps循环把 kook(bare)+kook:fc2048(多账号) 分别append, 每行Accounts都是完整platform账号集→前端显示两个kook且fc2048重复(主人'只点一次创建出两个kook') - 修: 按platform聚合(hasChannelSummary检查), 同platform只一行(Name=platform), Accounts为整个platform账号集; 保留agent-121删除同步(平台有账号才显示) - 测试断言: kook:3fc8/conf0 → kook(聚合行) 更新 - 全仓 go build/vet/test绿; 前端无涉
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 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
|
|
}
|
|
|
|
// stubNamedChannel is an sdk.Channel with a caller-chosen adapter name, so a test
|
|
// can register a multi-account adapter (kook:3fc8) distinct from its bare-platorm
|
|
// sibling and observe which entries survive a config change.
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// adminChannelsList is a small read helper that issues GET /admin/channels and
|
|
// decodes the data array, returning the raw body too so a caller can assert that a
|
|
// secret never leaked. It fails the test on any transport/parse error.
|
|
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 is the regression for the
|
|
// stale-adapter bug: deleting an account removes it from channels.json, but the
|
|
// adapter stays registered in caps. The list must still drop it (channels.json is
|
|
// the source of truth), so a deleted account never reappears on refresh.
|
|
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)
|
|
|
|
// Before the delete the account shows up (aggregated into a single "kook" row).
|
|
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)
|
|
}
|
|
|
|
// The stale adapter remains in caps (the bridge reload does not touch the
|
|
// registry), yet the deleted account must not reappear in the list.
|
|
if items, _ := adminChannelsList(t, ts, tok); len(items) != 0 {
|
|
t.Fatalf("after delete items = %+v, want none (deleted account must vanish)", items)
|
|
}
|
|
}
|
|
|
|
// TestAdminDeleteChannelAccountKeepsSibling verifies that deleting one account of a
|
|
// multi-account platform drops only that account: the sibling adapter and its
|
|
// account remain, and the deleted one disappears.
|
|
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 verifies the save path: after
|
|
// persisting an account the list reflects it, while a stale caps adapter for a
|
|
// different account id (here kook:oldid, never in channels.json) is dropped. The
|
|
// account's token is never leaked.
|
|
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)
|
|
}
|
|
}
|