Files
larksuite-cli/shortcuts/mail/lint/linter_test.go
bubbmon233 bbef3cbfb1 feat(mail): HTML lint library + Larksuite-native autofix + lark-mail … (#1019)
* feat(mail): HTML lint library + Larksuite-native autofix + lark-mail skill

为 lark-cli mail 域写信链路引入 HTML lint 能力,提升邮件 HTML 的兼容性、
安全性与 Larksuite-native 格式适配。

lint 库(shortcuts/mail/lint/):
- 四档分类:pass / native-autofix / warn-autofix / error-strip
- 安全规则覆盖 script / iframe / on* 事件处理器 / javascript: 及其它
  危险 URL scheme 等 XSS 向量,未知 scheme 一律删除并归 error
- Larksuite-native 格式自动修复:双层 div 段落、原生多级列表结构、
  灰边引用、Larksuite 蓝链接
- cleaned_html 输出确定性稳定(位置索引派生 data-ol-id),便于
  golden-file 测试与缓存

+lint-html 独立预检 shortcut:
- 只读、不调 API、不建草稿,供 AI / 用户 / CI 在写信前预览 lint 结果

写入路径内置 lint(6 个 compose shortcut):
- +send / +draft-create / +draft-edit / +reply / +reply-all / +forward
  在 emlbuilder 之前强制 lint 净化 HTML
- 默认 envelope 对 lint 改动透明(无 lint 字段),保持小巧供 AI 消费;
  --show-lint-details 显式取证返回 lint_applied[] / original_blocked[]
- --body-file 支持从文件读取 body(32MB 上限),与 --body 互斥

预制 HTML 邮件模板(skills/lark-mail/assets/templates/):
- 资讯周报 / 个人周报 / 团队周报 / 调研报告 / 求职简历 5 套
- 按 Larksuite mail-editor 原生格式编写,含正确的多级列表嵌套结构

lark-mail skill 文档:
- references/lark-mail-html.md:邮件 HTML 写法指南(24 个格式 section
  + 颜色调色盘 + URL scheme + 官方模板套用流程)
- references/lark-mail-lint-html.md:+lint-html 用法
- SKILL.md 顶部 CRITICAL 引导

* fix(mail): remove unused readAttr func and apply gofmt

Drop the unused `readAttr` helper in shortcuts/mail/lint/linter.go
that was flagged by golangci-lint (unused linter). Apply gofmt to
linter.go and rules.go which had minor formatting issues.

* fix(mail): address compose lint and guidance
2026-05-27 22:23:32 +08:00

921 lines
34 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package lint
import (
"strings"
"testing"
)
// =====================================================================
// Tier 1 — pass-through tags / attrs / styles (tag classification row "通过").
// =====================================================================
// TestRun_AllowedTagsPassThrough verifies that the canonical Feishu-native
// tag set passes through without findings (tag classification row "通过").
func TestRun_AllowedTagsPassThrough(t *testing.T) {
cases := []struct {
name string
html string
}{
{"plain paragraph", `<p>hello world</p>`},
{"div with span", `<div><span>nested</span></div>`},
{"unordered list", `<ul><li>a</li><li>b</li></ul>`},
{"ordered list", `<ol><li>x</li></ol>`},
{"table", `<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>v</td></tr></tbody></table>`},
{"headings", `<h1>t</h1><h2>t</h2><h3>t</h3><h4>t</h4><h5>t</h5><h6>t</h6>`},
{"emphasis", `<b>b</b><i>i</i><em>e</em><strong>s</strong><u>u</u><s>k</s>`},
{"sub sup", `<sub>s</sub><sup>p</sup>`},
{"hr br", `<p>x<br>y</p><hr>`},
{"blockquote", `<blockquote>q</blockquote>`},
{"code pre", `<pre><code>x = 1</code></pre>`},
{"safe href", `<a href="https://example.com">link</a>`},
{"mailto href", `<a href="mailto:a@b.c">m</a>`},
{"cid img", `<img src="cid:abc123">`},
{"data:image png", `<img src="data:image/png;base64,iVBOR" alt="x">`},
{"feishu native quote class",
`<div class="adit-html-block adit-html-block--collapsed"><div>x</div></div>`},
}
// Feishu-native autofix rules apply to <p>/<ul>/<ol>/<li>/<blockquote>/<a>
// — those are not "violations" so must not be flagged as errors. We
// allow STYLE_*_NATIVE_INLINE_APPLIED + STYLE_PARA_WRAPPER_REWRITTEN
// findings here but reject any other rule.
feishuNativeRules := map[string]bool{
RuleStyleListNative: true,
RuleStyleListItemNative: true,
RuleStyleBlockquoteNative: true,
RuleStyleLinkNative: true,
RuleStyleParaWrapper: true,
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rep := Run(tc.html, Options{})
if len(rep.Blocked) != 0 {
t.Errorf("expected no errors, got %d: %+v", len(rep.Blocked), rep.Blocked)
}
for _, f := range rep.Applied {
if !feishuNativeRules[f.RuleID] {
t.Errorf("unexpected non-Feishu-native warning: %+v", f)
}
}
})
}
}
// TestRun_AllowedStylePropertiesPassThrough verifies all allowed style
// properties survive a round-trip without dropping.
func TestRun_AllowedStylePropertiesPassThrough(t *testing.T) {
allowed := []string{
"color:rgb(31,35,41)",
"background-color:rgb(245,246,247)",
"font-size:14px",
"font-weight:bold",
"font-style:italic",
"text-align:center",
"text-decoration:underline",
"line-height:1.6",
"padding:8px",
"margin:12px",
"border:1px solid #ccc",
"border-top:1px solid red",
"border-bottom:2px solid blue",
"border-left:1px",
"border-right:1px",
"width:100%",
"height:auto",
"display:block",
"text-indent:2em",
}
for _, prop := range allowed {
t.Run(prop, func(t *testing.T) {
html := `<p style="` + prop + `">x</p>`
rep := Run(html, Options{})
for _, f := range rep.Applied {
if f.RuleID == RuleStylePropertyDropped {
t.Errorf("property %q unexpectedly dropped: %+v", prop, f)
}
}
})
}
}
// =====================================================================
// Tier 2 — warning + autofix tags (tag classification row "警告 + 自动修复").
// =====================================================================
// TestRun_FontTagAutofixedToSpan verifies <font color="..."> rewrites to
// <span style="color:..."> with AutoFix=true.
func TestRun_FontTagAutofixedToSpan(t *testing.T) {
// Use <div> wrapper to avoid the Feishu-native paragraph autofix
// firing alongside the <font> rewrite.
rep := Run(`<div><font color="red">x</font></div>`, Options{})
if len(rep.Applied) != 1 {
t.Fatalf("expected 1 warning, got %d: %+v", len(rep.Applied), rep.Applied)
}
got := rep.Applied[0]
if got.RuleID != RuleTagFontToSpan {
t.Errorf("rule = %s, want %s", got.RuleID, RuleTagFontToSpan)
}
if got.Severity != SeverityWarning {
t.Errorf("severity = %s, want warning", got.Severity)
}
if !strings.Contains(rep.CleanedHTML, "<span") || strings.Contains(rep.CleanedHTML, "<font") {
t.Errorf("expected <font>→<span> rewrite, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "color:red") {
t.Errorf("expected color preserved as inline style, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_FontTagSizeMappedToPx checks legacy <font size="N"> → font-size:Npx.
func TestRun_FontTagSizeMappedToPx(t *testing.T) {
rep := Run(`<font size="3">x</font>`, Options{})
if !strings.Contains(rep.CleanedHTML, "font-size:16px") {
t.Errorf("expected size=3 → 16px, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_CenterTagAutofixedToDiv verifies <center> → <div text-align:center>.
func TestRun_CenterTagAutofixedToDiv(t *testing.T) {
rep := Run(`<center>x</center>`, Options{})
if len(rep.Applied) != 1 {
t.Fatalf("expected 1 warning, got %d", len(rep.Applied))
}
if rep.Applied[0].RuleID != RuleTagCenterToDiv {
t.Errorf("rule = %s, want %s", rep.Applied[0].RuleID, RuleTagCenterToDiv)
}
if !strings.Contains(rep.CleanedHTML, "<div") || !strings.Contains(rep.CleanedHTML, "text-align:center") {
t.Errorf("expected <center>→<div text-align:center>, cleaned=%q", rep.CleanedHTML)
}
if strings.Contains(rep.CleanedHTML, "<center") {
t.Errorf("<center> should have been replaced, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_MarqueeBlinkCollapseToSpan verifies <marquee>/<blink> → <span>.
func TestRun_MarqueeBlinkCollapseToSpan(t *testing.T) {
for _, tag := range []string{"marquee", "blink"} {
rep := Run("<"+tag+">x</"+tag+">", Options{})
if len(rep.Applied) != 1 {
t.Errorf("[%s] expected 1 warning, got %d", tag, len(rep.Applied))
continue
}
if !strings.Contains(rep.CleanedHTML, "<span") {
t.Errorf("[%s] expected <span> wrapper, cleaned=%q", tag, rep.CleanedHTML)
}
}
}
// =====================================================================
// Tier 3 — error / delete tags (tag classification row "错误(删除)").
// =====================================================================
// TestRun_ScriptTagBlocked checks that <script> is removed unconditionally.
func TestRun_ScriptTagBlocked(t *testing.T) {
rep := Run(`<p>safe</p><script>alert(1)</script><p>after</p>`, Options{})
if len(rep.Blocked) != 1 {
t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked))
}
if rep.Blocked[0].RuleID != RuleTagScriptBlocked {
t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleTagScriptBlocked)
}
if strings.Contains(rep.CleanedHTML, "<script") || strings.Contains(rep.CleanedHTML, "alert(1)") {
t.Errorf("<script> content should be deleted, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "safe") || !strings.Contains(rep.CleanedHTML, "after") {
t.Errorf("surrounding content lost, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_BlockedTagsRemoved iterates all error-tier tags.
func TestRun_BlockedTagsRemoved(t *testing.T) {
cases := map[string]string{
`<iframe src="x"></iframe>`: RuleTagIframeBlocked,
`<object data="x"></object>`: RuleTagObjectBlocked,
`<embed src="x">`: RuleTagEmbedBlocked,
`<form action="x"><input></form>`: RuleTagFormBlocked,
`<link rel="stylesheet" href="x.css">`: RuleTagLinkBlocked,
`<meta http-equiv="refresh" content="0">`: RuleTagMetaBlocked,
`<base href="https://evil.com">`: RuleTagBaseBlocked,
}
for input, wantRule := range cases {
t.Run(input[:min(len(input), 30)], func(t *testing.T) {
rep := Run(input, Options{})
found := false
for _, f := range rep.Blocked {
if f.RuleID == wantRule {
found = true
break
}
}
if !found {
t.Errorf("expected rule %s, got %+v", wantRule, rep.Blocked)
}
})
}
}
// TestRun_EventHandlerAttrBlocked verifies on*-handlers (onclick etc.) are
// stripped — they are an event-handler injection vector.
func TestRun_EventHandlerAttrBlocked(t *testing.T) {
rep := Run(`<p onclick="alert(1)" id="ok">x</p>`, Options{})
if len(rep.Blocked) != 1 {
t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked))
}
if rep.Blocked[0].RuleID != RuleAttrEventHandlerBlocked {
t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleAttrEventHandlerBlocked)
}
if strings.Contains(rep.CleanedHTML, "onclick") {
t.Errorf("onclick should be stripped, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, `id="ok"`) {
t.Errorf("non-handler attrs should survive, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_OnErrorAttrBlocked tests one of the more common XSS vectors.
func TestRun_OnErrorAttrBlocked(t *testing.T) {
rep := Run(`<img src="cid:x" onerror="alert(1)">`, Options{})
hasErr := false
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrEventHandlerBlocked && f.TagOrAttr == "onerror" {
hasErr = true
}
}
if !hasErr {
t.Errorf("onerror should fire, got %+v", rep.Blocked)
}
}
// =====================================================================
// URL scheme allow-list.
// =====================================================================
// TestRun_JavaScriptURLBlocked verifies javascript: hrefs are stripped.
func TestRun_JavaScriptURLBlocked(t *testing.T) {
rep := Run(`<a href="javascript:alert(1)">click</a>`, Options{})
hasErr := false
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrJSURLBlocked {
hasErr = true
}
}
if !hasErr {
t.Errorf("javascript: URL should fire ATTR_JS_URL_BLOCKED, got %+v", rep.Blocked)
}
if strings.Contains(rep.CleanedHTML, "javascript:") {
t.Errorf("javascript: should be stripped, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_VBScriptURLBlocked verifies vbscript: is rejected.
func TestRun_VBScriptURLBlocked(t *testing.T) {
rep := Run(`<a href="vbscript:msgbox 1">x</a>`, Options{})
if len(rep.Blocked) == 0 {
t.Errorf("expected vbscript: to be blocked, got 0 findings")
}
}
// TestRun_DataNonImageURLBlocked verifies data:text/html is rejected
// (only data:image/* is allowed).
func TestRun_DataNonImageURLBlocked(t *testing.T) {
rep := Run(`<img src="data:text/html,<script>1</script>">`, Options{})
if len(rep.Blocked) == 0 {
t.Errorf("expected data:text/html to be blocked")
}
}
// TestRun_DataImageAllowed verifies data:image/png passes.
func TestRun_DataImageAllowed(t *testing.T) {
rep := Run(`<img src="data:image/png;base64,iVBORw0KGg=">`, Options{})
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrJSURLBlocked {
t.Errorf("data:image/* should pass, got %+v", f)
}
}
}
// TestRun_RelativeURLAllowed verifies relative URLs (no scheme) pass.
func TestRun_RelativeURLAllowed(t *testing.T) {
rep := Run(`<img src="./local.png"><a href="/path">x</a>`, Options{})
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrJSURLBlocked || f.RuleID == RuleAttrUnsafeSchemeBlocked {
t.Errorf("relative URL should pass, got %+v", f)
}
}
}
// =====================================================================
// Style property allow-list.
// =====================================================================
// TestRun_StylePropertyDropped verifies non-allow-list properties drop.
func TestRun_StylePropertyDropped(t *testing.T) {
rep := Run(`<p style="color:red; position:absolute; z-index:99">x</p>`, Options{})
dropped := []string{}
for _, f := range rep.Applied {
if f.RuleID == RuleStylePropertyDropped {
dropped = append(dropped, f.TagOrAttr)
}
}
if !sliceContains(dropped, "style.position") {
t.Errorf("expected position to be dropped, got %v", dropped)
}
if !sliceContains(dropped, "style.z-index") {
t.Errorf("expected z-index to be dropped, got %v", dropped)
}
if strings.Contains(rep.CleanedHTML, "position:") || strings.Contains(rep.CleanedHTML, "z-index:") {
t.Errorf("dropped properties should be removed from cleaned style, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "color:red") {
t.Errorf("allowed property should survive, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_StyleBorderPrefixAllowed verifies the border-* prefix rule.
func TestRun_StyleBorderPrefixAllowed(t *testing.T) {
rep := Run(`<p style="border-top:1px; border-bottom-color:red; border-radius:4px">x</p>`, Options{})
for _, f := range rep.Applied {
if f.RuleID == RuleStylePropertyDropped {
t.Errorf("border-* should pass, got %+v", f)
}
}
}
// TestRun_FeishuListShorthandMarginPreserved guards the nested-list indent
// regression: when a user writes shorthand `margin:0 0 0 24px` on an inner
// <ul> (mail-editor's own native nested-list shape), the Feishu-list autofix
// must NOT clobber it by appending `margin-left:0`. ensureInlineStyleProps
// is supposed to skip props the user already declared, but earlier
// hasInlineStyleProp was only matching longhand `margin-left:` literally
// and missed the shorthand form, causing 24px indents to be reset to 0.
func TestRun_FeishuListShorthandMarginPreserved(t *testing.T) {
in := `<ul style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;list-style-type:circle;font-size:14px" dir="auto"><span style="font-family:inherit"><span style="color:rgb(0,0,0)">indented</span></span></li></ul>`
rep := Run(in, Options{})
cleaned := rep.CleanedHTML
// Extract just the <ul ...> opening tag's style attr (li has its own
// independent margin-left:0 longhand which is correct — list indent
// belongs on the container, not the item).
ulOpen := cleaned
if i := strings.Index(ulOpen, ">"); i >= 0 {
ulOpen = ulOpen[:i]
}
if !strings.Contains(ulOpen, "margin:0px 0px 0px 24px") {
t.Errorf("shorthand margin with 24px left should survive on <ul>, ulOpen=%q", ulOpen)
}
// The bug signature: extra `margin-left:` appended after the shorthand
// on the <ul> element itself (CSS rule says the later one wins, so any
// margin-left:0 after the shorthand resets the indent to 0).
if strings.Contains(ulOpen, "margin-left") {
t.Errorf("autofix must not append margin-left longhand onto <ul> when shorthand already declares it, ulOpen=%q", ulOpen)
}
}
// TestRun_BlockquoteShorthandBorderPreserved verifies the blockquote native
// autofix does not override a user-authored border shorthand by appending
// border-left. CSS applies the later longhand over the earlier shorthand, so
// adding border-left here would replace the user's left border.
func TestRun_BlockquoteShorthandBorderPreserved(t *testing.T) {
rep := Run(`<blockquote style="border:1px solid red">quoted</blockquote>`, Options{})
cleaned := rep.CleanedHTML
if !strings.Contains(cleaned, `border:1px solid red`) {
t.Fatalf("user-authored border shorthand should survive, cleaned=%q", cleaned)
}
if strings.Contains(cleaned, `border-left:`) {
t.Fatalf("autofix must not append border-left when border shorthand already declares it, cleaned=%q", cleaned)
}
if !strings.Contains(cleaned, `color:rgb(100,106,115)`) {
t.Fatalf("blockquote native autofix should still add missing non-border style props, cleaned=%q", cleaned)
}
}
func TestRun_BlockquoteNativeContentWrapper(t *testing.T) {
rep := Run(`<blockquote>quoted</blockquote>`, Options{})
cleaned := rep.CleanedHTML
for _, want := range []string{
`class="lark-mail-doc-quote"`,
`border-left:2px solid rgb(187,191,196)`,
`<div dir="auto" style="font-size:14px;padding-left:12px">quoted</div>`,
} {
if !strings.Contains(cleaned, want) {
t.Fatalf("cleaned blockquote missing %q, cleaned=%q", want, cleaned)
}
}
}
func TestRun_BlockquoteNativeContentWrapperIdempotent(t *testing.T) {
in := `<blockquote class="lark-mail-doc-quote" style="padding-left:0px;color:rgb(100,106,115);border-left:2px solid rgb(187,191,196);margin:0px"><div dir="auto" style="font-size:14px;padding-left:12px">quoted</div></blockquote>`
rep := Run(in, Options{})
if strings.Count(rep.CleanedHTML, `padding-left:12px`) != 1 {
t.Fatalf("native-shaped blockquote should not get nested content wrappers, cleaned=%q", rep.CleanedHTML)
}
}
func TestRun_ParagraphRewritePreservesDirAndFontSize(t *testing.T) {
rep := Run(`<p style="font-size:20px" dir="rtl">hello</p>`, Options{})
cleaned := rep.CleanedHTML
if !strings.Contains(cleaned, `style="font-size:20px;margin-top:4px;margin-bottom:4px;line-height:1.6" dir="rtl"`) {
t.Fatalf("outer paragraph wrapper should preserve author font-size and dir, cleaned=%q", cleaned)
}
if !strings.Contains(cleaned, `<div dir="rtl">hello</div>`) {
t.Fatalf("inner paragraph wrapper should inherit author dir and omit default font-size, cleaned=%q", cleaned)
}
if strings.Contains(cleaned, `font-size:14px`) {
t.Fatalf("inner paragraph wrapper must not force default font-size over author value, cleaned=%q", cleaned)
}
if strings.Contains(cleaned, `dir="auto"`) {
t.Fatalf("inner paragraph wrapper must not force dir=auto over author value, cleaned=%q", cleaned)
}
}
// =====================================================================
// CleanedHTML output / contract guarantees.
// =====================================================================
// TestRun_EmptyArraysAlwaysPresent verifies the report has non-nil empty
// slices when nothing is found (the JSON envelope contract requires `[]`,
// not `null`).
func TestRun_EmptyArraysAlwaysPresent(t *testing.T) {
// Use <div> instead of <p> to avoid the Feishu-native paragraph
// rewrite autofix, which would surface a finding even on otherwise
// clean input.
rep := Run(`<div>nothing here</div>`, Options{})
if rep.Applied == nil || rep.Blocked == nil {
t.Errorf("Applied/Blocked must be non-nil; got applied=%v blocked=%v", rep.Applied, rep.Blocked)
}
if len(rep.Applied) != 0 || len(rep.Blocked) != 0 {
t.Errorf("expected empty findings, got applied=%d blocked=%d", len(rep.Applied), len(rep.Blocked))
}
}
// TestEmptyReport_HasContractFields covers the helper used by compose 5's
// plain-text branch.
func TestEmptyReport_HasContractFields(t *testing.T) {
rep := EmptyReport(`plain text`)
if rep.Applied == nil {
t.Error("Applied must be non-nil")
}
if rep.Blocked == nil {
t.Error("Blocked must be non-nil")
}
if rep.CleanedHTML != "plain text" {
t.Errorf("CleanedHTML = %q, want %q", rep.CleanedHTML, "plain text")
}
}
// TestRun_CleanedHTMLPreservesStructure verifies that the round-trip through
// the parser doesn't accidentally lose user content.
func TestRun_CleanedHTMLPreservesStructure(t *testing.T) {
html := `<div style="line-height:1.6"><h3>title</h3><p>body <b>bold</b> end</p><ul><li>a</li><li>b</li></ul></div>`
rep := Run(html, Options{})
if len(rep.Blocked) != 0 {
t.Fatalf("unexpected blocked: %+v", rep.Blocked)
}
// Feishu-native autofix expected to fire on <p>, <ul>, <li> — content
// must still survive untouched even though structure is augmented.
for _, want := range []string{"line-height:1.6", "<h3>", "title", "<b>", "bold", "<ul", "<li", "</ul>"} {
if !strings.Contains(rep.CleanedHTML, want) {
t.Errorf("expected %q in cleaned, got %q", want, rep.CleanedHTML)
}
}
}
// TestRun_EmptyInput verifies the lib short-circuits cleanly on empty input.
func TestRun_EmptyInput(t *testing.T) {
rep := Run("", Options{})
if rep.CleanedHTML != "" {
t.Errorf("CleanedHTML = %q, want empty", rep.CleanedHTML)
}
if len(rep.Applied) != 0 || len(rep.Blocked) != 0 {
t.Errorf("empty input must produce empty findings")
}
}
// TestRun_HasErrorFindingsFlag verifies the flag tracks blocked findings.
func TestRun_HasErrorFindingsFlag(t *testing.T) {
rep := Run(`<script>x</script>`, Options{})
if !rep.HasErrorFindings {
t.Error("expected HasErrorFindings=true")
}
clean := Run(`<p>safe</p>`, Options{})
if clean.HasErrorFindings {
t.Error("expected HasErrorFindings=false on clean HTML")
}
}
// TestRun_HasWarningFindingsFlag verifies the flag tracks warnings.
func TestRun_HasWarningFindingsFlag(t *testing.T) {
rep := Run(`<font color="red">x</font>`, Options{})
if !rep.HasWarningFindings {
t.Error("expected HasWarningFindings=true")
}
}
// =====================================================================
// Excerpt cap.
// =====================================================================
// TestTruncateExcerpt_RespectsCap verifies the per-finding excerpt cap.
func TestTruncateExcerpt_RespectsCap(t *testing.T) {
long := strings.Repeat("x", MaxExcerptBytes+50)
got := truncateExcerpt(long)
if len(got) > MaxExcerptBytes {
t.Errorf("excerpt len %d exceeds cap %d", len(got), MaxExcerptBytes)
}
if !strings.HasSuffix(got, " ...") {
t.Errorf("expected truncation suffix, got %q", got[len(got)-10:])
}
}
// TestRun_ExcerptCappedForLargeOffender verifies large blocked content
// produces a short excerpt (envelope size protection).
func TestRun_ExcerptCappedForLargeOffender(t *testing.T) {
bigAttr := strings.Repeat("a", MaxExcerptBytes*2)
rep := Run(`<a href="javascript:`+bigAttr+`">x</a>`, Options{})
if len(rep.Blocked) == 0 {
t.Fatal("expected blocked finding")
}
for _, f := range rep.Blocked {
if len(f.Excerpt) > MaxExcerptBytes {
t.Errorf("excerpt len %d exceeds cap %d", len(f.Excerpt), MaxExcerptBytes)
}
}
}
// =====================================================================
// Helpers.
// =====================================================================
func sliceContains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// =====================================================================
// Additional coverage for edge cases and exhaustive value mapping.
// =====================================================================
// TestMapFontSize_ExhaustiveSpan covers every <font size="N"> mapping
// + invalid values fall through to "" so the property is dropped.
func TestMapFontSize_ExhaustiveSpan(t *testing.T) {
cases := map[string]string{
"1": "10px",
"2": "13px",
"3": "16px",
"4": "18px",
"5": "24px",
"6": "32px",
"7": "48px",
"": "",
"8": "",
"abc": "",
"3.5": "",
" 3 ": "16px",
}
for raw, want := range cases {
got := mapFontSize(raw)
if got != want {
t.Errorf("mapFontSize(%q) = %q, want %q", raw, got, want)
}
}
}
// TestRun_FontTagWithFaceMappedToFontFamily ensures <font face="..."> →
// font-family inline style.
func TestRun_FontTagWithFaceMappedToFontFamily(t *testing.T) {
rep := Run(`<font face="Arial">x</font>`, Options{})
if !strings.Contains(rep.CleanedHTML, "font-family:Arial") {
t.Errorf("expected font-family preserved, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_FontTagWithExistingStyleMerged ensures distillation merges with an
// existing style attribute on the same element.
func TestRun_FontTagWithExistingStyleMerged(t *testing.T) {
rep := Run(`<font color="red" style="line-height:1.6">x</font>`, Options{})
if !strings.Contains(rep.CleanedHTML, "line-height:1.6") {
t.Errorf("expected line-height retained, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "color:red") {
t.Errorf("expected color merged, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_CenterTagWithExistingStyleMerged ensures <center>'s style merge.
func TestRun_CenterTagWithExistingStyleMerged(t *testing.T) {
rep := Run(`<center style="line-height:1.6">x</center>`, Options{})
if !strings.Contains(rep.CleanedHTML, "text-align:center") {
t.Errorf("expected text-align:center, cleaned=%q", rep.CleanedHTML)
}
if !strings.Contains(rep.CleanedHTML, "line-height:1.6") {
t.Errorf("expected line-height preserved, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_MarqueeRetainsClassAndID verifies marquee → span keeps class/id.
func TestRun_MarqueeRetainsClassAndID(t *testing.T) {
rep := Run(`<marquee class="cls" id="x" direction="left">y</marquee>`, Options{})
if !strings.Contains(rep.CleanedHTML, `class="cls"`) {
t.Errorf("expected class preserved, cleaned=%q", rep.CleanedHTML)
}
if strings.Contains(rep.CleanedHTML, `direction`) {
t.Errorf("expected marquee-specific attrs stripped, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_UnknownSchemeBlocked verifies an unknown URL scheme produces a
// blocked (error) finding and the attribute is dropped.
func TestRun_UnknownSchemeBlocked(t *testing.T) {
rep := Run(`<a href="webcal://x">x</a>`, Options{})
gotBlocked := false
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrUnsafeSchemeBlocked {
gotBlocked = true
}
}
if !gotBlocked {
t.Errorf("expected ATTR_UNSAFE_SCHEME_BLOCKED in Blocked, got blocked=%+v applied=%+v", rep.Blocked, rep.Applied)
}
if strings.Contains(rep.CleanedHTML, "webcal:") {
t.Errorf("expected unknown scheme stripped, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_WhitespaceObfuscatedJavaScriptScheme verifies "java\tscript:..."
// is still caught after control-byte stripping in classifyURLValue.
func TestRun_WhitespaceObfuscatedJavaScriptScheme(t *testing.T) {
rep := Run("<a href=\"java\tscript:alert(1)\">x</a>", Options{})
gotErr := false
for _, f := range rep.Blocked {
if f.RuleID == RuleAttrJSURLBlocked {
gotErr = true
}
}
if !gotErr {
t.Errorf("expected obfuscated javascript: to be caught, got %+v", rep.Blocked)
}
}
// TestRun_FileSchemeBlocked verifies file: URLs are rejected.
func TestRun_FileSchemeBlocked(t *testing.T) {
rep := Run(`<a href="file:///etc/passwd">x</a>`, Options{})
if len(rep.Blocked) == 0 {
t.Error("expected file: to be blocked")
}
}
// TestRun_StyleMalformedDeclarationDropped verifies a property without a
// colon delimiter is treated as malformed and dropped.
func TestRun_StyleMalformedDeclarationDropped(t *testing.T) {
rep := Run(`<p style="color:red; malformed; line-height:1.6">x</p>`, Options{})
gotMalformed := false
for _, f := range rep.Applied {
if f.RuleID == RuleStylePropertyDropped && f.TagOrAttr == "style.malformed" {
gotMalformed = true
}
}
if !gotMalformed {
t.Errorf("expected malformed declaration to be dropped, got %+v", rep.Applied)
}
if !strings.Contains(rep.CleanedHTML, "color:red") || !strings.Contains(rep.CleanedHTML, "line-height:1.6") {
t.Errorf("valid declarations should survive, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_StyleAllPropertiesDroppedRemovesAttribute verifies the style
// attribute is removed entirely when every property is invalid.
func TestRun_StyleAllPropertiesDroppedRemovesAttribute(t *testing.T) {
// Use <div> to avoid the Feishu-native paragraph autofix, which adds
// a fresh style attribute on the rewritten outer wrapper.
rep := Run(`<div style="position:absolute; z-index:99">x</div>`, Options{})
if strings.Contains(rep.CleanedHTML, "style=") {
t.Errorf("style attribute should be removed when all props invalid, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_StyleEmptyValuePassThrough verifies an empty style attr passes.
func TestRun_StyleEmptyValuePassThrough(t *testing.T) {
// Use <div> to avoid the Feishu-native paragraph autofix.
rep := Run(`<div style="">x</div>`, Options{})
if len(rep.Applied) != 0 {
t.Errorf("empty style attr should not produce findings, got %+v", rep.Applied)
}
}
// TestRun_HintsForAllBlockedTags verifies every blocked-tag rule has a
// non-empty hint (consumer contract).
func TestRun_HintsForAllBlockedTags(t *testing.T) {
cases := []string{
`<script>x</script>`, `<iframe src="x"></iframe>`,
`<object data="x"></object>`, `<embed src="x">`, `<form><input></form>`,
`<select></select>`, `<button>x</button>`, `<link href="x">`,
`<meta name="x">`, `<base href="x">`,
}
for _, html := range cases {
rep := Run(html, Options{})
for _, f := range rep.Blocked {
if f.Hint == "" {
t.Errorf("blocked rule %s missing hint for %q", f.RuleID, html)
}
}
}
}
// TestRun_HintsForAllWarnTags verifies every warn-tag rule has a non-empty hint.
func TestRun_HintsForAllWarnTags(t *testing.T) {
cases := []string{
`<font>x</font>`, `<center>x</center>`,
`<marquee>x</marquee>`, `<blink>x</blink>`,
}
for _, html := range cases {
rep := Run(html, Options{})
for _, f := range rep.Applied {
if f.Hint == "" {
t.Errorf("warn rule %s missing hint for %q", f.RuleID, html)
}
}
}
}
// TestClassifyTag_Coverage exercises classifyTag with every category.
func TestClassifyTag_Coverage(t *testing.T) {
if k, _ := classifyTag("p"); k != "allow" {
t.Errorf("p classified as %q", k)
}
if k, id := classifyTag("script"); k != "error" || id != RuleTagScriptBlocked {
t.Errorf("script classified as %q/%q", k, id)
}
if k, id := classifyTag("font"); k != "warn" || id != RuleTagFontToSpan {
t.Errorf("font classified as %q/%q", k, id)
}
// Niche tag passes silently (e.g. <details>).
if k, _ := classifyTag("details"); k != "allow" {
t.Errorf("niche tag <details> should pass through, got %q", k)
}
// Case-insensitive.
if k, _ := classifyTag("SCRIPT"); k != "error" {
t.Errorf("SCRIPT (uppercase) should still classify as error")
}
}
// TestClassifyURLValue_CoverageEdges covers empty, whitespace-only,
// no-scheme variants.
func TestClassifyURLValue_CoverageEdges(t *testing.T) {
cases := map[string]string{
"": "ok",
" ": "ok",
"https://x": "ok",
"https://x/path?q=1": "ok",
"#fragment": "ok",
"/relative": "ok",
"javascript:alert(1)": "error",
"vbscript:msgbox 1": "error",
"data:image/png;base64,XYZ": "ok",
"data:text/html,<script>": "error",
"webcal://x": "warn",
}
for raw, want := range cases {
got, _ := classifyURLValue(raw)
if got != want {
t.Errorf("classifyURLValue(%q) = %q, want %q", raw, got, want)
}
}
}
// TestClassifyStyleProperty_Coverage covers prefixes & explicit set.
func TestClassifyStyleProperty_Coverage(t *testing.T) {
cases := map[string]bool{
"color": true,
"BACKGROUND-COLOR": true, // case-insensitive
"border-top": true,
"padding-left": true,
"margin-bottom": true,
"position": false,
"z-index": false,
"": false,
" ": false,
}
for prop, want := range cases {
got := classifyStyleProperty(prop)
if got != want {
t.Errorf("classifyStyleProperty(%q) = %v, want %v", prop, got, want)
}
}
}
// TestIsEventHandlerAttr_Coverage covers the on*-detection rule.
func TestIsEventHandlerAttr_Coverage(t *testing.T) {
cases := map[string]bool{
"onclick": true,
"onmouseover": true,
"OnLoad": true, // case-insensitive
"on0": true,
"on": false, // need at least one char after "on"
"onerror": true,
"onsubmit": true,
"once": true, // would match unfortunately because "once" starts with "on" + 'c'
"id": false,
"href": false,
"data-on": false,
}
for k, want := range cases {
got := isEventHandlerAttr(k)
if got != want {
t.Errorf("isEventHandlerAttr(%q) = %v, want %v", k, got, want)
}
}
}
// TestRun_ParseFailureFallsBackGracefully verifies extreme malformed input
// short-circuits to EmptyReport.
func TestRun_PlainTextInputProducesNoFindings(t *testing.T) {
rep := Run("just a plain string with no markup", Options{})
if len(rep.Blocked) != 0 || len(rep.Applied) != 0 {
t.Errorf("plain text should produce no findings, got %+v %+v", rep.Blocked, rep.Applied)
}
}
// TestRun_MultipleErrorsAccumulate ensures multiple offenders all surface.
func TestRun_MultipleErrorsAccumulate(t *testing.T) {
html := `<script>1</script><iframe></iframe><a href="javascript:0">x</a>` +
`<form></form><p onclick="">y</p>`
rep := Run(html, Options{})
if len(rep.Blocked) < 4 {
t.Errorf("expected ≥4 errors, got %d: %+v", len(rep.Blocked), rep.Blocked)
}
}
// TestRun_NestedStructurePreserved verifies deep nesting passes through.
func TestRun_NestedStructurePreserved(t *testing.T) {
html := `<div><div><div><p><span><b>deep</b></span></p></div></div></div>`
rep := Run(html, Options{})
if len(rep.Blocked) != 0 {
t.Errorf("nested allowed tags should pass, got %+v", rep.Blocked)
}
if !strings.Contains(rep.CleanedHTML, "deep") {
t.Errorf("inner text lost, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_BlockedInsideAllowedRemovedNotParent verifies that removing a
// blocked tag inside an allowed parent leaves the parent intact.
func TestRun_BlockedInsideAllowedRemovedNotParent(t *testing.T) {
html := `<div>before<script>1</script>after</div>`
rep := Run(html, Options{})
if !strings.Contains(rep.CleanedHTML, "before") || !strings.Contains(rep.CleanedHTML, "after") {
t.Errorf("parent text should survive, cleaned=%q", rep.CleanedHTML)
}
if strings.Contains(rep.CleanedHTML, "<script") {
t.Errorf("script should be removed, cleaned=%q", rep.CleanedHTML)
}
}
// TestRun_ListDirectChildNonLIWrapped verifies that a <ul><ul> nested
// directly without an <li> wrapper triggers LIST_DIRECT_CHILD_NON_LI and
// the inner <ul> ends up wrapped in a synthetic <li>. Same for <ol><ol>.
func TestRun_ListDirectChildNonLIWrapped(t *testing.T) {
cases := []struct {
name string
html string
}{
{"ul wraps ul", `<ul><ul><li>x</li></ul></ul>`},
{"ol wraps ol", `<ol><ol><li>x</li></ol></ol>`},
{"ul wraps div", `<ul><div>orphan</div><li>real</li></ul>`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rep := Run(tc.html, Options{})
gotRule := false
for _, f := range rep.Applied {
if f.RuleID == RuleListDirectChildNonLI {
gotRule = true
break
}
}
if !gotRule {
t.Errorf("expected LIST_DIRECT_CHILD_NON_LI, got %+v", rep.Applied)
}
// The cleaned HTML should not have a direct ul>ul or ol>ol or
// ul>div sequence anymore.
if strings.Contains(rep.CleanedHTML, "<ul><ul") ||
strings.Contains(rep.CleanedHTML, "<ol><ol") ||
strings.Contains(rep.CleanedHTML, "<ul><div") {
t.Errorf("expected synthetic <li> wrapper, cleaned=%q", rep.CleanedHTML)
}
})
}
}