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(既有行为,不在授权内放宽)
This commit is contained in:
1 parent
8d70f6d331
commit
4ae714883d
4 files changed
+446
-9
No files matched your search
@@ -138,6 +138,63 @@ func UpsertChannel(accounts map[string][]config.ChannelAccount, platform string,
|
||||
return accounts
|
||||
}
|
||||
|
||||
// PatchChannel 以 **patch 语义** 写入一个账号,是 UpsertChannel 的补丁版:
|
||||
// acct.Config 被当作「只含本次请求显式给出的键」的补丁,而不是一整套完整配置。
|
||||
//
|
||||
// - 补丁里**缺位的键** → 保留该账号已存值(未被触碰的键绝不因一次保存被抹掉);
|
||||
// - 补丁里给出的非空值 → 覆盖该键;
|
||||
// - 补丁里给出 nil 或空白字符串 → **删除**该键(保留「清空」能力,删掉后适配器回落到自己的零值)。
|
||||
//
|
||||
// 同平台同 id 的已存条目是补丁的基线;没有基线时(新账号,或 id 为空、由 UpsertChannel
|
||||
// 生成 id)只写入补丁本身。acct 的其它字段(黑白名单 / 限流 / personaId)与 UpsertChannel
|
||||
// 一样是整值替换 —— 它们在线上是值语义,不是三态。返回值与入参 accounts 的关系同 UpsertChannel。
|
||||
func PatchChannel(accounts map[string][]config.ChannelAccount, platform string, acct config.ChannelAccount) map[string][]config.ChannelAccount {
|
||||
if accounts == nil {
|
||||
accounts = map[string][]config.ChannelAccount{}
|
||||
}
|
||||
var stored config.ChannelAccount
|
||||
if id := strings.TrimSpace(acct.ID); id != "" {
|
||||
for _, cur := range accounts[platform] {
|
||||
if cur.ID == id {
|
||||
stored = cur
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
acct.Config = mergeChannelConfig(stored.Config, acct.Config)
|
||||
return UpsertChannel(accounts, platform, acct)
|
||||
}
|
||||
|
||||
// mergeChannelConfig 把补丁合并进已存 config,返回一个新 map(stored 与 patch 均不被改动)。
|
||||
// 缺位的键保留已存值;nil/空白的补丁值删除该键;其余覆盖。空补丁是恒等变换。
|
||||
func mergeChannelConfig(stored, patch map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(stored)+len(patch))
|
||||
for k, v := range stored {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range patch {
|
||||
if isEmptyConfigValue(v) {
|
||||
delete(out, k)
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isEmptyConfigValue 报告一个补丁值是否表示「清除该键」:显式 nil,或只含空白的字符串。
|
||||
// 其它类型(数字/布尔/对象/数组)永远不算空 —— 0 与 false 是真实取值,不是「未设置」。
|
||||
func isEmptyConfigValue(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return strings.TrimSpace(t) == ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteChannel returns accounts with the account of the given id removed from
|
||||
// its platform. When the platform has no remaining accounts the platform key is
|
||||
// dropped entirely, so a reload of that platform yields no adapters.
|
||||
|
||||
@@ -211,6 +211,101 @@ func TestUpsertChannelAssignsIDAndReplaces(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
|
||||
Reference in new issue
Block a user