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, "