Files
larksuite-cli/shortcuts/mail/mail_lint_html_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

275 lines
8.8 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/json"
"os"
"strings"
"testing"
)
// =====================================================================
// +lint-html Shortcut tests — public stdout envelope contract checks.
//
// These exercise the full cobra Mount → Execute pipeline (parse args →
// Validate → Execute → OutFormat) so they catch any regression in flag
// declaration, mutual-exclusion validation, path safety, and the JSON
// envelope shape.
// =====================================================================
// TestMailLintHTML_RequiresExactlyOneOfBodyOrFile verifies the mutual-
// exclusion + at-least-one-of constraint surfaces ErrValidation.
func TestMailLintHTML_RequiresExactlyOneOfBodyOrFile(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
t.Run("neither flag", func(t *testing.T) {
err := runMountedMailShortcut(t, MailLintHTML, []string{"+lint-html"}, f, stdout)
if err == nil {
t.Fatal("expected error when neither flag is set")
}
if !strings.Contains(err.Error(), "exactly one of --body or --body-file") {
t.Errorf("wrong error: %v", err)
}
})
t.Run("both flags", func(t *testing.T) {
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", "<p>x</p>",
"--body-file", "fake.html",
}, f, stdout)
if err == nil {
t.Fatal("expected error when both flags set")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("wrong error: %v", err)
}
})
}
// TestMailLintHTML_BodyFilePathSafetyRejected verifies absolute paths /
// `..` traversal are rejected by the path safety check.
func TestMailLintHTML_BodyFilePathSafetyRejected(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
chdirTemp(t)
t.Run("absolute path", func(t *testing.T) {
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body-file", "/etc/passwd",
}, f, stdout)
if err == nil {
t.Fatal("expected validation error for absolute path")
}
})
t.Run("dotdot traversal", func(t *testing.T) {
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body-file", "../../../etc/passwd",
}, f, stdout)
if err == nil {
t.Fatal("expected validation error for traversal")
}
})
}
// TestMailLintHTML_BodyFileReadsCwdSubpath verifies a legitimate cwd-subtree
// path loads HTML correctly.
func TestMailLintHTML_BodyFileReadsCwdSubpath(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
chdirTemp(t)
if err := os.WriteFile("input.html", []byte(`<p>safe</p><script>1</script>`), 0o644); err != nil {
t.Fatal(err)
}
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body-file", "input.html",
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("expected success, got: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
errors, _ := data["errors"].([]interface{})
if len(errors) != 1 {
t.Errorf("expected 1 error finding (script), got %d: %+v", len(errors), errors)
}
cleaned, _ := data["cleaned_html"].(string)
if strings.Contains(cleaned, "<script") {
t.Errorf("cleaned_html should not contain <script>, got %q", cleaned)
}
}
// TestMailLintHTML_DefaultEnvelopeShape verifies the default envelope only
// contains cleaned_html — warnings[] / errors[] are token-frugally suppressed
// unless --show-lint-details is passed.
func TestMailLintHTML_DefaultEnvelopeShape(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", `<p>safe content</p>`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if _, ok := data["cleaned_html"]; !ok {
t.Error("cleaned_html key missing from envelope (default --auto-fix=true)")
}
if _, ok := data["warnings"]; ok {
t.Error("warnings[] must be hidden in default mode (use --show-lint-details to surface)")
}
if _, ok := data["errors"]; ok {
t.Error("errors[] must be hidden in default mode (use --show-lint-details to surface)")
}
}
// TestMailLintHTML_ShowLintDetailsExposesArrays verifies --show-lint-details
// surfaces the full warnings[] / errors[] arrays alongside cleaned_html.
func TestMailLintHTML_ShowLintDetailsExposesArrays(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", `<p>safe content</p>`,
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if _, ok := data["warnings"]; !ok {
t.Error("warnings[] missing in --show-lint-details mode")
}
if _, ok := data["errors"]; !ok {
t.Error("errors[] missing in --show-lint-details mode")
}
}
// TestMailLintHTML_PlainTextBodyShortCircuits verifies plain-text input
// produces empty arrays (lib short-circuit path) when --show-lint-details is
// set; without the flag, the arrays are omitted entirely.
func TestMailLintHTML_PlainTextBodyShortCircuits(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", "just plain text, no markup",
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
w, _ := data["warnings"].([]interface{})
e, _ := data["errors"].([]interface{})
if len(w) != 0 || len(e) != 0 {
t.Errorf("plain text should produce no findings, got w=%v e=%v", w, e)
}
}
// TestMailLintHTML_FindingShape verifies each finding entry has the
// contract-required keys (rule_id / severity / tag_or_attr / excerpt / hint).
func TestMailLintHTML_FindingShape(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", `<p>x</p><script>alert(1)</script>`,
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
errors, _ := data["errors"].([]interface{})
if len(errors) == 0 {
t.Fatal("expected at least 1 error finding")
}
first, _ := errors[0].(map[string]interface{})
for _, key := range []string{"rule_id", "severity", "tag_or_attr", "excerpt", "hint"} {
if _, ok := first[key]; !ok {
t.Errorf("finding missing required key %q: %+v", key, first)
}
}
if first["severity"] != "error" {
t.Errorf("severity = %v, want error", first["severity"])
}
if !strings.HasPrefix(first["rule_id"].(string), "TAG_") &&
!strings.HasPrefix(first["rule_id"].(string), "ATTR_") &&
!strings.HasPrefix(first["rule_id"].(string), "STYLE_") {
t.Errorf("rule_id must be UPPER_SNAKE_CASE prefix, got %v", first["rule_id"])
}
}
// TestMailLintHTML_DryRun verifies dry-run mode doesn't execute lint and
// surfaces the read-only / no-network annotation.
func TestMailLintHTML_DryRun(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", `<p>x</p>`,
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Dry-run output is JSON containing "mode":"local-lint-only".
if !strings.Contains(stdout.String(), "local-lint-only") {
t.Errorf("expected dry-run mode marker, stdout=%s", stdout.String())
}
}
// TestMailLintHTML_BlockedTagAndWarningAccumulate verifies the report
// surfaces both warning + error findings simultaneously.
func TestMailLintHTML_BlockedTagAndWarningAccumulate(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
body := `<font color="red">warn-tag</font><script>err-tag</script>` +
`<a href="javascript:0">err-url</a>`
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", body,
"--show-lint-details",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
w, _ := data["warnings"].([]interface{})
e, _ := data["errors"].([]interface{})
if len(w) < 1 {
t.Errorf("expected ≥ 1 warning, got %d", len(w))
}
if len(e) < 2 {
t.Errorf("expected ≥ 2 errors (script + js URL), got %d", len(e))
}
}
// TestMailLintHTML_FindingsAreJSONSerialisable confirms the cleaned envelope
// can round-trip through json (no nil / function values leak in).
func TestMailLintHTML_FindingsAreJSONSerialisable(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailLintHTML, []string{
"+lint-html",
"--body", `<font color="red">x</font>`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Re-encode the data back to JSON to confirm it's serialisable.
data := decodeShortcutEnvelopeData(t, stdout)
if _, err := json.Marshal(data); err != nil {
t.Errorf("envelope not JSON-serialisable: %v", err)
}
}