mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
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.
184 lines
6.0 KiB
Go
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:])
|
|
}
|
|
})
|
|
}
|
|
}
|