- 新问题(本轮前端组发现):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(既有行为,不在授权内放宽)
263 lines
11 KiB
Go
263 lines
11 KiB
Go
package gateway
|
||
|
||
// 本文件覆盖渠道配置写路径的 **patch 语义** —— 「凭据静默丢失」回归
|
||
// (实现见 admin_write_channel_config.go 与 runtimeconfig.PatchChannel)。
|
||
//
|
||
// 面板只携带用户本次碰过的平台字段,旧实现把 config 当作整条替换,
|
||
// 未被面板携带的键因此被静默抹掉:保存微信(iLink)账号卡会清掉
|
||
// bot_token/bot_id/owner_id/get_updates_buf(iLink token 是长期凭据),
|
||
// 给 Telegram 账号填 webhook_url 而不重填 token 会把整个账号
|
||
// 从 channels.json 里丢掉(cleanAccounts)。线上契约因此是三态:
|
||
//
|
||
// 键缺席 → 保留该账号已存值
|
||
// 给出非空值 → 覆盖该键
|
||
// 显式 null / "" → 清除该键
|
||
//
|
||
// 用字面量 JSON 请求体断言线上形状(尤其是「完全没有 token 键」),
|
||
// 否则 map[string]any 会让断言从别处取值而通过。
|
||
|
||
import (
|
||
"path/filepath"
|
||
"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"
|
||
)
|
||
|
||
// storedChannelAccountOrNil 读取 channels.json 里某平台某 id 的账号;
|
||
// 不存在时返回 (零值, false)。
|
||
func storedChannelAccountOrNil(t *testing.T, path, platform, accountID string) (config.ChannelAccount, bool) {
|
||
t.Helper()
|
||
accounts, warn := runtimeconfig.LoadChannels(path, nil)
|
||
if warn != nil {
|
||
t.Fatalf("load channels warn = %v", warn)
|
||
}
|
||
for _, acct := range accounts[platform] {
|
||
if acct.ID == accountID {
|
||
return acct, true
|
||
}
|
||
}
|
||
return config.ChannelAccount{}, false
|
||
}
|
||
|
||
// TestAdminSaveChannelConfigKeepsWeixinIlinkCredentials 是主回归:微信
|
||
// (iLink)账号卡的 PLATFORM_FIELDS 只有 cdn_base_url,保存它时请求体
|
||
// 里一个凭据键都没有。patch 语义下 bot_token/bot_id/owner_id/
|
||
// get_updates_buf 必须原样留在盘上 —— iLink token 是长期凭据,
|
||
// 被抹掉就等于掉线且不可恢复。
|
||
func TestAdminSaveChannelConfigKeepsWeixinIlinkCredentials(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "channels.json")
|
||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
||
"weixin-ilink": {{ID: "wx1", Config: map[string]any{
|
||
"bot_token": "ilink-long-lived-token",
|
||
"bot_id": "wxid_bot",
|
||
"owner_id": "wxid_owner",
|
||
"get_updates_buf": "buf-42",
|
||
"cdn_base_url": "https://cdn.old",
|
||
}}},
|
||
}}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
putChannelConfigRaw(t, ts, "weixin-ilink",
|
||
`{"id":"wx1","config":{"cdn_base_url":"https://cdn.new"}}`, tok)
|
||
|
||
acct, ok := storedChannelAccountOrNil(t, path, "weixin-ilink", "wx1")
|
||
if !ok {
|
||
t.Fatalf("weixin-ilink/wx1 vanished from channels.json after saving its card")
|
||
}
|
||
for key, want := range map[string]any{
|
||
"bot_token": "ilink-long-lived-token",
|
||
"bot_id": "wxid_bot",
|
||
"owner_id": "wxid_owner",
|
||
"get_updates_buf": "buf-42",
|
||
"cdn_base_url": "https://cdn.new",
|
||
} {
|
||
if got := acct.Config[key]; got != want {
|
||
t.Fatalf("config[%q] = %#v, want %#v (config=%v)", key, got, want, acct.Config)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestAdminSaveChannelConfigKeepsWeixinCredentialsOnEmptyPatch 是同一条
|
||
// 丢失口的真实触发形态:主人打开微信账号卡、**什么都没填**就点保存
|
||
// (面板于是既不发 config,也不发任何便捷字段)。整个请求不得
|
||
// 改变已存 config —— 既不能抹掉凭据,也不能写入 null 垃圾。
|
||
func TestAdminSaveChannelConfigKeepsWeixinCredentialsOnEmptyPatch(t *testing.T) {
|
||
t.Parallel()
|
||
stored := func() map[string][]config.ChannelAccount {
|
||
return map[string][]config.ChannelAccount{
|
||
"weixin-ilink": {{ID: "wx1", Config: map[string]any{
|
||
"bot_token": "ilink-token", "bot_id": "wxid_bot", "owner_id": "wxid_owner",
|
||
}}},
|
||
}
|
||
}
|
||
for _, tc := range []struct{ name, body string }{
|
||
{"没有 config 键", `{"id":"wx1"}`},
|
||
{"config 显式 null", `{"id":"wx1","config":null}`},
|
||
{"config 是空对象", `{"id":"wx1","config":{}}`},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
// 每个子用例独占一个路径:并行写同一个临时文件会互相打架。
|
||
path := filepath.Join(t.TempDir(), "channels.json")
|
||
store := &stubChannelConfigStore{path: path, accounts: stored()}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
putChannelConfigRaw(t, ts, "weixin-ilink", tc.body, tok)
|
||
|
||
acct, ok := storedChannelAccountOrNil(t, path, "weixin-ilink", "wx1")
|
||
if !ok {
|
||
t.Fatalf("account vanished (body=%s)", tc.body)
|
||
}
|
||
for _, key := range []string{"bot_token", "bot_id", "owner_id"} {
|
||
if got := acct.Config[key]; got == nil || got == "" {
|
||
t.Fatalf("config[%q] = %#v, want the stored value kept (body=%s)", key, got, tc.body)
|
||
}
|
||
}
|
||
for key, v := range acct.Config {
|
||
if v == nil {
|
||
t.Fatalf("config[%q] was written as JSON null (body=%s)", key, tc.body)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestAdminSaveChannelConfigKeepsTelegramAccountWhenTokenOmitted 覆盖第二条
|
||
// 丢失口:主人给已有的 Telegram 账号填 webhook_url 而不重填 token。
|
||
// 旧行为是整条替换 ⇒ bot_token 变空 ⇒ cleanAccounts 把**整个账号**
|
||
// 丢掉。patch 语义下该账号必须还在,且已存 token 不变。
|
||
func TestAdminSaveChannelConfigKeepsTelegramAccountWhenTokenOmitted(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "channels.json")
|
||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
||
"telegram": {{ID: "conf0", Config: map[string]any{"bot_token": "tg-stored-token"}}},
|
||
}}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
putChannelConfigRaw(t, ts, "telegram",
|
||
`{"id":"conf0","config":{"webhook_url":"https://hook.example"}}`, tok)
|
||
|
||
accounts, warn := runtimeconfig.LoadChannels(path, nil)
|
||
if warn != nil {
|
||
t.Fatalf("load channels warn = %v", warn)
|
||
}
|
||
list := accounts["telegram"]
|
||
if len(list) != 1 {
|
||
t.Fatalf("telegram = %+v, want the single existing account to survive", list)
|
||
}
|
||
if list[0].ID != "conf0" || list[0].Config["bot_token"] != "tg-stored-token" {
|
||
t.Fatalf("telegram account = %+v, want conf0 with its stored token kept", list[0])
|
||
}
|
||
if list[0].Config["webhook_url"] != "https://hook.example" {
|
||
t.Fatalf("webhook_url = %#v, want the newly sent value", list[0].Config["webhook_url"])
|
||
}
|
||
}
|
||
|
||
// TestAdminSaveChannelConfigClearsConfigKeyExplicitly 保持「清空」能力:
|
||
// 显式 null 与显式 "" 都必须真的删掉该键(适配器随后回落到自己的
|
||
// 零值),而未触碰的兄弟键不受影响。
|
||
func TestAdminSaveChannelConfigClearsConfigKeyExplicitly(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "channels.json")
|
||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
||
"telegram": {{ID: "conf0", Config: map[string]any{
|
||
"bot_token": "tg-stored-token", "webhook_url": "https://old.hook", "base_url": "https://old.api",
|
||
}}},
|
||
}}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
// 显式 null → 清除。
|
||
putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","config":{"webhook_url":null}}`, tok)
|
||
acct, ok := storedChannelAccountOrNil(t, path, "telegram", "conf0")
|
||
if !ok {
|
||
t.Fatal("telegram/conf0 vanished after an explicit null")
|
||
}
|
||
if _, present := acct.Config["webhook_url"]; present {
|
||
t.Fatalf("explicit null did not clear the key: %v", acct.Config)
|
||
}
|
||
if acct.Config["base_url"] != "https://old.api" || acct.Config["bot_token"] != "tg-stored-token" {
|
||
t.Fatalf("sibling keys were touched by a single-key clear: %v", acct.Config)
|
||
}
|
||
|
||
// 显式 "" → 清除。
|
||
putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","config":{"base_url":""}}`, tok)
|
||
acct, ok = storedChannelAccountOrNil(t, path, "telegram", "conf0")
|
||
if !ok {
|
||
t.Fatal("telegram/conf0 vanished after an explicit \"\"")
|
||
}
|
||
if _, present := acct.Config["base_url"]; present {
|
||
t.Fatalf("explicit \"\" did not clear the key: %v", acct.Config)
|
||
}
|
||
if acct.Config["bot_token"] != "tg-stored-token" {
|
||
t.Fatalf("the token was lost while clearing another key: %v", acct.Config)
|
||
}
|
||
}
|
||
|
||
// TestAdminSaveChannelConfigExplicitTokenClearStillDropsAccount 钉住 patch
|
||
// 与既有凭据保护的交互:token 要求型平台(kook/telegram)上**显式**
|
||
// 清掉 bot_token 仍然按 cleanAccounts 丢弃该账号 —— 这是主人明确要求
|
||
// 清除凭据,不是「未被触碰的键被误删」。本用例只记录现状,不改该行为。
|
||
func TestAdminSaveChannelConfigExplicitTokenClearStillDropsAccount(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "channels.json")
|
||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
||
"telegram": {
|
||
{ID: "conf0", Config: map[string]any{"bot_token": "tg-token"}},
|
||
{ID: "conf1", Config: map[string]any{"bot_token": "tg-other"}},
|
||
},
|
||
}}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","config":{"bot_token":null}}`, tok)
|
||
|
||
accounts, warn := runtimeconfig.LoadChannels(path, nil)
|
||
if warn != nil {
|
||
t.Fatalf("load channels warn = %v", warn)
|
||
}
|
||
if _, ok := storedChannelAccountOrNil(t, path, "telegram", "conf0"); ok {
|
||
t.Fatalf("telegram/conf0 = %+v, want it dropped after an explicit token clear", accounts["telegram"])
|
||
}
|
||
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].ID != "conf1" {
|
||
t.Fatalf("telegram = %+v, want only the untouched sibling conf1", accounts["telegram"])
|
||
}
|
||
}
|
||
|
||
// TestAdminSaveChannelConfigCreatesAccountWithPatchOnly 覆盖无基线的新建:
|
||
// 没有已存条目时补丁本身就是配置(非空键写入,显式清除不留 null)。
|
||
func TestAdminSaveChannelConfigCreatesAccountWithPatchOnly(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)
|
||
|
||
// qq-obv11 不要求 token,因此新建账号不会被 cleanAccounts 丢掉。
|
||
putChannelConfigRaw(t, ts, "qq-obv11",
|
||
`{"id":"ob1","config":{"mode":"server","remote_url":"ws://127.0.0.1:3001"}}`, tok)
|
||
acct, ok := storedChannelAccountOrNil(t, path, "qq-obv11", "ob1")
|
||
if !ok {
|
||
t.Fatal("a brand-new account was not created")
|
||
}
|
||
if acct.Config["mode"] != "server" || acct.Config["remote_url"] != "ws://127.0.0.1:3001" {
|
||
t.Fatalf("new account config = %v, want the patch written verbatim", acct.Config)
|
||
}
|
||
|
||
// 全新账号 + 只有显式清除 → 不写 null 垃圾,账号照样建出来。
|
||
putChannelConfigRaw(t, ts, "qq-obv11", `{"id":"ob2","config":{"mode":null}}`, tok)
|
||
acct, ok = storedChannelAccountOrNil(t, path, "qq-obv11", "ob2")
|
||
if !ok {
|
||
t.Fatal("a new account carrying only clears was not created")
|
||
}
|
||
if _, present := acct.Config["mode"]; present {
|
||
t.Fatalf("a clear on a brand-new account must not write the key: %v", acct.Config)
|
||
}
|
||
}
|