Files
ARTEX/notify/pack_test.go
T
BaaRai 809436e111 notify: make the summary and asset limits configurable
The IM renderers cut the summary of a single push at 120 characters and
listed at most 3 assets, as fixed constants. Both are now global settings
in the settings table, read by the delivery engine when it renders:

  notify_summary_max_runes   0-4000, default 120
  notify_assets_max_shown    0-50,   default 3

0 means no truncation and an unset key keeps 120 / 3. PUT /api/settings
answers 400 for a value outside the bounds. The limits travel on
notify.Message, so the notify package still does not touch the database.

With no truncation, nothing bounded a single item in a Feishu card, and
Feishu rejects an oversized request as a whole. feishuCard now truncates
an item's text as the final backstop, the role TruncateBytes plays in
markdownBody for DingTalk and WeCom. feishuMaxCardBytes goes from 24000
to 16000: Feishu custom bots cap the request body at 20 KB, which the old
value exceeded.

Refs #168.
2026-11-10 16:05:31 +01:00

225 lines
7.6 KiB
Go

package notify
import (
"strings"
"testing"
"unicode/utf8"
)
// This file covers the "pack whole items" fix: when a digest message exceeds the
// channel's length limit, it must be truncated **item by item** and honestly
// report how many items did not fit, so the caller marks only the ones actually
// delivered.
//
// The previous approach rendered the whole thing, truncated it, and then marked
// the whole batch as delivered: the second half of the message vanished into
// thin air while the delivery history showed all succeeded — vulnerabilities
// were simply lost, and nowhere could you find out.
func TestMarkdownBodyPacksWholeItemsWithinByteLimit(t *testing.T) {
// 200 digest items are guaranteed to far exceed WeCom's 4096 bytes.
m := batchMsg(200)
body, kept := markdownBody(m, weComMarkdownLimit)
if len(body) > weComMarkdownLimit {
t.Fatalf("body of %d bytes exceeds the limit of %d", len(body), weComMarkdownLimit)
}
if !utf8.ValidString(body) {
t.Fatal("body is not valid UTF-8")
}
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("only a part should fit (0 < kept < %d), got %d", len(m.Items), kept)
}
// The header must honestly state how many items this message contains and how
// many remain — otherwise readers take the number in the header for the whole
// batch.
if !strings.Contains(body, "remaining") || !strings.Contains(body, "next message") {
t.Fatalf("the header should state how many items are not included in this message:\n%s", body[:minInt(400, len(body))])
}
// Only the first kept items should be included.
for i := 0; i < kept; i++ {
if !strings.Contains(body, "vuln"+itoa(i+1)) {
t.Fatalf("item %d should be in this message:\n%s", i+1, body)
}
}
if strings.Contains(body, "vuln"+itoa(kept+1)) {
t.Fatalf("item %d should not appear (it belongs to the next batch)", kept+1)
}
}
func TestMarkdownBodyKeepsEverythingWhenUnderLimit(t *testing.T) {
m := batchMsg(3)
body, kept := markdownBody(m, 0) // 0 = unlimited
if kept != len(m.Items) {
t.Fatalf("everything should be kept when length is unlimited, got kept=%d", kept)
}
if strings.Contains(body, "remaining") {
t.Fatalf("no truncation notice should appear when nothing was truncated:\n%s", body)
}
}
func TestMarkdownBodyAlwaysKeepsAtLeastOneItem(t *testing.T) {
// When the budget is too small to fit even one item, still send one (with
// final truncation as the backstop). Otherwise a single oversized
// vulnerability would permanently jam the whole batch in place: never fitting
// on every claim, never sent.
m := batchMsg(5)
_, kept := markdownBody(m, 50)
if kept != 1 {
t.Fatalf("at least 1 item should be kept, got %d", kept)
}
}
func TestMarkdownBodySingleReturnsOne(t *testing.T) {
_, kept := markdownBody(singleMsg(), 4096)
if kept != 1 {
t.Fatalf("a single message should report 1 delivered, got %d", kept)
}
// An empty message has no items to deliver.
if _, k := markdownBody(Message{}, 4096); k != 0 {
t.Fatalf("an empty message should report 0 items, got %d", k)
}
}
func TestTelegramPackingUsesRuneBudget(t *testing.T) {
m := batchMsg(200)
text, kept := telegramHTML(m)
// Telegram limits by **character count**; measuring in bytes would cram a
// CJK message down to a third.
if n := utf8.RuneCountInString(text); n > telegramTextLimit {
t.Fatalf("body of %d characters exceeds the limit of %d", n, telegramTextLimit)
}
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("only a part should fit, got %d", kept)
}
if !strings.Contains(text, "next message") {
t.Fatalf("should state that more items are not included:\n%.300s", text)
}
}
func TestFeishuPackingReportsKept(t *testing.T) {
m := batchMsg(2000)
_, kept := feishuCard(m)
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("the card should only fit a part, got %d", kept)
}
}
func TestWebhookAndEmailReportAllItems(t *testing.T) {
// These two channels do not truncate the body; the whole batch counts as
// delivered.
m := batchMsg(7)
if n := len(m.Items); n != 7 {
t.Fatal("precondition does not hold")
}
// Confirm indirectly via the renderer's return value: markdownBody(0) keeps
// everything when unlimited.
if _, k := markdownBody(m, 0); k != len(m.Items) {
t.Fatalf("everything should be used when length is unlimited, got %d", k)
}
}
// TestMarkdownEscapesUntrustedContent is the regression test for "untrusted
// content must not change message structure". Titles and summaries come from
// model output (the model reads the tested target's responses); asset names come
// from the tested target's URLs.
func TestMarkdownEscapesUntrustedContent(t *testing.T) {
cases := []struct {
name string
item Item
must []string // must appear in the result (escaped form)
wrong []string // must not appear in the result (unescaped form)
}{
{
name: "newline + external link in the title",
item: Item{
Severity: "high",
Name: "SQL injection on login page\n[URGENT: click to verify your account](http://attacker.tld)",
},
// The newline must be collapsed (otherwise it can forge a new list
// item / quote block); the square brackets and parentheses must be
// escaped (otherwise it is a clickable external link).
must: []string{`\[URGENT: click to verify your account\]`, `\(http://attacker.tld\)`},
wrong: []string{"\n[URGENT", "\n\n[URGENT"},
},
{
name: "image beacon in the title",
item: Item{
Severity: "high",
Name: "Vuln ![](http://attacker.tld/beacon)",
},
must: []string{`\!`, `\(http://attacker.tld/beacon\)`},
wrong: []string{"![]("},
},
{
name: "emphasis and quote in an asset name",
item: Item{
Severity: "high",
Name: "plain title",
Assets: []string{"a.com/*inject*>quote"},
},
must: []string{`\*inject\*`, `\>`},
wrong: []string{"*inject*"},
},
{
name: "backticks and pipe in the summary",
item: Item{
Severity: "high",
Name: "title",
Summary: "`code` | table",
},
must: []string{"\\`code\\`", `\|`},
wrong: []string{"`code`"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := Message{Items: []Item{tc.item}}
// writeItem in single mode is the rendering path shared by all three
// markdown channels.
var b strings.Builder
writeItem(&b, tc.item, "", true, m.limits())
got := b.String()
for _, want := range tc.must {
if !strings.Contains(got, want) {
t.Errorf("missing escaped form %q:\n%s", want, got)
}
}
for _, bad := range tc.wrong {
if strings.Contains(got, bad) {
t.Errorf("unescaped form %q appeared (can be used to inject structure or external links):\n%s", bad, got)
}
}
_ = m
})
}
}
// TestMarkdownEscapeBackslashFirst locks the escape order: the backslash must be
// handled first, otherwise the backslashes added later get another layer and the
// output ends up with double backslashes.
func TestMarkdownEscapeBackslashFirst(t *testing.T) {
if got := markdownEscape(`a\b*c`); got != `a\\b\*c` {
t.Fatalf("wrong escape order, got %q", got)
}
}
// TestTelegramTitleHasNoMarkdownEscapes locks a specific regression: markdown
// escapes must not leak into Telegram's HTML output (an escape was once added to
// the shared title function, and Telegram messages ended up showing visible
// backslashes like `\(1\)`).
func TestTelegramTitleHasNoMarkdownEscapes(t *testing.T) {
m := Message{Items: []Item{{Severity: "high", Name: "alert(1) *important*"}}}
text, _ := telegramHTML(m)
if strings.Contains(text, `\(`) || strings.Contains(text, `\*`) {
t.Fatalf("markdown backslash escapes appeared in the Telegram body:\n%s", text)
}
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}