package notify
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
)
// singleMsg builds a single-send message with quotes and newlines. Deliberately
// uses a title/summary containing `"` and `\n`: exactly the input that most
// easily produces invalid JSON from template interpolation.
func singleMsg() Message {
return Message{
Items: []Item{{
FindingID: 42,
Name: `login page "SQL injection" risk`,
VulnClass: "SQL injection",
Severity: "high",
Summary: "param id\nunfiltered leads to injection",
Assets: []string{"a.example.com", "b.example.com"},
DetailURL: "https://artex.local/function/findings/detail?id=42",
}},
}
}
// batchMsg builds a batch digest message.
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: "vuln" + itoa(i+1),
VulnClass: "XSS",
Severity: "medium",
Summary: "reflected cross-site scripting",
Assets: []string{"target.example.com"},
})
}
return m
}
// capturePost starts a fake receiver and hands the received request body and
// headers to the assertion function.
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("request body is not valid JSON: %v\nraw: %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("with a backlink it should send actionCard, got %v", body["msgtype"])
}
card, _ := body["actionCard"].(map[string]any)
if card["singleURL"] != "https://artex.local/function/findings/detail?id=42" {
t.Errorf("backlink lost: %v", card["singleURL"])
}
})
if _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg()); err != nil {
t.Fatalf("delivery failed: %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("a batch message should send markdown, got %v", body["msgtype"])
}
md, _ := body["markdown"].(map[string]any)
if !strings.Contains(md["text"].(string), "in the last 30 minutes") {
t.Errorf("batch body missing the time window: %v", md["text"])
}
})
if _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, batchMsg(3)); err != nil {
t.Fatalf("delivery failed: %v", err)
}
}
// TestDingTalkBusinessErrorIsPermanent locks in the "HTTP 200 but errcode != 0"
// judgment. Not checking errcode would record failed deliveries as successful —
// a shared pitfall of the domestic IM platforms.
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 should error")
}
if !IsPermanent(err) {
t.Fatalf("keyword mismatch is a configuration error and should be marked permanent, got %v", err)
}
if !strings.Contains(err.Error(), "310000") {
t.Errorf("the error should carry the platform error code, got %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("not valid UTF-8 after truncation — WeCom would reject the whole message")
}
})
// Build a batch long enough to definitely exceed 4096 bytes.
m := batchMsg(200)
if _, err := (weComChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, m); err != nil {
t.Fatalf("delivery failed: %v", err)
}
if contentLen > weComMarkdownLimit {
t.Fatalf("body %d bytes exceeds the WeCom limit %d", contentLen, weComMarkdownLimit)
}
if contentLen == 0 {
t.Fatal("body is empty")
}
}
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 is rolling-window rate limiting, should be retryable, got %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 is an invalid key; retries will not fix it, should be permanent, got %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("should send an interactive card, got %v", body["msg_type"])
}
card, _ := body["card"].(map[string]any)
header, _ := card["header"].(map[string]any)
if header["template"] != "orange" {
t.Errorf("high severity should use the orange color, got %v", header["template"])
}
// With a secret configured the signing params must be present, otherwise
// Feishu rejects with 19021.
if body["sign"] == nil || body["timestamp"] == nil {
t.Fatalf("missing signing parameters: %v", body)
}
// The card elements should include a button whose url points to the
// finding details.
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("no button pointing to the detail page in the card")
}
})
cfg := map[string]any{"webhook": srv.URL, "secret": secret}
if _, err := (feishuChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
t.Fatalf("delivery failed: %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("signing params must not be present when no secret is configured: %v", body)
}
})
if _, err := (feishuChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg()); err != nil {
t.Fatalf("delivery failed: %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("should use HTML parse mode, got %v", body["parse_mode"])
}
})
m := Message{Items: []Item{{
Severity: "high",
// The title and summary come from the tested target / model output —
// untrusted content.
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("delivery failed: %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, "