Files
ARTEX/notify/limits_test.go
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

184 lines
6.0 KiB
Go

package notify
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
)
// This file covers the configurable render limits (Message.Limits). The summary
// length and the number of assets listed used to be the fixed constants 120 / 3;
// they now come from the caller, with 0 meaning "no truncation" and nil meaning
// "the old defaults".
// limitsItem returns an item whose summary (200 characters) and asset list (8)
// both exceed the defaults. It contains no markdown/HTML metacharacters, so
// every renderer emits the text verbatim and one set of assertions fits all.
func limitsItem() Item {
assets := make([]string, 8)
for i := range assets {
assets[i] = "host" + itoa(i+1)
}
return Item{Severity: "high", Name: "title", Summary: strings.Repeat("Z", 200), Assets: assets}
}
// feishuContent returns the lark_md text of the card's element idx.
func feishuContent(t *testing.T, m Message, idx int) string {
t.Helper()
card, _ := feishuCard(m)
elements, _ := card["elements"].([]any)
if idx >= len(elements) {
t.Fatalf("card has %d elements, cannot get element %d", len(elements), idx)
}
div, _ := elements[idx].(map[string]any)
text, _ := div["text"].(map[string]any)
content, _ := text["content"].(string)
return content
}
// limitsRenderings returns the body of every channel family that applies the
// limits. For a digest, the Feishu entry is the first item line (element 0 is
// the intro).
func limitsRenderings(t *testing.T, m Message) map[string]string {
t.Helper()
md, _ := markdownBody(m, 0)
tg, _ := telegramHTML(m)
feishuIdx := 0
if m.Batch {
feishuIdx = 1
}
return map[string]string{
"markdown": md,
"feishu": feishuContent(t, m, feishuIdx),
"telegram": tg,
"email": htmlBody(m, 0),
}
}
func TestMessageLimitsApplyToSinglePush(t *testing.T) {
cases := []struct {
name string
lim *Limits
must []string
wrong []string
}{
{
name: "unset keeps the 120 / 3 defaults",
lim: nil,
must: []string{strings.Repeat("Z", 119) + ellipsis, "host1, host2, host3 and 5 more"},
wrong: []string{strings.Repeat("Z", 120), "host4"},
},
{
name: "zero means no truncation",
lim: &Limits{},
must: []string{strings.Repeat("Z", 200), "host7, host8"},
wrong: []string{ellipsis, " more"},
},
{
name: "custom values",
lim: &Limits{SummaryRunes: 10, AssetsShown: 1},
must: []string{strings.Repeat("Z", 9) + ellipsis, "host1 and 7 more"},
wrong: []string{strings.Repeat("Z", 10), "host2"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := Message{Items: []Item{limitsItem()}, Limits: tc.lim}
for channel, got := range limitsRenderings(t, m) {
for _, want := range tc.must {
if !strings.Contains(got, want) {
t.Errorf("%s: missing %q:\n%s", channel, want, got)
}
}
for _, bad := range tc.wrong {
if strings.Contains(got, bad) {
t.Errorf("%s: %q should not appear:\n%s", channel, bad, got)
}
}
}
})
}
}
// TestMessageLimitsApplyToDigestAssets covers the digest lines, which list the
// assets too. The digest summary is not governed by Limits.SummaryRunes: it
// stays at its own fixed 60 characters (markdown and email), a digest being a
// list of one-liners.
func TestMessageLimitsApplyToDigestAssets(t *testing.T) {
m := Message{Batch: true, Items: []Item{limitsItem()}}
for channel, got := range limitsRenderings(t, m) {
if !strings.Contains(got, "host1, host2, host3 and 5 more") {
t.Errorf("%s: unset should keep 3 assets:\n%s", channel, got)
}
}
m.Limits = &Limits{}
for channel, got := range limitsRenderings(t, m) {
if !strings.Contains(got, "host7, host8") || strings.Contains(got, " more") {
t.Errorf("%s: 0 should list every asset:\n%s", channel, got)
}
if strings.Contains(got, strings.Repeat("Z", 60)) {
t.Errorf("%s: the digest summary should stay within 60 characters:\n%s", channel, got)
}
}
}
// TestFeishuUntruncatedItemStaysWithinBodyLimit is the guard that goes with
// "0 = no truncation". Feishu custom bots reject a request body over 20 KB as a
// whole, so an unbounded summary or asset list would not arrive shortened — it
// would not arrive at all. The body measured here is the real one sent on the
// wire, signature included.
func TestFeishuUntruncatedItemStaysWithinBodyLimit(t *testing.T) {
const feishuBodyLimit = 20000
huge := limitsItem()
huge.DetailURL = "https://artex.local/function/findings/detail?id=42"
// 36000 bytes of CJK: also checks that the cut lands on a character boundary.
huge.Summary = strings.Repeat("漏洞摘要", 3000)
huge.Assets = nil
for i := 0; i < 1500; i++ {
huge.Assets = append(huge.Assets, "host"+itoa(i+1)+".example.com")
}
cases := []struct {
name string
m Message
idx int
}{
{"single push", Message{Items: []Item{huge}, Limits: &Limits{}}, 0},
{"digest whose first item is oversized", Message{
Batch: true, WindowMinutes: 30, HomeURL: "https://artex.local/function/findings",
Items: []Item{huge, huge}, Limits: &Limits{},
}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
size := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
size = len(raw)
_, _ = io.WriteString(w, `{"code":0,"msg":"success"}`)
}))
defer srv.Close()
kept, err := (feishuChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL, "secret": "SECtest123"}, tc.m)
if err != nil {
t.Fatalf("delivery failed: %v", err)
}
if kept != 1 {
t.Fatalf("exactly the first item should be reported as delivered, got %d", kept)
}
if size == 0 || size > feishuBodyLimit {
t.Fatalf("request body of %d bytes, want 1..%d", size, feishuBodyLimit)
}
content := feishuContent(t, tc.m, tc.idx)
if !utf8.ValidString(content) || !strings.HasSuffix(content, ellipsis) {
t.Fatalf("the item should be cut on a character boundary and end with an ellipsis, got tail %q", content[len(content)-12:])
}
})
}
}