Files
ARTEX-chixuweihu/server/notify_api_test.go
T
luckoneandClaude Code f3fb37f70d feat(notify): 漏洞发现支持推送到钉钉/飞书/企业微信等六种渠道
新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、
通用 Webhook、Telegram、邮件。同一类型可配任意多个机器人实例,每个实例独立
设启停、限流与过滤规则(最低级别 / 任务资产范围 / 漏洞类型关键词 / 是否接收
处置状态变更)。推送时机分实时与汇总两种模式,「高危实时、其余汇总」建两个
渠道即可表达,策略不写死在代码里。带投递历史与失败重发。

三个关键设计:

1. 写漏洞的事务只做一次盲 INSERT。notification_events 由 RecordFindingTx
   在同一事务内写入,保证「漏洞落库」与「推送任务存在」原子一致;这条 INSERT
   刻意不读渠道表、不跑用户的过滤规则,否则一条配错的过滤条件就能污染甚至
   中止事务,让高危漏洞存不进库。为把失败隔离在这一条语句上(PostgreSQL 中
   事务内任一语句报错会让整个事务作废、连 COMMIT 都失败),它被 SAVEPOINT
   包住,失败时只记日志。

2. 投递用租约领取而非长事务。FOR UPDATE SKIP LOCKED 领取后把行置为 sending、
   把 next_attempt_at 推到未来作为租约,提交后再做网络投递,因此投递期间不
   持有数据库锁;崩溃留下的 sending 行会被下一轮重新领取,自愈且不会无限重试。

3. 限流不消耗重试预算。先按渠道令牌桶算出本轮还能发几条,再按这个数量领取;
   顺序反过来会让被限流挡下的投递白计一次尝试,三次预算被等待耗光后落入失败。

审计并修复的四个问题:

- 推送凭据经错误信息外泄:http.Client.Do 返回的 *url.Error 会把完整 URL 打进
  错误文本,而钉钉 access_token、企业微信 key、飞书 hook id、Telegram
  /bot<token>/ 都就在 URL 里。该串流入 last_error(明文落库)、投递历史接口
  (绕过渠道掩码)、服务端日志与前端错误提示。现统一脱敏为 scheme://host
  + 底层原因,并拒绝跨主机重定向。
- 最低级别门槛打错字会让过滤器静默失效:未知级别序数为 0,判定退化成
  rank >= 0 恒真,用户以为限制「仅高危」实际全推。现于写入路径校验取值。
- 汇总批次无上限:一次领取把全部待发行读进内存、消息被渠道长度上限截掉大半
  且静默丢失。现单批上限 500 条,超出部分留库成为下一批。
- 邮件渠道把 SMTP 4xx(灰名单等临时失败)判成永久失败,导致这类最该重试的
  场景从不重试。现按应答码首位区分 4xx/5xx。

测试:notify 包 82% 覆盖(含用自建最小 SMTP 服务器驱动的完整邮件协议会话、
两种签名算法对 OpenSSL 基准值的比对、UTF-8 边界截断、以及模板能力边界的
反射断言);db 与 server 侧共 29 个用例跑真实 PostgreSQL,其中端到端用例
真发 HTTP 到假接收端并断言消息内容与投递状态。

Co-Authored-By: Claude Code <[email protected]>
2026-09-30 16:25:06 +08:00

766 lines
27 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 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 := &notifyFixture{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)
}
}