package notify
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
)
// singleMsg 构造一条带引号与换行的单发消息。刻意用含 `"` 与 `\n` 的标题/摘要:
// 这正是模板插值最容易产出的非法 JSON 的输入。
func singleMsg() Message {
return Message{
Items: []Item{{
FindingID: 42,
Name: `登录处 "SQL注入" 风险`,
VulnClass: "SQL注入",
Severity: "high",
Summary: "参数 id\n未过滤 导致注入",
Assets: []string{"a.example.com", "b.example.com"},
DetailURL: "https://artex.local/function/findings/detail?id=42",
}},
}
}
// batchMsg 构造一批汇总消息。
func batchMsg(n int) Message {
m := Message{Batch: true, WindowMinutes: 30, HomeURL: "https://artex.local/function/findings"}
for i := 0; i < n; i++ {
m.Items = append(m.Items, Item{
FindingID: int64(i + 1),
Name: "漏洞" + itoa(i+1),
VulnClass: "XSS",
Severity: "medium",
Summary: "反射型跨站脚本",
Assets: []string{"target.example.com"},
})
}
return m
}
// capturePost 起一个假接收端,把收到的请求体与头回传给断言函数。
func capturePost(t *testing.T, respBody string, assert func(t *testing.T, body map[string]any, r *http.Request)) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var body map[string]any
if len(raw) > 0 {
if err := json.Unmarshal(raw, &body); err != nil {
t.Errorf("请求体不是合法 JSON: %v\n原文: %s", err, raw)
}
}
if assert != nil {
assert(t, body, r)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, respBody)
}))
t.Cleanup(srv.Close)
return srv
}
func TestDingTalkSendsActionCardWhenLinkPresent(t *testing.T) {
srv := capturePost(t, `{"errcode":0,"errmsg":"ok"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
if body["msgtype"] != "actionCard" {
t.Fatalf("有回链时应发 actionCard,得到 %v", body["msgtype"])
}
card, _ := body["actionCard"].(map[string]any)
if card["singleURL"] != "https://artex.local/function/findings/detail?id=42" {
t.Errorf("回链丢失: %v", card["singleURL"])
}
})
if _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
}
func TestDingTalkFallsBackToMarkdownForBatch(t *testing.T) {
srv := capturePost(t, `{"errcode":0,"errmsg":"ok"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
if body["msgtype"] != "markdown" {
t.Fatalf("汇总消息应发 markdown,得到 %v", body["msgtype"])
}
md, _ := body["markdown"].(map[string]any)
if !strings.Contains(md["text"].(string), "최근 30분간") {
t.Errorf("汇总正文缺少时间窗: %v", md["text"])
}
})
if _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, batchMsg(3)); err != nil {
t.Fatalf("投递失败: %v", err)
}
}
// TestDingTalkBusinessErrorIsPermanent 锁住「HTTP 200 但 errcode 非 0」的判定。
// 不检查 errcode 会把投递失败记成成功——这是各家国内 IM 平台共有的坑。
func TestDingTalkBusinessErrorIsPermanent(t *testing.T) {
srv := capturePost(t, `{"errcode":310000,"errmsg":"keywords not in content"}`, nil)
_, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg())
if err == nil {
t.Fatal("errcode 非 0 应报错")
}
if !IsPermanent(err) {
t.Fatalf("关键词不匹配属于配置错误,应标记为永久失败,得到 %v", err)
}
if !strings.Contains(err.Error(), "310000") {
t.Errorf("错误信息应带上平台错误码,得到 %v", err)
}
}
func TestWeComTruncatesCJKWithinByteLimit(t *testing.T) {
var contentLen int
srv := capturePost(t, `{"errcode":0,"errmsg":"ok"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
md, _ := body["markdown"].(map[string]any)
content, _ := md["content"].(string)
contentLen = len(content)
if !utf8.ValidString(content) {
t.Fatal("截断后不是合法 UTF-8——企微会整条拒收")
}
})
// 造一批足够长的中文汇总,必然超过 4096 字节。
m := batchMsg(200)
if _, err := (weComChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, m); err != nil {
t.Fatalf("投递失败: %v", err)
}
if contentLen > weComMarkdownLimit {
t.Fatalf("正文 %d 字节超出企微上限 %d", contentLen, weComMarkdownLimit)
}
if contentLen == 0 {
t.Fatal("正文为空")
}
}
func TestWeComRateLimitIsRetryableButKeyErrorIsPermanent(t *testing.T) {
limited := capturePost(t, `{"errcode":45009,"errmsg":"api freq out of limit"}`, nil)
_, err := (weComChannel{}).Send(context.Background(), map[string]any{"webhook": limited.URL}, singleMsg())
if err == nil || IsPermanent(err) {
t.Fatalf("45009 是滚动窗口限流,应可重试,得到 %v", err)
}
badKey := capturePost(t, `{"errcode":93000,"errmsg":"invalid webhook url"}`, nil)
_, err = (weComChannel{}).Send(context.Background(), map[string]any{"webhook": badKey.URL}, singleMsg())
if err == nil || !IsPermanent(err) {
t.Fatalf("93000 是 key 无效,重试不会自愈,应为永久失败,得到 %v", err)
}
}
func TestFeishuCardStructureAndSign(t *testing.T) {
const secret = "SECtest123"
srv := capturePost(t, `{"code":0,"msg":"success"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
if body["msg_type"] != "interactive" {
t.Fatalf("应发交互式卡片,得到 %v", body["msg_type"])
}
card, _ := body["card"].(map[string]any)
header, _ := card["header"].(map[string]any)
if header["template"] != "orange" {
t.Errorf("high 级别应为 orange 配色,得到 %v", header["template"])
}
// 配了 secret 就必须带加签参数,否则飞书会以 19021 拒收。
if body["sign"] == nil || body["timestamp"] == nil {
t.Fatalf("缺少加签参数: %v", body)
}
// 卡片元素里应包含一个按钮,其 url 指向漏洞详情。
elements, _ := card["elements"].([]any)
foundButton := false
for _, e := range elements {
em, _ := e.(map[string]any)
if em["tag"] != "action" {
continue
}
actions, _ := em["actions"].([]any)
for _, a := range actions {
am, _ := a.(map[string]any)
if am["url"] == "https://artex.local/function/findings/detail?id=42" {
foundButton = true
}
}
}
if !foundButton {
t.Fatal("卡片里没有指向详情页的按钮")
}
})
cfg := map[string]any{"webhook": srv.URL, "secret": secret}
if _, err := (feishuChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
}
func TestFeishuWithoutSecretOmitsSign(t *testing.T) {
srv := capturePost(t, `{"code":0,"msg":"success"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
if body["sign"] != nil || body["timestamp"] != nil {
t.Fatalf("未配置 secret 时不应带加签参数: %v", body)
}
})
if _, err := (feishuChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
}
func TestTelegramEscapesHTMLInUntrustedContent(t *testing.T) {
var text string
srv := capturePost(t, `{"ok":true}`, func(t *testing.T, body map[string]any, _ *http.Request) {
text, _ = body["text"].(string)
if body["parse_mode"] != "HTML" {
t.Fatalf("应使用 HTML 解析模式,得到 %v", body["parse_mode"])
}
})
m := Message{Items: []Item{{
Severity: "high",
// 标题与摘要来自被测目标/模型输出,是不可信内容。
Name: ``,
Summary: "a & b < c",
}}}
if _, err := (telegramChannel{}).Send(context.Background(),
map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": srv.URL}, m); err != nil {
t.Fatalf("投递失败: %v", err)
}
if strings.Contains(text, "`,
Summary: "a & b > c",
DetailURL: `https://artex.local/x?a="onmouseover=alert(1)`,
}},
}
html := htmlBody(m, 0)
if strings.Contains(html, "