package notify import ( "errors" "strings" "testing" ) func TestMaskedValueHidesBodyButKeepsTailHint(t *testing.T) { const secret = "https://oapi.dingtalk.com/robot/send?access_token=abcdef123456" got := MaskedValue(secret) if strings.Contains(got, "abcdef123456") { t.Fatalf("掩码值泄露了完整凭据: %q", got) } if strings.Contains(got, "oapi.dingtalk.com") { t.Fatalf("掩码值不应暴露地址主体: %q", got) } // 末 6 位要保留,用户才能认出是哪个机器人。 if !strings.HasSuffix(got, "123456") { t.Fatalf("应保留末 6 位作为辨识提示: %q", got) } if !IsMasked(got) { t.Fatalf("掩码值必须能被 IsMasked 识别: %q", got) } } func TestMaskedValueShortSecretGivesNoHint(t *testing.T) { // 短凭据如果也暴露末 6 位,等于把整个凭据暴露出去。 for _, s := range []string{"abc", "abcdef", ""} { got := MaskedValue(s) if got != MaskedPrefix { t.Fatalf("长度 %d 的凭据不应给出尾部提示,得到 %q", len(s), got) } if s != "" && strings.Contains(got, s) { t.Fatalf("掩码值包含了原值: %q", got) } } } func TestMaskConfigMasksOnlySecrets(t *testing.T) { cfg := map[string]any{ "webhook": "https://example.com/hook?token=SECRETVALUE", "secret": "SECtest123456", "port": float64(587), "host": "smtp.example.com", } masked := MaskConfig(KindDingTalk, cfg) for _, k := range []string{"webhook", "secret"} { s, _ := masked[k].(string) if !IsMasked(s) { t.Errorf("%s 应被掩码,得到 %q", k, s) } } // 非凭据字段必须原样保留,否则 UI 无法展示。 if masked["port"] != float64(587) { t.Errorf("非凭据字段 port 不应改动: %v", masked["port"]) } } func TestMaskConfigUnknownKindReturnsEmpty(t *testing.T) { // 渠道类型无法识别时,宁可让 UI 显示空配置,也不要把可能含凭据的原始内容吐回去。 got := MaskConfig("nope", map[string]any{"webhook": "https://x/y?token=LEAK"}) if len(got) != 0 { t.Fatalf("未知渠道类型应返回空配置,得到 %v", got) } } func TestMaskConfigDoesNotMutateInput(t *testing.T) { // 掩码是展示层行为,不能反过来把库里的真值改掉。 cfg := map[string]any{"webhook": "https://example.com/hook", "secret": "SECtest123456"} _ = MaskConfig(KindDingTalk, cfg) if IsMasked(cfg["secret"].(string)) { t.Fatal("MaskConfig 修改了入参,会导致真实凭据被掩码值覆盖") } } func TestMergeConfigKeepsStoredOnMaskedIncoming(t *testing.T) { stored := map[string]any{"webhook": "https://real/hook", "secret": "REALSECRET", "method": "POST"} // 用户只改了 method,浏览器提交的是掩码值+新 method。 incoming := map[string]any{ "webhook": MaskedValue("https://real/hook"), "secret": MaskedValue("REALSECRET"), "method": "PUT", } got := MergeConfig(stored, incoming) if got["webhook"] != "https://real/hook" || got["secret"] != "REALSECRET" { t.Fatalf("掩码字段应保留库中原值,得到 %v", got) } if got["method"] != "PUT" { t.Fatalf("被修改的字段应生效,得到 %v", got["method"]) } } func TestMergeConfigEmptyStringClears(t *testing.T) { stored := map[string]any{"webhook": "https://real/hook", "secret": "REALSECRET"} got := MergeConfig(stored, map[string]any{"secret": ""}) if _, ok := got["secret"]; ok { t.Fatalf("空串应清空该字段,得到 %v", got) } // 没提到的字段保留(局部更新语义)。 if got["webhook"] != "https://real/hook" { t.Fatalf("未提及的字段应保留,得到 %v", got) } } func TestMergeConfigKeepsUnmentionedStoredKeys(t *testing.T) { stored := map[string]any{"host": "smtp.example.com", "port": float64(587), "password": "pw"} got := MergeConfig(stored, map[string]any{"port": float64(465)}) if got["host"] != "smtp.example.com" || got["password"] != "pw" { t.Fatalf("未提及的字段应保留,得到 %v", got) } if got["port"] != float64(465) { t.Fatalf("已提及的字段应更新,得到 %v", got["port"]) } } // TestPrepareConfigUpdateBlocksDestinationSwap 是本包最重要的一条安全不变量: // **改目标地址不能把旧凭据带过去**。 // // 这些用例用的正是攻击形状的输入(只改地址、对凭据避而不谈), // 而不是「防御逻辑的正确输入」——只测后者的话,防御没生效也照样全绿。 func TestPrepareConfigUpdateBlocksDestinationSwap(t *testing.T) { cases := []struct { name string kind string stored map[string]any incoming map[string]any // wantMissing 是预期被点名的凭据键。 wantMissing string }{ { name: "通用 Webhook 改地址想沿用 Authorization 头", kind: KindWebhook, stored: map[string]any{ "url": "https://legit.example.com/hook", "headers": map[string]any{"Authorization": "Bearer REAL-TOKEN"}, }, incoming: map[string]any{"url": "https://attacker.tld/c"}, wantMissing: "headers", }, { name: "Telegram 改 base_url 想把 Bot Token 发到自己的端点", kind: KindTelegram, stored: map[string]any{"bot_token": "123456:REAL", "chat_id": "1", "base_url": "https://api.telegram.org"}, incoming: map[string]any{"base_url": "https://attacker.tld"}, wantMissing: "bot_token", }, { name: "邮件改 SMTP 主机想交出密码", kind: KindEmail, stored: map[string]any{"host": "smtp.corp.com", "port": 587, "password": "REALPW", "from": "a@b.c", "to": []any{"d@e.f"}}, incoming: map[string]any{"host": "smtp.attacker.tld"}, wantMissing: "password", }, { name: "邮件关掉 TLS 也必须重新表态密码", kind: KindEmail, stored: map[string]any{"host": "smtp.corp.com", "port": 587, "tls": false, "password": "REALPW", "from": "a@b.c", "to": []any{"d@e.f"}}, incoming: map[string]any{"tls": true}, wantMissing: "password", }, { // 掩码值 = 「沿用旧凭据」,在地址变更的语境下同样必须拒绝。 name: "回传掩码凭据 + 新地址", kind: KindTelegram, stored: map[string]any{"bot_token": "123456:REAL", "chat_id": "1", "base_url": "https://api.telegram.org"}, incoming: map[string]any{"base_url": "https://attacker.tld", "bot_token": MaskedValue("123456:REAL")}, wantMissing: "bot_token", }, { name: "钉钉改 Webhook 想沿用加签密钥", kind: KindDingTalk, stored: map[string]any{"webhook": "https://oapi.dingtalk.com/robot/send?access_token=OLD", "secret": "REALSEC"}, incoming: map[string]any{"webhook": "https://attacker.tld/hook"}, wantMissing: "secret", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { merged, err := PrepareConfigUpdate(tc.kind, tc.stored, tc.incoming) if err == nil { t.Fatalf("改地址却未重新表态凭据,应当被拒绝;得到配置 %v", merged) } var target *ErrDestinationChangedWithoutCredentials if !errors.As(err, &target) { t.Fatalf("应返回专门的错误类型以便接口给出可操作提示,得到 %T: %v", err, err) } found := false for _, m := range target.Missing { if m == tc.wantMissing { found = true } } if !found { t.Fatalf("应点名缺失的凭据键 %q,得到 %v", tc.wantMissing, target.Missing) } // 错误信息要能指导操作者怎么修。 if !strings.Contains(err.Error(), tc.wantMissing) { t.Errorf("错误信息应提到 %q: %v", tc.wantMissing, err) } }) } } // TestPrepareConfigUpdateAllowsLegitimateEdits 反向用例:正常的编辑不能被误拦, // 否则这个防护会因为「太烦」而被绕过或删掉。 func TestPrepareConfigUpdateAllowsLegitimateEdits(t *testing.T) { cases := []struct { name string kind string stored map[string]any incoming map[string]any }{ { name: "只改名字(配置原样回传)", kind: KindWebhook, stored: map[string]any{"url": "https://legit.example.com/hook", "headers": map[string]any{"Authorization": "Bearer REAL"}}, incoming: map[string]any{"url": MaskedValue("https://legit.example.com/hook")}, }, { name: "只改请求方法,地址与凭据都不动", kind: KindWebhook, stored: map[string]any{"url": "https://legit.example.com/hook", "method": "POST"}, incoming: map[string]any{"method": "PUT"}, }, { name: "换地址并**同时**给新凭据", kind: KindWebhook, stored: map[string]any{"url": "https://old.example.com/hook", "headers": map[string]any{"Authorization": "Bearer OLD"}}, incoming: map[string]any{"url": "https://new.example.com/hook", "headers": map[string]any{"Authorization": "Bearer NEW"}}, }, { name: "换地址并显式声明不再需要凭据", kind: KindWebhook, stored: map[string]any{"url": "https://old.example.com/hook", "headers": map[string]any{"Authorization": "Bearer OLD"}}, incoming: map[string]any{"url": "https://new.example.com/hook", "headers": ""}, }, { name: "Telegram 改 chat_id(不是目的地)", kind: KindTelegram, stored: map[string]any{"bot_token": "t", "chat_id": "1", "base_url": "https://api.telegram.org"}, incoming: map[string]any{"chat_id": "-100200"}, }, { name: "邮件改收件人(不是目的地)", kind: KindEmail, stored: map[string]any{"host": "smtp.corp.com", "port": 587, "password": "PW", "from": "a@b.c", "to": []any{"x@y.z"}}, incoming: map[string]any{"to": []any{"new@y.z"}}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { merged, err := PrepareConfigUpdate(tc.kind, tc.stored, tc.incoming) if err != nil { t.Fatalf("合法编辑被误拦: %v", err) } if merged == nil { t.Fatal("应返回合并结果") } }) } } // TestPrepareConfigUpdatePortTypeTolerance 覆盖一个容易误判的细节: // 前端提交的端口是 JSON number(float64),库里读回来也是 float64, // 但两个值的类型可能不同(如 int vs float64)。用 == 比较会把「没改」判成「改了」, // 从而对只改了名字的用户弹出「请重新填写密码」——假警报会让人不再信任这个防护。 func TestPrepareConfigUpdatePortTypeTolerance(t *testing.T) { stored := map[string]any{"host": "smtp.corp.com", "port": float64(587), "password": "PW"} // 同一个端口,以 int 形式提交。 if _, err := PrepareConfigUpdate(KindEmail, stored, map[string]any{"port": 587}); err != nil { t.Fatalf("端口值相同(仅类型不同)不应被判为地址变更: %v", err) } // 真的换了端口则必须拦。 if _, err := PrepareConfigUpdate(KindEmail, stored, map[string]any{"port": 25}); err == nil { t.Fatal("端口变更应被拦下") } } // TestPrepareConfigUpdateSurvivesRepeatedSaveWithBlankDestination 覆盖「可留空的 // 目的地字段」这条路径:Telegram 的 base_url 留空表示用官方 API 地址。 // // 曾经这里会让渠道从第二次保存起永久保存失败: // // 新建时库里存下 base_url:""(创建路径直接存前端提交的 config,不走 MergeConfig) // → 第一次保存,MergeConfig 把空串当显式清空、delete 掉该键 // → 第二次保存,incoming 仍是 ""、而 stored 里已经没这个键,被判成「地址变了」 // → bot_token 是掩码回显值 → 400「目标地址已变更,请同时重新填写凭据字段」 // // 用户什么都没改,却从此再也存不上,除非重新粘贴一遍 Bot Token。 func TestPrepareConfigUpdateSurvivesRepeatedSaveWithBlankDestination(t *testing.T) { stored := map[string]any{"bot_token": "123:ABC", "chat_id": "-100", "base_url": ""} // 前端 buildConfig() 对该渠道的每个字段定义都提交一个值:凭据回填掩码, // 空文本框提交空串。这里完整复现它的输出,而不是只提交「改动的键」。 submit := func() map[string]any { return map[string]any{ "bot_token": MaskedValue("123:ABC"), "chat_id": "-100", "base_url": "", } } // 第一次保存:只改了渠道名字,config 原样回传。 merged, err := PrepareConfigUpdate(KindTelegram, stored, submit()) if err != nil { t.Fatalf("第一次保存被误拦: %v", err) } if _, ok := merged["base_url"]; ok { t.Fatal("前提已变:空串应被 MergeConfig 删除——本用例要覆盖的正是「键消失之后」那一步") } // 第二次保存:提交内容与上次完全一致,用户什么都没改。 merged2, err := PrepareConfigUpdate(KindTelegram, merged, submit()) if err != nil { t.Fatalf("第二次保存被误拦(用户什么都没改): %v", err) } // 第三次,确认不是「只错一次」而是稳定可保存。 if _, err := PrepareConfigUpdate(KindTelegram, merged2, submit()); err != nil { t.Fatalf("第三次保存被误拦: %v", err) } // 凭据必须一路保留下来,没有被空串逻辑连带清掉。 if got := merged2["bot_token"]; got != "123:ABC" { t.Fatalf("Bot Token 应沿用原值,得到 %v", got) } } // TestPrepareConfigUpdateStillGuardsBlankDestinationChanges 是上一条用例的配对 // 断言:把空串与「键不存在」视为等价,**不能**连带放过真正的地址变更。 // 这两个方向都是真实的凭据外发路径——Telegram 的 Bot Token 走在 URL 路径里, // 换了 base_url 就等于把 Token 送给新地址。 func TestPrepareConfigUpdateStillGuardsBlankDestinationChanges(t *testing.T) { // 方向一:从「空」(官方地址)换到自建地址。 official := map[string]any{"bot_token": "123:ABC", "chat_id": "-100"} if _, err := PrepareConfigUpdate(KindTelegram, official, map[string]any{ "bot_token": MaskedValue("123:ABC"), "base_url": "https://tg-proxy.attacker.tld", }); err == nil { t.Fatal("从官方地址换到自建地址必须要求重新填写 Token") } // 方向二:把自建地址清空(= 换回官方 API)同样是地址变更。 proxied := map[string]any{"bot_token": "123:ABC", "base_url": "https://proxy.internal/bot"} if _, err := PrepareConfigUpdate(KindTelegram, proxied, map[string]any{ "bot_token": MaskedValue("123:ABC"), "base_url": "", }); err == nil { t.Fatal("清空自建地址(换回官方 API)同样是地址变更,必须要求重新填写 Token") } } func TestDestinationKeysDeclaredForEveryKind(t *testing.T) { // 与 SecretKeys 同理:渠道若忘记声明目的地键,PrepareConfigUpdate 就保护不到它。 for kind, ch := range registry { if len(ch.DestinationKeys()) == 0 { t.Errorf("渠道 %s 未声明目的地键,改地址带出凭据的防护对它无效", kind) } if len(ch.SecretKeys()) == 0 { t.Errorf("渠道 %s 未声明凭据键", kind) } } } func TestSecretKeysDeclaredForEveryKind(t *testing.T) { // 编译器已经强制每个渠道实现 SecretKeys,这里再确认一遍「没有渠道在掩码上 // 交白卷」——返回空切片的渠道意味着它的凭据会明文回显到浏览器。 expect := map[string]bool{ KindDingTalk: true, KindFeishu: true, KindWeCom: true, KindWebhook: true, KindTelegram: true, KindEmail: true, } for kind, ch := range registry { if !expect[kind] { t.Errorf("渠道 %s 未在测试中登记掩码预期", kind) continue } if len(ch.SecretKeys()) == 0 { t.Errorf("渠道 %s 未声明任何凭据字段,其配置会明文回显", kind) } } } // TestPrepareConfigUpdateRejectsMaskedInContainer 覆盖审计指出的一处口子: // 把掩码哨兵塞进**非字符串**结构(如 webhook.headers 是个对象)时, // MergeConfig 只认「字符串且带前缀」为掩码,于是字面量 "__masked__" 会被当成 // 真实头值存进库——后续鉴权静默失效,且没有任何报错。 func TestPrepareConfigUpdateRejectsMaskedInContainer(t *testing.T) { stored := map[string]any{ "url": "https://legit.example.com/hook", "headers": map[string]any{"Authorization": "Bearer REAL"}, } // 对象内部夹带掩码哨兵。 incoming := map[string]any{ "headers": map[string]any{"Authorization": MaskedPrefix}, } if _, err := PrepareConfigUpdate(KindWebhook, stored, incoming); err == nil { t.Fatal("结构体内部夹带掩码哨兵应被拒绝(否则会把字面量存进库)") } // 整体提交对象(真实新值)照常接受。 ok := map[string]any{"headers": map[string]any{"Authorization": "Bearer NEW"}} if _, err := PrepareConfigUpdate(KindWebhook, stored, ok); err != nil { t.Fatalf("正常提交新请求头不应被拦: %v", err) } }