Files
affiliate_dash/backend/internal/service/recharge_mask_test.go
T
yml2213 7f984b0f4e fix(security): 告警渠道 webhook 与 Bark Device Key 响应脱敏
AlertNotifyChannel.Config 原先以真值返回。钉钉/飞书/企微的 webhook URL 与 Bark
的 device key 本身就是凭据,泄露即可直接向对应群或设备推送消息,而列表接口对
具备 recharge:manage 的商户成员可见。

- 新增 service.MaskAlertChannel(s):在 HTTP 响应层脱敏,只保留密钥末 4 位。
  webhook 保留域名与路径以便辨认是哪一台机器人(钉钉/企微的 token 在查询串,
  飞书的 uuid 在路径末段),Server 属主机地址不遮蔽
- UpdateAlertChannel 识别回传的脱敏占位值并沿用库中真实密钥,使「只改名称/开关」
  不会被掩码覆盖;填写新值则正常替换
- 脱敏刻意不放在 service 层:CheckLowBalanceAndNotify 复用 ListAlertChannels
  的结果真正去发消息,在那一层脱敏会直接打断告警发送
- 前端表单补充说明文案

验证:新增 6 组测试,覆盖脱敏规则、三种机器人 URL 形态、脱敏结果仍能通过校验、
掩码回传不覆盖真实密钥,以及「service 层必须返回真值」这条关键边界。
2026-10-06 19:49:11 +08:00

