Files
summercms/modules/breakwater/breakwater_test.go
Jakub Zych 98bc6de8f5 feat(breakwater): Markdown renderer and HTML sanitizer with the forum policy (D-17)
- modules/breakwater exports Render(markdown) and Purify(html): goldmark GFM
  in its default safe mode (raw HTML stripped, unsafe links dropped), then a
  named bluemonday policy built from NewPolicy, not the UGC preset
- the policy is the PHP golem15/forum HTMLPurifier config 1:1: the fixed
  element list, a[href|rel|target], http/https/mailto only, rel=nofollow on
  every link, target=_blank plus noopener on fully-qualified links
- tests cover the PHP ForumMarkdownRenderTest corpus, links per scheme,
  blank input, the Purify allow-list and concurrent use; testable examples
  feed the docs page snippets
- module README, root module table row, introduction.md Utilities row and
  docs/services/markdown.md (which gate serves user content vs admin fields)
- go.mod and go.sum unchanged: bluemonday v1.0.27 and goldmark v1.8.6 were
  already pinned
2026-10-18 14:52:30 +02:00

187 lines
5.9 KiB
Go

package breakwater_test
import (
"strings"
"sync"
"testing"
"git.golem15.com/golem15/summercms/modules/breakwater"
)
// The corpora of the PHP golem15/forum ForumMarkdownRenderTest, verbatim.
const (
xssCorpus = "<script>alert(1)</script>\n\n[x](javascript:alert(1))\n\n<iframe src=x></iframe>\n\n<img src=x onerror=alert(1)>"
gfmCorpus = "| A | B |\n|---|---|\n| 1 | 2 |\n\n```php\necho 'wave';\n```"
)
func wantContains(t *testing.T, got string, subs ...string) {
t.Helper()
for _, s := range subs {
if !strings.Contains(got, s) {
t.Errorf("output lacks %q:\n%s", s, got)
}
}
}
func wantNotContains(t *testing.T, got string, subs ...string) {
t.Helper()
for _, s := range subs {
if strings.Contains(got, s) {
t.Errorf("output contains %q:\n%s", s, got)
}
}
}
func TestRenderXSS(t *testing.T) {
got := breakwater.Render(xssCorpus)
lower := strings.ToLower(got)
wantNotContains(t, lower, "<script", "javascript:", "<iframe", "onerror", "<img", "&lt;script", "<!--")
wantContains(t, got, "x")
}
func TestRenderStripsRawHTML(t *testing.T) {
cases := []struct {
name string
in string
keeps []string
}{
{"script", "<script>alert(1)</script>", nil},
{"inline", "text <b onclick=\"x()\">bold</b> more", []string{"text", "more"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := breakwater.Render(tc.in)
wantNotContains(t, got, "alert(1)", "onclick", "<!--", "&lt;")
wantContains(t, got, tc.keeps...)
})
}
}
func TestRenderGFM(t *testing.T) {
got := breakwater.Render(gfmCorpus)
wantContains(t, got, "<table>", "<th>A</th>", "<td>1</td>", "<pre>", "<code")
wantNotContains(t, got, "class=")
got = breakwater.Render("~~gone~~ **kept**")
wantContains(t, got, "<del>gone</del>", "<strong>kept</strong>")
got = breakwater.Render("# Title\n\n> quote\n\n- a\n- b\n\n---")
wantContains(t, got, "<h1>", "<blockquote>", "<ul>", "<li>", "<hr")
}
func TestRenderLinks(t *testing.T) {
t.Run("absolute", func(t *testing.T) {
got := breakwater.Render("[site](https://example.com/a?b=1&c=2)")
wantContains(t, got, `href="https://example.com/a?b=1&amp;c=2"`, `target="_blank"`)
rel := relValue(t, got)
wantContains(t, rel, "nofollow", "noopener")
})
t.Run("autolink", func(t *testing.T) {
got := breakwater.Render("visit https://example.com/x now")
wantContains(t, got, `<a href="https://example.com/x"`, "nofollow", `target="_blank"`)
})
t.Run("mailto", func(t *testing.T) {
got := breakwater.Render("[mail](mailto:a@example.com)")
wantContains(t, got, `href="mailto:a@example.com"`, "nofollow")
})
dropped := []struct {
name, in, scheme, text string
}{
{"data", "[d](data:text/html;base64,AAAA)", "data:", "d"},
{"vbscript", "[v](vbscript:x)", "vbscript:", "v"},
{"tel", "[t](tel:+48123456)", "tel:", "t"},
}
for _, tc := range dropped {
t.Run(tc.name, func(t *testing.T) {
got := breakwater.Render(tc.in)
wantNotContains(t, got, "data:", "vbscript:", "tel:", "<a")
wantContains(t, got, tc.text)
})
}
t.Run("relative", func(t *testing.T) {
got := breakwater.Render("[rel](/threads/1)")
wantNotContains(t, got, "href")
wantContains(t, got, "rel")
})
}
// relValue returns the value of the first rel attribute in html.
func relValue(t *testing.T, html string) string {
t.Helper()
_, after, ok := strings.Cut(html, `rel="`)
if !ok {
t.Fatalf("no rel attribute in:\n%s", html)
}
val, _, ok := strings.Cut(after, `"`)
if !ok {
t.Fatalf("unterminated rel attribute in:\n%s", html)
}
return val
}
func TestBlankInput(t *testing.T) {
cases := []struct {
name string
got string
}{
{"render empty", breakwater.Render("")},
{"render whitespace", breakwater.Render(" \n\t ")},
{"purify empty", breakwater.Purify("")},
{"purify whitespace", breakwater.Purify(" \n ")},
}
for _, tc := range cases {
if tc.got != "" {
t.Errorf("%s: got %q, want \"\"", tc.name, tc.got)
}
}
}
func TestPurifyAllowList(t *testing.T) {
in := `<p onclick="x()">Hi <b>b</b> <i>i</i> <img src=x onerror=alert(1)><script>alert(1)</script></p>` +
`<figure><img src="a.jpg"></figure><h2 class="t" id="h">H</h2>` +
`<a href="https://e.com/p" target="_top" rel="opener">l</a><a href="tel:+48">t</a>` +
`<sub>s</sub><sup>p</sup><ins>i</ins><del>d</del><hr>` +
`<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>c</td></tr></tbody></table>` +
`<blockquote><pre><code>c &lt; d</code></pre></blockquote>` +
`<ul><li>u</li></ul><ol><li>o</li></ol><h6>6</h6><br>`
got := breakwater.Purify(in)
wantContains(t, got,
"<p>", "<b>b</b>", "<i>i</i>", "<h2>H</h2>", "<sub>s</sub>", "<sup>p</sup>", "<ins>i</ins>", "<del>d</del>",
"<hr", "<table>", "<thead>", "<th>h</th>", "<tbody>", "<td>c</td>", "<blockquote>", "<pre>",
"<code>c &lt; d</code>", "<ul>", "<ol>", "<li>", "<h6>6</h6>", "<br",
`href="https://e.com/p"`, `target="_blank"`,
)
rel := relValue(t, got)
wantContains(t, rel, "nofollow", "noopener")
lower := strings.ToLower(got)
wantNotContains(t, lower, "onclick", "onerror", "<img", "<script", "<figure", "class=", "id=",
`target="_top"`, `rel="opener"`, "tel:", "alert(")
// The text of the dropped tel link survives.
wantContains(t, got, "</a>t<sub>")
got = breakwater.Purify("<p>plain</p><!-- c --><style>p{}</style><form><input></form><svg onload=x></svg><iframe src=x></iframe>")
wantContains(t, got, "<p>plain</p>")
wantNotContains(t, got, "<!--", "<style", "<form", "<input", "<svg", "onload", "<iframe")
}
func TestConcurrentUse(t *testing.T) {
wantRender := breakwater.Render(gfmCorpus)
wantPurify := breakwater.Purify(wantRender)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 20; j++ {
if got := breakwater.Render(gfmCorpus); got != wantRender {
t.Errorf("concurrent Render differs:\n%s\nwant:\n%s", got, wantRender)
}
if got := breakwater.Purify(wantRender); got != wantPurify {
t.Errorf("concurrent Purify differs:\n%s\nwant:\n%s", got, wantPurify)
}
}
}()
}
wg.Wait()
}