package server import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strconv" "strings" "sync" "testing" "time" "github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/notify" ) // 本文件覆盖推送功能的端到端行为:漏洞落库 → 事件 → 分派 → 真发 HTTP。 // // 一个安全上的注意点:这些用例**不调用全局的 Notifier.step()**,只对自己创建的 // 渠道调用 stepRealtime/stepDigest。原因是 step() 会遍历库里所有启用渠道—— // 在一个已配好真实钉钉/企微机器人的开发库上跑测试,全局 step 会把测试期间 // 产生的漏洞真推到那些群里。逐渠道调用把影响面严格限制在测试自造的假接收端上。 // // 清理:用例结束时删掉本用例产生的事件(级联删投递)与渠道,不给真实渠道留积压。 // // 断言口径:stepRealtime/stepDigest 不返回值、内部记日志,因此这里断言的是 // **可观测的外部行为**(假接收端收到了什么、投递行落到什么状态),而不是函数的 // 返回值——这比对返回值打桩更接近真实调用路径。 // notifyFixture 是本文件用例的公共装置。 type notifyFixture struct { s *Server pg *db.DB request func(method, path, body string) *httptest.ResponseRecorder n *Notifier // 自建的 task/exploration:用例把漏洞记到这里,与其它用例的数据隔离。 taskID int64 expID int64 // cleanupMark 之后产生的事件在清理时一并删除。 cleanupMark int64 } func newNotifyFixture(t *testing.T) *notifyFixture { t.Helper() s, _, request := trafficEvidenceServer(t) pg := s.m.pg // 自建一个 task:共享装置 trafficEvidenceServer 造的 task 拿不到 exploration id, // 而记录漏洞必须提供它。 task, err := s.m.CreateTask("通知推送测试", "推送行为验证", nil, 0, 0) if err != nil { t.Fatal(err) } taskID, err := strconv.ParseInt(task.ID, 10, 64) if err != nil { t.Fatal(err) } t.Cleanup(func() { pg.Exec(`DELETE FROM tasks WHERE id=$1`, taskID) }) var mark int64 if err := pg.QueryRow(`SELECT COALESCE(max(id),0) FROM notification_events`).Scan(&mark); err != nil { t.Fatal(err) } // 让装置自成闭环:把建 fixture 之前就存在的事件一次性标记为已分派。 // // 为什么必须做:FanOutPendingEvents 是**全局**的,会把库里所有未分派事件 // 展开到所有匹配渠道上。而共享装置 trafficEvidenceServer 自己就会记一条漏洞 // (正是它返回的那个初始 finding),其它用例也可能有残留。不隔离的话, // 这些杂散事件会被分派到本用例的渠道上,让「应有 N 条投递」这类断言 // 时对时错——而且错法取决于用例执行顺序,比直接失败更难查。 if _, err := pg.Exec(`UPDATE notification_events SET fanned_out = true WHERE id <= $1 AND NOT fanned_out`, mark); err != nil { t.Fatal(err) } f := ¬ifyFixture{s: s, pg: pg, request: request, n: newNotifier(s), taskID: taskID, expID: task.ExpID, cleanupMark: mark} t.Cleanup(func() { if _, err := pg.Exec(`DELETE FROM notification_events WHERE id > $1`, f.cleanupMark); err != nil { t.Logf("清理通知事件失败: %v", err) } }) // 总开关必须是开的(其它用例可能关过它)。 if err := pg.SetBool(settingNotifyEnabled, true); err != nil { t.Fatal(err) } return f } // record 走真实的证据写入路径落一条漏洞,返回 finding id。 // 这条路会在**同一事务**里登记推送事件——正是本功能的挂点。 func (f *notifyFixture) record(t *testing.T, vulnclass, severity string) int64 { t.Helper() out, err := f.s.evidenceStore().Record(context.Background(), db.RecordFindingInput{ TaskID: f.taskID, ExplorationID: f.expID, Worker: "test", VulnClass: vulnclass, Name: vulnclass, Severity: severity, Summary: vulnclass + " 的摘要", Evidence: "poc", }, nil) if err != nil { t.Fatalf("记录漏洞失败: %v", err) } return out.FindingID } // channel 读回渠道配置(供逐渠道调用 stepX 使用)。 func (f *notifyFixture) channel(t *testing.T, id int64) *db.NotificationChannel { t.Helper() ch, err := f.pg.NotificationChannelByID(context.Background(), id) if err != nil { t.Fatalf("读渠道失败: %v", err) } return ch } // deliver 分派事件并只对指定渠道跑一轮投递。 func (f *notifyFixture) deliver(t *testing.T, chID int64, baseURL string) { t.Helper() ctx := context.Background() if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil { t.Fatalf("分派失败: %v", err) } f.n.stepRealtime(ctx, f.channel(t, chID), 50, baseURL) } // createChannel 通过 HTTP 接口建渠道,顺带覆盖接口自身的校验路径。 func (f *notifyFixture) createChannel(t *testing.T, payload map[string]any) int64 { t.Helper() raw, _ := json.Marshal(payload) r := f.request("POST", "/api/notify/channels", string(raw)) if r.Code != 200 { t.Fatalf("建渠道失败 %d: %s", r.Code, r.Body) } var res struct { ID int64 `json:"id"` } if err := json.Unmarshal(r.Body.Bytes(), &res); err != nil || res.ID == 0 { t.Fatalf("建渠道返回异常: %s (%v)", r.Body, err) } t.Cleanup(func() { f.pg.Exec(`DELETE FROM notification_channels WHERE id=$1`, res.ID) }) return res.ID } // fakeWebhook 是记录收到的请求体的假接收端。 type fakeWebhook struct { *httptest.Server mu sync.Mutex bodies []map[string]any } func newFakeWebhook(t *testing.T) *fakeWebhook { t.Helper() f := &fakeWebhook{} f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw, _ := io.ReadAll(r.Body) var body map[string]any _ = json.Unmarshal(raw, &body) f.mu.Lock() f.bodies = append(f.bodies, body) f.mu.Unlock() w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`) })) t.Cleanup(f.Close) return f } func (f *fakeWebhook) count() int { f.mu.Lock() defer f.mu.Unlock() return len(f.bodies) } func (f *fakeWebhook) body(t *testing.T, i int) map[string]any { t.Helper() f.mu.Lock() defer f.mu.Unlock() if i >= len(f.bodies) { t.Fatalf("假接收端只收到 %d 条请求,取不到第 %d 条", len(f.bodies), i) } return f.bodies[i] } func (f *fakeWebhook) last(t *testing.T) map[string]any { t.Helper() if f.count() == 0 { t.Fatal("假接收端没有收到任何请求") } return f.body(t, f.count()-1) } // markdownText 从钉钉 markdown/actionCard 请求体里取出正文。 func markdownText(t *testing.T, body map[string]any) string { t.Helper() if md, ok := body["markdown"].(map[string]any); ok { s, _ := md["text"].(string) return s } if card, ok := body["actionCard"].(map[string]any); ok { s, _ := card["text"].(string) return s } t.Fatalf("请求体里没有 markdown/actionCard 正文: %v", body) return "" } // agePendingBatch 把该渠道的待发投递催老,用于测试汇总批次到期。 func (f *notifyFixture) agePendingBatch(t *testing.T, chID int64) { t.Helper() if _, err := f.pg.Exec(`UPDATE notification_deliveries SET created_at = now() - interval '2 hours' WHERE channel_id=$1 AND state=$2`, chID, db.NotifyStatePending); err != nil { t.Fatal(err) } } func TestNotifyEndToEndRealtimeDelivery(t *testing.T) { f := newNotifyFixture(t) hook := newFakeWebhook(t) chID := f.createChannel(t, map[string]any{ "name": "实时推送", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": hook.URL}, }) f.record(t, "SQL注入", "high") f.deliver(t, chID, "") if hook.count() != 1 { t.Fatalf("应发出 1 条消息,实际 %d", hook.count()) } text := markdownText(t, hook.last(t)) for _, want := range []string{"SQL注入", "高危", "摘要"} { if !strings.Contains(text, want) { t.Fatalf("消息正文缺少 %q:\n%s", want, text) } } // 投递应流转为 sent。 var pending int if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1 AND state <> $2`, chID, db.NotifyStateSent).Scan(&pending); err != nil { t.Fatal(err) } if pending != 0 { t.Fatalf("投递后仍有 %d 条未标记 sent", pending) } } func TestNotifyChannelAPIMasksSecretsAndPreservesOnUpdate(t *testing.T) { f := newNotifyFixture(t) chID := f.createChannel(t, map[string]any{ "name": "掩码用例", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": "https://oapi.dingtalk.com/robot/send?access_token=abc123456", "secret": "SECabcdef123456"}, }) r := f.request("GET", "/api/notify/channels", "") if r.Code != 200 { t.Fatalf("列渠道失败 %d: %s", r.Code, r.Body) } if strings.Contains(r.Body.String(), "abc123456") || strings.Contains(r.Body.String(), "SECabcdef123456") { t.Fatalf("接口回显泄露了凭据: %s", r.Body) } var listed struct { Channels []struct { ID int64 `json:"id"` Config map[string]any `json:"config"` SecretKeys []string `json:"secret_keys"` } `json:"channels"` } if err := json.Unmarshal(r.Body.Bytes(), &listed); err != nil { t.Fatal(err) } var mine *struct { ID int64 `json:"id"` Config map[string]any `json:"config"` SecretKeys []string `json:"secret_keys"` } for i := range listed.Channels { if listed.Channels[i].ID == chID { mine = &listed.Channels[i] } } if mine == nil { t.Fatal("新建的渠道未出现在列表里") } if !notify.IsMasked(fmt.Sprint(mine.Config["webhook"])) || !notify.IsMasked(fmt.Sprint(mine.Config["secret"])) { t.Fatalf("凭据字段应为掩码值: %v", mine.Config) } if len(mine.SecretKeys) == 0 { t.Fatal("接口应告知前端哪些字段是凭据") } // PATCH 只改名 + 回传掩码凭据:真凭据必须原样保留。 body, _ := json.Marshal(map[string]any{ "name": "改名后", "config": map[string]any{"webhook": fmt.Sprint(mine.Config["webhook"]), "secret": fmt.Sprint(mine.Config["secret"])}, }) if r := f.request("PATCH", fmt.Sprintf("/api/notify/channels/%d", chID), string(body)); r.Code != 200 { t.Fatalf("更新失败 %d: %s", r.Code, r.Body) } cfg := f.channelConfig(t, chID) if cfg["webhook"] != "https://oapi.dingtalk.com/robot/send?access_token=abc123456" { t.Fatalf("掩码回传把真凭据覆盖了: %v", cfg["webhook"]) } if cfg["secret"] != "SECabcdef123456" { t.Fatalf("掩码回传把 secret 覆盖了: %v", cfg["secret"]) } if f.channel(t, chID).Name != "改名后" { t.Fatal("名字未更新") } // 显式清空 secret 应生效(区别于「回传掩码=保持不变」)。 body, _ = json.Marshal(map[string]any{"config": map[string]any{"secret": ""}}) if r := f.request("PATCH", fmt.Sprintf("/api/notify/channels/%d", chID), string(body)); r.Code != 200 { t.Fatalf("清空 secret 失败 %d: %s", r.Code, r.Body) } if _, still := f.channelConfig(t, chID)["secret"]; still { t.Fatal("空串应清空 secret") } } func (f *notifyFixture) channelConfig(t *testing.T, id int64) map[string]any { t.Helper() var cfg map[string]any if err := json.Unmarshal(f.channel(t, id).Config, &cfg); err != nil { t.Fatal(err) } return cfg } func TestNotifyChannelAPICreateValidation(t *testing.T) { f := newNotifyFixture(t) cases := []struct { name string payload map[string]any wantSub string }{ {"类型非法", map[string]any{"name": "x", "kind": "nope", "config": map[string]any{}}, "渠道类型无效"}, {"缺名称", map[string]any{"kind": notify.KindDingTalk, "config": map[string]any{"webhook": "https://e.com/h"}}, "缺少渠道名称"}, {"缺 webhook", map[string]any{"name": "x", "kind": notify.KindDingTalk, "config": map[string]any{}}, "Webhook"}, {"webhook 协议非法", map[string]any{"name": "x", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": "file:///etc/passwd"}}, "Webhook 地址无效"}, {"模式非法", map[string]any{"name": "x", "kind": notify.KindDingTalk, "mode": "sometimes", "config": map[string]any{"webhook": "https://e.com/h"}}, "推送模式无效"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { raw, _ := json.Marshal(tc.payload) r := f.request("POST", "/api/notify/channels", string(raw)) if r.Code != 400 { t.Fatalf("应返回 400,得到 %d: %s", r.Code, r.Body) } if !strings.Contains(r.Body.String(), tc.wantSub) { t.Fatalf("错误信息应提到 %q,得到 %s", tc.wantSub, r.Body) } }) } if r := f.request("DELETE", "/api/notify/channels/99999999", ""); r.Code != 404 { t.Fatalf("删除不存在的渠道应 404,得到 %d", r.Code) } } func TestNotifyFilterBlocksBelowThreshold(t *testing.T) { f := newNotifyFixture(t) hook := newFakeWebhook(t) chID := f.createChannel(t, map[string]any{ "name": "仅严重", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": hook.URL}, "filter": map[string]any{"min_severity": "critical"}, }) f.record(t, "低危问题", "low") if _, _, err := f.pg.FanOutPendingEvents(context.Background(), 500); err != nil { t.Fatal(err) } var n int if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&n); err != nil { t.Fatal(err) } if n != 0 { t.Fatalf("低于阈值的漏洞不该产生投递,得到 %d 条", n) } f.n.stepRealtime(context.Background(), f.channel(t, chID), 50, "") if hook.count() != 0 { t.Fatal("被过滤的漏洞不应发出消息") } } func TestNotifyDigestBatchesMultipleFindingsIntoOneMessage(t *testing.T) { f := newNotifyFixture(t) hook := newFakeWebhook(t) chID := f.createChannel(t, map[string]any{ "name": "汇总推送", "kind": notify.KindDingTalk, "mode": db.NotifyModeDigest, "config": map[string]any{"webhook": hook.URL}, }) for i := 0; i < 3; i++ { f.record(t, fmt.Sprintf("汇总漏洞%d", i+1), "high") } ctx := context.Background() if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil { t.Fatal(err) } ch := f.channel(t, chID) // 未到期:不发。 f.n.stepDigest(ctx, ch, "") if hook.count() != 0 { t.Fatal("汇总批次未到期就发了") } // 催老批次后:三条合成一条消息。 f.agePendingBatch(t, chID) f.n.stepDigest(ctx, ch, "") if got := hook.count(); got != 1 { t.Fatalf("三条应汇总成一条消息,实际发了 %d 条", got) } text := markdownText(t, hook.last(t)) if !strings.Contains(text, "近") || !strings.Contains(text, "3 个漏洞") { t.Fatalf("汇总消息缺少条数/时间窗文案:\n%s", text) } for i := 1; i <= 3; i++ { if !strings.Contains(text, fmt.Sprintf("汇总漏洞%d", i)) { t.Fatalf("汇总消息缺少第 %d 条:\n%s", i, text) } } // 同一批次应共享 batch_id。 var distinct, total int if err := f.pg.QueryRow(`SELECT count(DISTINCT batch_id), count(*) FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&distinct, &total); err != nil { t.Fatal(err) } if total != 3 || distinct != 1 { t.Fatalf("三条投递应共享一个 batch_id,得到 distinct=%d total=%d", distinct, total) } } func TestNotifyDisabledChannelDoesNotSend(t *testing.T) { f := newNotifyFixture(t) hook := newFakeWebhook(t) chID := f.createChannel(t, map[string]any{ "name": "停用渠道", "kind": notify.KindDingTalk, "enabled": false, "config": map[string]any{"webhook": hook.URL}, }) f.record(t, "停用期间的漏洞", "critical") if _, _, err := f.pg.FanOutPendingEvents(context.Background(), 500); err != nil { t.Fatal(err) } var n int if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&n); err != nil { t.Fatal(err) } if n != 0 { t.Fatalf("停用渠道不该产生投递,得到 %d 条", n) } } func TestNotifyStatusChangeDelivery(t *testing.T) { f := newNotifyFixture(t) hook := newFakeWebhook(t) chID := f.createChannel(t, map[string]any{ "name": "状态变更订阅", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": hook.URL}, "filter": map[string]any{"on_status_change": true}, }) finding := f.record(t, "状态变更用例", "high") r := f.request("PATCH", fmt.Sprintf("/api/exploration/findings/%d", finding), `{"status":"fixed"}`) if r.Code != 200 { t.Fatalf("改状态失败 %d: %s", r.Code, r.Body) } f.deliver(t, chID, "") // 应有两条:fixed 那一条是状态变更;finding_created 那条也可能在同一轮发出。 // 状态变更的实际上更晚创建,但不依赖顺序,全量找。 found := false for i := 0; i < hook.count(); i++ { text := markdownText(t, hook.body(t, i)) if strings.Contains(text, "状态变更") && strings.Contains(text, "已修复") { found = true } } if !found { t.Fatalf("没有收到含「状态变更 → 已修复」的消息(共 %d 条)", hook.count()) } } func TestNotifyStatusChangeSuppressedByDefault(t *testing.T) { f := newNotifyFixture(t) hook := newFakeWebhook(t) chID := f.createChannel(t, map[string]any{ "name": "不订阅状态变更", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": hook.URL}, }) finding := f.record(t, "不订阅变更", "high") if r := f.request("PATCH", fmt.Sprintf("/api/exploration/findings/%d", finding), `{"status":"false_positive"}`); r.Code != 200 { t.Fatalf("改状态失败 %d: %s", r.Code, r.Body) } if _, _, err := f.pg.FanOutPendingEvents(context.Background(), 500); err != nil { t.Fatal(err) } var n int if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries d JOIN notification_events e ON e.id = d.event_id WHERE d.channel_id=$1 AND e.kind=$2`, chID, notify.EventFindingStatusChanged).Scan(&n); err != nil { t.Fatal(err) } if n != 0 { t.Fatalf("未订阅状态变更的渠道不该收到状态变更投递,得到 %d 条", n) } } func TestNotifyTestMessageEndpoint(t *testing.T) { f := newNotifyFixture(t) hook := newFakeWebhook(t) chID := f.createChannel(t, map[string]any{ "name": "测试发送", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": hook.URL}, }) if r := f.request("POST", fmt.Sprintf("/api/notify/channels/%d/test", chID), ""); r.Code != 200 { t.Fatalf("测试发送失败 %d: %s", r.Code, r.Body) } if hook.count() != 1 { t.Fatalf("假接收端应收到 1 条测试消息,得到 %d", hook.count()) } // 测试消息必须一眼能看出是测试,不能被误当成真实漏洞。 if text := markdownText(t, hook.last(t)); !strings.Contains(text, "测试") { t.Fatalf("测试消息应标明是测试: %s", text) } // 配置坏掉时应把渠道的原始错误如实回给用户。 badID := f.createChannel(t, map[string]any{ "name": "坏地址", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": "http://127.0.0.1:1/hook"}, }) if r := f.request("POST", fmt.Sprintf("/api/notify/channels/%d/test", badID), ""); r.Code != 502 { t.Fatalf("投递失败应回 502,得到 %d: %s", r.Code, r.Body) } } func TestNotifyDeliveriesHistoryAndRetry(t *testing.T) { f := newNotifyFixture(t) // 指向必然失败的地址,制造 failed 投递。 chID := f.createChannel(t, map[string]any{ "name": "失败重试", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": "http://127.0.0.1:1/hook"}, }) f.record(t, "会失败的推送", "high") ctx := context.Background() if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil { t.Fatal(err) } ch := f.channel(t, chID) // 连投到耗尽重试预算。 for i := 0; i < db.MaxNotifyAttempts; i++ { f.n.stepRealtime(ctx, ch, 50, "") if _, err := f.pg.Exec(`UPDATE notification_deliveries SET next_attempt_at = now() - interval '1 minute' WHERE channel_id=$1`, chID); err != nil { t.Fatal(err) } } var state string if err := f.pg.QueryRow(`SELECT state FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&state); err != nil { t.Fatal(err) } if state != db.NotifyStateFailed { t.Fatalf("重试耗尽后应为 failed,得到 %s", state) } r := f.request("GET", fmt.Sprintf("/api/notify/deliveries?channel_id=%d&state=failed", chID), "") if r.Code != 200 { t.Fatalf("查历史失败 %d: %s", r.Code, r.Body) } var hist struct { Deliveries []struct { ID int64 `json:"id"` State string `json:"state"` LastError string `json:"last_error"` Attempts int `json:"attempts"` Title string `json:"title"` } `json:"deliveries"` Total int `json:"total"` } if err := json.Unmarshal(r.Body.Bytes(), &hist); err != nil { t.Fatal(err) } if hist.Total != 1 || len(hist.Deliveries) != 1 { t.Fatalf("应查到 1 条失败投递,得到 total=%d len=%d", hist.Total, len(hist.Deliveries)) } if hist.Deliveries[0].LastError == "" { t.Fatal("历史里应带上失败原因,否则用户无法排查") } if hist.Deliveries[0].Attempts < db.MaxNotifyAttempts { t.Fatalf("尝试次数应被记录,得到 %d", hist.Deliveries[0].Attempts) } if hist.Deliveries[0].Title != "会失败的推送" { t.Fatalf("历史应带出漏洞标题,得到 %q", hist.Deliveries[0].Title) } // 手动重发:应回到 pending 且清零计数。 if r := f.request("POST", fmt.Sprintf("/api/notify/deliveries/%d/retry", hist.Deliveries[0].ID), ""); r.Code != 200 { t.Fatalf("重发失败 %d: %s", r.Code, r.Body) } var attempts int if err := f.pg.QueryRow(`SELECT state, attempts FROM notification_deliveries WHERE id=$1`, hist.Deliveries[0].ID).Scan(&state, &attempts); err != nil { t.Fatal(err) } if state != db.NotifyStatePending || attempts != 0 { t.Fatalf("重发后应为 pending 且 attempts=0,得到 %s/%d", state, attempts) } } func TestNotifyMetaAndSettingsRoundTrip(t *testing.T) { f := newNotifyFixture(t) r := f.request("GET", "/api/notify/meta", "") if r.Code != 200 { t.Fatalf("meta 失败: %s", r.Body) } var meta struct { Kinds []struct { Kind string `json:"kind"` SecretKeys []string `json:"secret_keys"` } `json:"kinds"` } if err := json.Unmarshal(r.Body.Bytes(), &meta); err != nil { t.Fatal(err) } if len(meta.Kinds) != len(notify.Kinds()) { t.Fatalf("meta 应列出全部 %d 个渠道,得到 %d", len(notify.Kinds()), len(meta.Kinds)) } for _, k := range meta.Kinds { if len(k.SecretKeys) == 0 { t.Errorf("渠道 %s 未上报凭据字段", k.Kind) } } // 三项全局设置往返。尾部斜杠应被规范化掉,否则回链会拼出 "//function/..."。 if r := f.request("PUT", "/api/settings", `{"notify_public_base_url":"https://artex.example.com/","notify_digest_interval_min":15,"notify_enabled":true}`); r.Code != 200 { t.Fatalf("写设置失败 %d: %s", r.Code, r.Body) } t.Cleanup(func() { f.pg.Exec(`DELETE FROM settings WHERE key IN ($1,$2)`, settingNotifyPublicBaseURL, settingNotifyDigestMinutes) }) payload := f.s.settingsPayload() if payload["notify_public_base_url"] != "https://artex.example.com" { t.Fatalf("回链地址未规范化: %v", payload["notify_public_base_url"]) } if payload["notify_digest_interval_min"] != 15 { t.Fatalf("汇总周期未生效: %v", payload["notify_digest_interval_min"]) } // 非法值应被拒。 for _, body := range []string{ `{"notify_public_base_url":"ftp://x"}`, `{"notify_digest_interval_min":0}`, `{"notify_digest_interval_min":99999}`, } { if r := f.request("PUT", "/api/settings", body); r.Code != 400 { t.Errorf("%s 应返回 400,得到 %d", body, r.Code) } } } // TestNotifyDeepLinkUsesPublicBaseURL 覆盖回链拼接:配了 public_base_url 时 // 单条消息必须用带按钮的 ActionCard,且链接指向漏洞详情页。 func TestNotifyDeepLinkUsesPublicBaseURL(t *testing.T) { f := newNotifyFixture(t) hook := newFakeWebhook(t) chID := f.createChannel(t, map[string]any{ "name": "回链", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": hook.URL}, }) finding := f.record(t, "带回链的漏洞", "high") f.deliver(t, chID, "https://artex.example.com") body := hook.last(t) card, _ := body["actionCard"].(map[string]any) if card == nil { t.Fatalf("有回链时应用 ActionCard,得到 msgtype=%v", body["msgtype"]) } want := fmt.Sprintf("https://artex.example.com/function/findings/detail?id=%d", finding) if card["singleURL"] != want { t.Fatalf("回链不对\n期望 %s\n得到 %v", want, card["singleURL"]) } } // TestNotifyNoDeepLinkWithoutBaseURL 反向覆盖:没配外部地址时不该产生坏链接 // (比如指向 localhost 或相对路径),应退回纯 markdown。 func TestNotifyNoDeepLinkWithoutBaseURL(t *testing.T) { f := newNotifyFixture(t) hook := newFakeWebhook(t) chID := f.createChannel(t, map[string]any{ "name": "无回链", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": hook.URL}, }) f.record(t, "无回链的漏洞", "high") f.deliver(t, chID, "") body := hook.last(t) if body["msgtype"] != "markdown" { t.Fatalf("未配外部地址时应发 markdown,得到 %v", body["msgtype"]) } if text := markdownText(t, body); strings.Contains(text, "查看详情") { t.Fatalf("未配外部地址时不该出现详情链接:\n%s", text) } } // TestNotifyBackoffTableMatchesAttemptBudget 是防漂移断言。 // // 重试预算(db.MaxNotifyAttempts)与退避序列表(notifyBackoff)分居两个包: // 前者是状态机的策略、后者是引擎的执行节拍。若只改其中一个——比如把预算提到 5 // 次而忘了加退避档位——代码不会报错,只会让第 4、5 次重试沿用最后一档间隔, // 表现为「重试节奏莫名变慢」,排查时很难联想到是这里。 // 断言两者长度一致,让这种漂移在 CI 里就暴露。 func TestNotifyBackoffTableMatchesAttemptBudget(t *testing.T) { if len(notifyBackoff) != db.MaxNotifyAttempts { t.Fatalf("退避档位数(%d)与最大尝试次数(%d)不一致——改一个必须同时改另一个", len(notifyBackoff), db.MaxNotifyAttempts) } // 退避间隔必须单调不减,否则重试会越试越急,反而加剧限流。 for i := 1; i < len(notifyBackoff); i++ { if notifyBackoff[i] < notifyBackoff[i-1] { t.Fatalf("退避间隔必须单调不减:第 %d 档 %v < 第 %d 档 %v", i, notifyBackoff[i], i-1, notifyBackoff[i-1]) } } } // TestNotifyRateLimitDoesNotConsumeRetryBudget 锁住「先取令牌再领取」的顺序。 // 若反了(先领后弃),被限流挡下的投递已经计过一次 attempts, // 预算会被纯粹的等待耗光,最后落进 failed。 func TestNotifyRateLimitDoesNotConsumeRetryBudget(t *testing.T) { // 只测令牌桶本身,不需要 Server(也不该为它造一个)。 n := &Notifier{buckets: map[int64]*notifyBucket{}} now := time.Now() // 每分钟 1 条:满桶时最多 1 条。 if got := n.takeTokens(1, 1, now); got != 1 { t.Fatalf("满桶时每分钟 1 条应取 1 个令牌,得到 %d", got) } if got := n.takeTokens(1, 1, now.Add(time.Millisecond)); got != 0 { t.Fatalf("令牌耗尽后应立即返回 0,得到 %d", got) } if got := n.takeTokens(1, 1, now.Add(30*time.Second)); got != 0 { t.Fatalf("半程不应补满一个令牌,得到 %d", got) } if got := n.takeTokens(1, 1, now.Add(time.Minute)); got != 1 { t.Fatalf("满一个周期应补回 1 个令牌,得到 %d", got) } // 不限流渠道走有限上限,避免单轮被无限积压拖住。 if got := n.takeTokens(2, 0, now); got != notifyUnlimitedBurstPerTick { t.Fatalf("不限流应返回每轮上限 %d,得到 %d", notifyUnlimitedBurstPerTick, got) } // 渠道之间的令牌桶互相独立。 if got := n.takeTokens(1, 1, now.Add(time.Millisecond)); got != 0 { t.Fatalf("渠道 1 的桶应仍然为空,得到 %d", got) } }