- 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)——如实标注待凭据
193 lines
6.4 KiB
Go
193 lines
6.4 KiB
Go
package runtimeconfig
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
)
|
|
|
|
func TestLoadChannelsFileWins(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
if err := os.WriteFile(path, []byte(`{"kook":[{"id":"a","config":{"bot_token":"tk"}}]}`), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
accounts, warn := LoadChannels(path, map[string][]config.ChannelAccount{
|
|
"telegram": {{ID: "env", Config: map[string]any{"bot_token": "env"}}},
|
|
})
|
|
if warn != nil {
|
|
t.Fatalf("warn = %v, want nil", warn)
|
|
}
|
|
if len(accounts["kook"]) != 1 || accounts["kook"][0].ID != "a" || accounts["kook"][0].Config["bot_token"] != "tk" {
|
|
t.Fatalf("file accounts not used: %+v", accounts)
|
|
}
|
|
if _, ok := accounts["telegram"]; ok {
|
|
t.Fatalf("file should win over env fallback, telegram leaked in: %+v", accounts)
|
|
}
|
|
}
|
|
|
|
func TestLoadChannelsMissingFallsBackToEnv(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "nope.json")
|
|
fallback := map[string][]config.ChannelAccount{"telegram": {{ID: "env", Config: map[string]any{"bot_token": "tk"}}}}
|
|
accounts, warn := LoadChannels(path, fallback)
|
|
if warn != nil {
|
|
t.Fatalf("warn = %v, want nil for missing file", warn)
|
|
}
|
|
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].ID != "env" {
|
|
t.Fatalf("fallback not used: %+v", accounts)
|
|
}
|
|
}
|
|
|
|
func TestLoadChannelsInvalidFallsBackWithWarn(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
if err := os.WriteFile(path, []byte(`{not-json`), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
fallback := map[string][]config.ChannelAccount{"kook": {{Config: map[string]any{}}}}
|
|
accounts, warn := LoadChannels(path, fallback)
|
|
if warn == nil {
|
|
t.Fatal("warn = nil, want a warning for an invalid file")
|
|
}
|
|
if len(accounts["kook"]) != 1 {
|
|
t.Fatalf("invalid file did not fall back to env: %+v", accounts)
|
|
}
|
|
}
|
|
|
|
func TestLoadChannelsEmptyFileFallsBackWithWarn(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
fallback := map[string][]config.ChannelAccount{"kook": {{Config: map[string]any{}}}}
|
|
accounts, warn := LoadChannels(path, fallback)
|
|
if warn == nil {
|
|
t.Fatal("warn = nil, want a warning for an empty file")
|
|
}
|
|
if len(accounts["kook"]) != 1 {
|
|
t.Fatalf("empty file did not fall back to env: %+v", accounts)
|
|
}
|
|
}
|
|
|
|
func TestLoadChannelsEmptyMapFallsBackWithWarn(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
if err := os.WriteFile(path, []byte(`{}`), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
fallback := map[string][]config.ChannelAccount{"kook": {{Config: map[string]any{}}}}
|
|
accounts, warn := LoadChannels(path, fallback)
|
|
if warn == nil {
|
|
t.Fatal("warn = nil, want a warning for an empty map")
|
|
}
|
|
if len(accounts["kook"]) != 1 {
|
|
t.Fatalf("empty map did not fall back to env: %+v", accounts)
|
|
}
|
|
}
|
|
|
|
func TestSaveChannelsRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
accts := map[string][]config.ChannelAccount{
|
|
"kook": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}},
|
|
}
|
|
if err := SaveChannels(path, accts); err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
got, warn := LoadChannels(path, nil)
|
|
if warn != nil {
|
|
t.Fatalf("load warn = %v", warn)
|
|
}
|
|
if len(got["kook"]) != 1 || got["kook"][0].Config["bot_token"] != "tk" {
|
|
t.Fatalf("round trip mismatch: %+v", got)
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("stat: %v", err)
|
|
}
|
|
if info.Mode().Perm()&0o077 != 0 {
|
|
t.Fatalf("channels.json mode = %v, want owner-only (0600)", info.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
func TestUpsertChannelAssignsIDAndReplaces(t *testing.T) {
|
|
t.Parallel()
|
|
accounts := map[string][]config.ChannelAccount{
|
|
"kook": {{ID: "a", Config: map[string]any{"bot_token": "old"}}},
|
|
}
|
|
// Blank ID → generated.
|
|
added := UpsertChannel(accounts, "kook", config.ChannelAccount{Config: map[string]any{"bot_token": "new"}})
|
|
if len(added["kook"]) != 2 {
|
|
t.Fatalf("want 2 accounts after add, got %d", len(added["kook"]))
|
|
}
|
|
if added["kook"][1].ID == "" {
|
|
t.Fatal("new account should get a generated id")
|
|
}
|
|
// Same ID → replaced in place.
|
|
replaced := UpsertChannel(accounts, "kook", config.ChannelAccount{ID: "a", Config: map[string]any{"bot_token": "updated"}})
|
|
if len(replaced["kook"]) != 2 {
|
|
t.Fatalf("want 2 accounts after replace, got %d", len(replaced["kook"]))
|
|
}
|
|
if replaced["kook"][0].Config["bot_token"] != "updated" {
|
|
t.Fatalf("replace did not update in place: %+v", replaced["kook"][0])
|
|
}
|
|
}
|
|
|
|
func TestDeleteChannelRemovesAccountAndDropsPlatform(t *testing.T) {
|
|
t.Parallel()
|
|
accounts := map[string][]config.ChannelAccount{
|
|
"kook": {{ID: "a", Config: map[string]any{}}, {ID: "b", Config: map[string]any{}}},
|
|
"telegram": {{ID: "t", Config: map[string]any{}}},
|
|
}
|
|
out := DeleteChannel(accounts, "kook", "a")
|
|
if len(out["kook"]) != 1 || out["kook"][0].ID != "b" {
|
|
t.Fatalf("kook after delete: %+v", out["kook"])
|
|
}
|
|
// Deleting the last account drops the platform key entirely.
|
|
out2 := DeleteChannel(out, "kook", "b")
|
|
if _, ok := out2["kook"]; ok {
|
|
t.Fatalf("kook should be dropped when empty: %+v", out2)
|
|
}
|
|
// Other platforms untouched.
|
|
if len(out2["telegram"]) != 1 {
|
|
t.Fatalf("telegram affected: %+v", out2)
|
|
}
|
|
}
|
|
|
|
func TestMaskedRedactsTokensKeepsOriginal(t *testing.T) {
|
|
t.Parallel()
|
|
acct := config.ChannelAccount{
|
|
ID: "a",
|
|
Config: map[string]any{"bot_token": "super-secret-token", "base_url": "https://x", "access_token": "abc", "secret": "s"},
|
|
DenyUsers: []string{"u1"},
|
|
}
|
|
masked := Masked(acct)
|
|
if got := masked.Config["bot_token"]; got != "****oken" {
|
|
t.Fatalf("bot_token = %v, want masked", got)
|
|
}
|
|
if got := masked.Config["access_token"]; got != "****" {
|
|
t.Fatalf("access_token = %v, want fully masked", got)
|
|
}
|
|
if got := masked.Config["secret"]; got != "****" {
|
|
t.Fatalf("secret = %v, want fully masked", got)
|
|
}
|
|
if got := masked.Config["base_url"]; got != "https://x" {
|
|
t.Fatalf("base_url = %v, want unchanged", got)
|
|
}
|
|
if masked.DenyUsers[0] != "u1" {
|
|
t.Fatalf("deny user list lost: %+v", masked)
|
|
}
|
|
// The original account still carries the raw token.
|
|
if acct.Config["bot_token"] != "super-secret-token" {
|
|
t.Fatalf("Masked mutated the original account")
|
|
}
|
|
if strings.Contains(masked.Config["bot_token"].(string), "super-secret") {
|
|
t.Fatalf("masked leaked the full token: %v", masked.Config["bot_token"])
|
|
}
|
|
}
|