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 层必须返回真值」这条关键边界。
230 lines
7.8 KiB
Go
230 lines
7.8 KiB
Go
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)
|
||
}
|
||
}
|