Files
Cyrene_v2/internal/services/gateway/admin_channel_config_patch_test.go
AskaEth 4ae714883d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,runtimeconfig): 渠道配置写路径改为 patch 语义,修凭据静默丢失(P0 级数据安全)
- 新问题(本轮前端组发现):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(既有行为,不在授权内放宽)
2026-10-04 13:42:37 +08:00

263 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}