- 新问题(本轮前端组发现):config 是整条替换,而面板只发送用户本次碰过的平台字段 ⇒ ① 保存微信(iLink)账号卡(其平台字段只有 cdn_base_url,甚至可为空)会把 bot_token/bot_id/owner_id/get_updates_buf **全部抹掉**(iLink token 是长期凭据); ② Telegram 填 webhook_url 而不重填 token 会因 cleanAccounts「token 空即丢账号」把**整个账号**删掉 - 修法:新增 runtimeconfig.PatchChannel + mergeChannelConfig + isEmptyConfigValue(三态,与 personaId 同构) · 键省略 / config 整体缺席 / null = **保留已存值**(未给出的键绝不被抹掉) · 给出非空值 = 覆盖 · 显式 null 或 "" = **清除该键**(0/false 不算空) · 写路径 UpsertChannel → PatchChannel;非 config 字段(名单/限流/personaId)仍整值替换(不变) - 回归(真断言落盘内容):微信卡片只带 cdn_base_url / 空卡片三种形态 → 四个凭据键全在; Telegram 只带 webhook_url → 账号存活且 token 不变;显式 null/"" → 键真被清除;新账号正常写入; 既有 TestAdminSaveChannelConfigPersistsAndReloads 保持绿 - 变异反证 3 条(改回 UpsertChannel → 精确复现两缺陷;去掉清除分支;"" 不再算空)均已还原 - 边界未改:token 要求型平台**显式**清 token 仍按 cleanAccounts 丢账号(主人明确清除凭据的语义); 「本次一个 config 键都没给出」在非 weixin-ilink 平台仍回 400(既有行为,不在授权内放宽)
361 lines
13 KiB
Go
361 lines
13 KiB
Go
package runtimeconfig
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"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 TestLoadChannelsCleansEmptyTokenKook(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
// A stale channels.json holding a bare empty-token kook plus a real account.
|
|
raw := `{"kook":[
|
|
{"id":"","config":{"base_url":"","bot_token":""}},
|
|
{"id":"3fc8ef9434bddfbbe4f4daa8da21c093","config":{"bot_token":"real-token"}}
|
|
]}`
|
|
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
accounts, warn := LoadChannels(path, nil)
|
|
if warn != nil {
|
|
t.Fatalf("warn = %v, want nil", warn)
|
|
}
|
|
kook := accounts["kook"]
|
|
if len(kook) != 1 {
|
|
t.Fatalf("kook = %+v, want only the token-bearing account", kook)
|
|
}
|
|
if kook[0].ID != "3fc8ef9434bddfbbe4f4daa8da21c093" || kook[0].Config["bot_token"] != "real-token" {
|
|
t.Fatalf("kook = %+v, want the token-bearing account", kook[0])
|
|
}
|
|
}
|
|
|
|
func TestLoadChannelsKeepsNonTokenPlatforms(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
// qq-obv11/webhook carry no bot token and must be preserved.
|
|
raw := `{"qq-obv11":[{"id":"","config":{"mode":"server"}}],"webhook":[{"id":"","config":{"endpointPath":""}}]}`
|
|
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
accounts, warn := LoadChannels(path, nil)
|
|
if warn != nil {
|
|
t.Fatalf("warn = %v", warn)
|
|
}
|
|
if len(accounts["qq-obv11"]) != 1 || len(accounts["webhook"]) != 1 {
|
|
t.Fatalf("non-token platforms should be preserved: %+v", accounts)
|
|
}
|
|
}
|
|
|
|
func TestSaveChannelsDropsEmptyTokenBareAccount(t *testing.T) {
|
|
t.Parallel()
|
|
path := filepath.Join(t.TempDir(), "channels.json")
|
|
accts := map[string][]config.ChannelAccount{
|
|
"kook": {
|
|
{ID: "", Config: map[string]any{"bot_token": ""}},
|
|
{ID: "a", Config: map[string]any{"bot_token": "tk"}},
|
|
},
|
|
"webhook": {{ID: "", Config: map[string]any{"endpointPath": ""}}},
|
|
}
|
|
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].ID != "a" {
|
|
t.Fatalf("kook = %+v, want only the token-bearing account", got["kook"])
|
|
}
|
|
if _, ok := got["webhook"]; !ok || len(got["webhook"]) != 1 {
|
|
t.Fatalf("webhook should be preserved untouched: %+v", got["webhook"])
|
|
}
|
|
}
|
|
|
|
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 runtime.GOOS != "windows" {
|
|
// Windows has no POSIX permission bits for regular files: Chmod only
|
|
// toggles the read-only flag and Stat reports 0666 regardless of the
|
|
// requested 0600, so this assertion is only meaningful on Unix-like
|
|
// platforms. SaveChannels still requests 0600 (see channels.go).
|
|
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])
|
|
}
|
|
}
|
|
|
|
// TestPatchChannelKeepsOmittedKeys 钉住补丁语义:补丁里缺位的键保留
|
|
// 已存值,给出的键覆盖,显式 nil/"" 删除该键(且已存 map 不被就地改写)。
|
|
func TestPatchChannelKeepsOmittedKeys(t *testing.T) {
|
|
t.Parallel()
|
|
stored := map[string]any{"bot_token": "old-token", "webhook_url": "https://old", "cdn_base_url": ""}
|
|
accounts := map[string][]config.ChannelAccount{
|
|
"telegram": {{ID: "conf0", Config: stored}},
|
|
}
|
|
out := PatchChannel(accounts, "telegram", config.ChannelAccount{
|
|
ID: "conf0",
|
|
Config: map[string]any{"webhook_url": "https://new"},
|
|
})
|
|
got := out["telegram"][0].Config
|
|
if got["bot_token"] != "old-token" {
|
|
t.Fatalf("an omitted key was lost: %v", got)
|
|
}
|
|
if got["webhook_url"] != "https://new" {
|
|
t.Fatalf("a given key was not overridden: %v", got)
|
|
}
|
|
// 已存条目自身不被改动(补丁是纯函数式的合并)。
|
|
if stored["webhook_url"] != "https://old" {
|
|
t.Fatalf("PatchChannel mutated the stored map: %v", stored)
|
|
}
|
|
}
|
|
|
|
// TestPatchChannelClearsOnNilAndBlank 覆盖「清除」三态的一半:显式 nil 与
|
|
// 只含空白的字符串删除该键;数字 0 / 布尔 false 是真实取值,不算空。
|
|
func TestPatchChannelClearsOnNilAndBlank(t *testing.T) {
|
|
t.Parallel()
|
|
accounts := map[string][]config.ChannelAccount{
|
|
"qq-obv11": {{ID: "ob1", Config: map[string]any{
|
|
"mode": "server", "remote_url": "ws://x", "listen_addr": "127.0.0.1:1", "verbose": true,
|
|
}}},
|
|
}
|
|
out := PatchChannel(accounts, "qq-obv11", config.ChannelAccount{
|
|
ID: "ob1",
|
|
Config: map[string]any{
|
|
"mode": nil, "remote_url": "", "listen_addr": " ",
|
|
"verbose": false, "retry": float64(0),
|
|
},
|
|
})
|
|
got := out["qq-obv11"][0].Config
|
|
for _, key := range []string{"mode", "remote_url", "listen_addr"} {
|
|
if _, present := got[key]; present {
|
|
t.Fatalf("key %q should have been cleared: %v", key, got)
|
|
}
|
|
}
|
|
if v, ok := got["verbose"]; !ok || v != false {
|
|
t.Fatalf("verbose = %#v (present=%v), want a real false kept", v, ok)
|
|
}
|
|
if v, ok := got["retry"]; !ok || v != float64(0) {
|
|
t.Fatalf("retry = %#v (present=%v), want a real 0 kept", v, ok)
|
|
}
|
|
}
|
|
|
|
// TestPatchChannelWithoutBaseline 覆盖没有已存条目的情形:补丁即配置,
|
|
// 空 id 由 UpsertChannel 生成,且清除不会留下 null 值。
|
|
func TestPatchChannelWithoutBaseline(t *testing.T) {
|
|
t.Parallel()
|
|
accounts := map[string][]config.ChannelAccount{}
|
|
out := PatchChannel(accounts, "weixin-ilink", config.ChannelAccount{
|
|
Config: map[string]any{"cdn_base_url": "https://cdn", "bot_token": nil},
|
|
})
|
|
list := out["weixin-ilink"]
|
|
if len(list) != 1 || list[0].ID == "" {
|
|
t.Fatalf("out = %+v, want one account with a generated id", list)
|
|
}
|
|
if list[0].Config["cdn_base_url"] != "https://cdn" {
|
|
t.Fatalf("config = %v, want the patch written", list[0].Config)
|
|
}
|
|
if _, present := list[0].Config["bot_token"]; present {
|
|
t.Fatalf("a clear must not write a key: %v", list[0].Config)
|
|
}
|
|
}
|
|
|
|
// TestPatchChannelOnlyTouchesTargetAccount 钉住「补丁只作用于目标账号」:
|
|
// 兄弟账号与其它平台的 config 完全不动。
|
|
func TestPatchChannelOnlyTouchesTargetAccount(t *testing.T) {
|
|
t.Parallel()
|
|
accounts := map[string][]config.ChannelAccount{
|
|
"telegram": {
|
|
{ID: "conf0", Config: map[string]any{"bot_token": "a"}},
|
|
{ID: "conf1", Config: map[string]any{"bot_token": "b", "webhook_url": "https://keep"}},
|
|
},
|
|
"kook": {{ID: "conf0", Config: map[string]any{"bot_token": "k"}}},
|
|
}
|
|
out := PatchChannel(accounts, "kook", config.ChannelAccount{ID: "conf0", Config: map[string]any{"bot_token": "k2"}})
|
|
if len(out["telegram"]) != 2 || out["telegram"][1].Config["webhook_url"] != "https://keep" {
|
|
t.Fatalf("a sibling account was touched: %+v", out["telegram"])
|
|
}
|
|
if out["kook"][0].Config["bot_token"] != "k2" || len(out["kook"][0].Config) != 1 {
|
|
t.Fatalf("kook account = %+v, want just the patched key", out["kook"][0].Config)
|
|
}
|
|
}
|
|
|
|
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"])
|
|
}
|
|
}
|