230 lines
7.8 KiB
Go
Raw 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 service
import (
"strings"
"testing"
"affiliate_dash/internal/model"
)
func TestMaskAlertSecret(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"空值原样返回", "", ""},
{"纯空白原样返回", " ", ""},
{"长度不足 4 位全部遮蔽", "abcd", alertSecretMask},
{"正好 5 位保留末 4 位", "abcde", alertSecretMask + "bcde"},
{"长密钥保留末 4 位", "access_token_value_1234", alertSecretMask + "1234"},
{"前后空白被裁剪", " secret1234 ", alertSecretMask + "1234"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := MaskAlertSecret(tc.input)
if got != tc.want {
t.Fatalf("期望 %q,实际 %q", tc.want, got)
}
})
}
}
func TestIsMaskedAlertSecret(t *testing.T) {
if !IsMaskedAlertSecret(alertSecretMask + "1234") {
t.Fatal("整体掩码(Bark Key)应被识别")
}
if !IsMaskedAlertSecret("https://oapi.dingtalk.com/robot/send?access_token=" + alertSecretMask + "1234") {
t.Fatal("嵌在 URL 中间的掩码应被识别")
}
if IsMaskedAlertSecret("real-secret-1234") {
t.Fatal("真实密钥不应被误判为脱敏值")
}
if IsMaskedAlertSecret("") {
t.Fatal("空值不应被当作脱敏值")
}
}
// TestMaskAlertWebhookURL 覆盖三种真实机器人的 URL 形态:
// 钉钉/企微把 token 放在查询串,飞书放在路径末段。
func TestMaskAlertWebhookURL(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{
name: "钉钉查询串保留域名与路径",
input: "https://oapi.dingtalk.com/robot/send?access_token=abcdef123456",
want: "https://oapi.dingtalk.com/robot/send?access_token=" + alertSecretMask + "3456",
},
{
name: "企业微信查询串",
input: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=aaaa-bbbb-cccc-dddd",
want: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=" + alertSecretMask + "dddd",
},
{
name: "飞书路径末段 uuid 被遮蔽",
input: "https://open.feishu.cn/open-apis/bot/v2/hook/11111111-2222-3333-4444-555555555555",
want: "https://open.feishu.cn/open-apis/bot/v2/hook/" + alertSecretMask + "5555",
},
{
name: "短路径末段不误伤(钉钉 send)",
input: "https://oapi.dingtalk.com/robot/send",
want: "https://oapi.dingtalk.com/robot/send",
},
{
name: "空值原样返回",
input: "",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := maskAlertWebhookURL(tc.input)
if got != tc.want {
t.Fatalf("期望 %q,实际 %q", tc.want, got)
}
if strings.Contains(got, "%2A") {
t.Fatalf("掩码里的 * 不应被百分号转义:%q", got)
}
})
}
}
// TestMaskAlertWebhookURLKeepsURLValid 脱敏结果必须仍是合法 http(s) URL,
// 否则前端原样回传时会撞上 ValidateAlertChannelInput。
func TestMaskAlertWebhookURLKeepsURLValid(t *testing.T) {
urls := []string{
"https://oapi.dingtalk.com/robot/send?access_token=abcdef123456",
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=aaaa-bbbb-cccc-dddd",
"https://open.feishu.cn/open-apis/bot/v2/hook/11111111-2222-3333-4444-555555555555",
}
for _, raw := range urls {
masked := maskAlertWebhookURL(raw)
if err := ValidateAlertChannelInput(AlertChannelInput{
ChannelType: model.AlertChannelDingtalk,
Config: model.AlertChannelConfig{WebhookURL: masked},
}); err != nil {
t.Fatalf("脱敏后的 URL 未通过校验:%q -> %q (%v)", raw, masked, err)
}
}
}
func TestMaskAlertChannelKeepsServer(t *testing.T) {
channel := model.AlertNotifyChannel{
ChannelType: model.AlertChannelBark,
Name: "老板手机",
Config: model.AlertChannelConfig{
Server: "https://api.day.app",
Key: "device-key-9876",
},
}
masked := MaskAlertChannel(channel)
if masked.Config.Server != "https://api.day.app" {
t.Fatalf("Server 是主机地址而非密钥,不应被遮蔽,实际 %q", masked.Config.Server)
}
if masked.Config.Key != alertSecretMask+"9876" {
t.Fatalf("Key 应被遮蔽,实际 %q", masked.Config.Key)
}
if channel.Config.Key != "device-key-9876" {
t.Fatal("MaskAlertChannel 不应修改入参")
}
}
func TestMaskAlertChannelsNil(t *testing.T) {
if MaskAlertChannels(nil) != nil {
t.Fatal("nil 输入应返回 nil,便于直接替换响应体")
}
}
// TestUpdateAlertChannelWithMaskedSecret 验证脱敏值回传时不会把真实密钥覆盖成掩码。
func TestUpdateAlertChannelWithMaskedSecret(t *testing.T) {
db := newServiceTestDB(t)
merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-alert-mask", 0, -1, 100)
svc := NewRechargeService(db, NewFulfillmentService(db, nil), t.TempDir())
const realWebhook = "https://oapi.dingtalk.com/robot/send?access_token=realtoken1234"
created, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{
ChannelType: model.AlertChannelDingtalk,
Name: "财务群",
Enabled: true,
Config: model.AlertChannelConfig{WebhookURL: realWebhook},
})
if err != nil {
t.Fatalf("创建渠道失败: %v", err)
}
// 模拟前端:拿到脱敏值后只改名称,原样回传 webhook。
maskedForm := MaskAlertChannel(*created)
if maskedForm.Config.WebhookURL == realWebhook {
t.Fatal("响应中的 webhook 应已脱敏")
}
updated, err := svc.UpdateAlertChannel(merchantID, created.ID, AlertChannelInput{
ChannelType: maskedForm.ChannelType,
Name: "财务群(改名)",
Enabled: false,
Config: maskedForm.Config,
})
if err != nil {
t.Fatalf("以脱敏值更新应被接受: %v", err)
}
if updated.Name != "财务群(改名)" || updated.Enabled {
t.Fatalf("名称/开关应被更新,实际 %+v", updated)
}
if updated.Config.WebhookURL != realWebhook {
t.Fatalf("真实密钥被掩码覆盖了:%q", updated.Config.WebhookURL)
}
// 重新读取,确认落库值仍是真实密钥(否则告警会发不出去)。
var stored model.AlertNotifyChannel
if err := db.First(&stored, created.ID).Error; err != nil {
t.Fatalf("读取渠道失败: %v", err)
}
if stored.Config.WebhookURL != realWebhook {
t.Fatalf("库中密钥被掩码覆盖:%q", stored.Config.WebhookURL)
}
// 用户主动填写新密钥时,必须真正替换。
const newWebhook = "https://oapi.dingtalk.com/robot/send?access_token=newtoken5678"
replaced, err := svc.UpdateAlertChannel(merchantID, created.ID, AlertChannelInput{
ChannelType: model.AlertChannelDingtalk,
Name: "财务群(改名)",
Enabled: true,
Config: model.AlertChannelConfig{WebhookURL: newWebhook},
})
if err != nil {
t.Fatalf("更新新密钥失败: %v", err)
}
if replaced.Config.WebhookURL != newWebhook {
t.Fatalf("新密钥未被写入:%q", replaced.Config.WebhookURL)
}
}
// TestAlertChannelsServiceStillReturnsRawSecret 卡住一个关键边界:
// service 层不能脱敏,否则余额巡检拿不到真实 webhook 就发不出告警。
func TestAlertChannelsServiceStillReturnsRawSecret(t *testing.T) {
db := newServiceTestDB(t)
merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-alert-raw", 0, -1, 100)
svc := NewRechargeService(db, NewFulfillmentService(db, nil), t.TempDir())
const realWebhook = "https://oapi.dingtalk.com/robot/send?access_token=rawtoken9999"
if _, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{
ChannelType: model.AlertChannelDingtalk,
Name: "财务群",
Enabled: true,
Config: model.AlertChannelConfig{WebhookURL: realWebhook},
}); err != nil {
t.Fatalf("创建渠道失败: %v", err)
}
list, err := svc.ListAlertChannels(merchantID)
if err != nil || len(list) != 1 {
t.Fatalf("期望 1 个渠道,实际 %d,err=%v", len(list), err)
}
if list[0].Config.WebhookURL != realWebhook {
t.Fatalf("service 层必须返回真实密钥(供发送告警使用),实际 %q", list[0].Config.WebhookURL)
}
}