Files
sm-journal-plugin/classes/format_html_test.go
Jakub Zych 03907ee387 test: FormatHTML keep and refuse coverage, admin re-save keeps HTML
- TestFormatHTMLKeepsRawHTML: imported-style bodies (paragraphs, attributes, https links and images, entities) round-trip; markdown keeps a trailing raw footer and style
- TestFormatHTMLRejectsUnsafeHTML: every unsafe case must return an error now that the gate is the only defence; adds svg onload, mixed-case schemes and tags, object, embed, script in an HTML block and raw data URLs
- TestPostsAdminResaveKeepsHTML: an admin update of HTML content keeps content_html
2026-10-09 22:48:59 +02:00

150 lines
5.8 KiB
Go

package classes
import (
"strings"
"testing"
)
// TestFormatHTMLRejectsUnsafeHTML substitutes JOURNAL-003/004 Twig XSS
// templates (Phase 16). Script, iframe, event handlers, and javascript /
// vbscript / data schemes must fail the reject gate. Footnotes and tables
// from goldmark extensions still pass.
func TestFormatHTMLRejectsUnsafeHTML(t *testing.T) {
html, err := FormatHTML("# Hello")
if err != nil || !strings.Contains(html, "<h1>") {
t.Fatalf("safe heading: html=%s err=%v", html, err)
}
table, err := FormatHTML("| a | b |\n| - | - |\n| 1 | 2 |")
if err != nil {
t.Fatalf("table markdown: %v", err)
}
if !strings.Contains(table, "<table") {
t.Fatalf("table did not render: %s", table)
}
fn, err := FormatHTML("See footnote.[^1]\n\n[^1]: note")
if err != nil {
t.Fatalf("footnote markdown: %v", err)
}
if !strings.Contains(strings.ToLower(fn), "footnote") && !strings.Contains(fn, "<sup") {
t.Fatalf("footnote did not render: %s", fn)
}
// With unsafe rendering the gate is the only defence, so every case
// must be refused with an error, not merely rendered harmless.
cases := []struct {
name string
src string
}{
{"script", "<script>alert(1)</script>"},
{"mixed-case script", "<ScRiPt>alert(1)</sCrIpT>"},
{"script inside an HTML block", "<div>\n<p>Intro</p>\n<script>alert(1)</script>\n</div>"},
{"script after markdown", "# Title\n\nText.\n\n<script src=\"https://evil.test/x.js\"></script>"},
{"iframe", `<iframe src="https://evil.test"></iframe>`},
{"upper-case iframe", `<IFRAME SRC="https://evil.test"></IFRAME>`},
{"object", `<object data="https://evil.test/x.swf"></object>`},
{"embed", `<embed src="https://evil.test/x.swf">`},
{"event", `<img src=x onerror="alert(1)">`},
{"svg onload", `<svg onload="alert(1)"><circle r="1"/></svg>`},
{"mixed-case event handler", `<p OnClick="alert(1)">Click</p>`},
{"event handler in an inline element", "Text with <span onmouseover=\"alert(1)\">hover</span>."},
{"javascript", "[x](javascript:alert(1))"},
{"mixed-case javascript href", `<a href="JaVaScRiPt:alert(1)">x</a>`},
{"vbscript", "[x](vbscript:msgbox(1))"},
{"raw vbscript href", `<a href="VBScript:msgbox(1)">x</a>`},
{"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)"},
{"raw data URL attribute", `<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">`},
{"markdown image with a data URL", "![x](data:image/png;base64,iVBORw0KGgo=)"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := FormatHTML(tc.src)
if err == nil {
t.Fatalf("FormatHTML accepted unsafe input %q: %s", tc.src, out)
}
if out != "" {
t.Fatalf("FormatHTML returned HTML with its error: %s", out)
}
})
}
}
// TestFormatHTMLKeepsRawHTML pins D-03: raw HTML in content passes through
// unchanged, as Winter's Markdown::parse does, so an imported HTML body
// survives an admin save, an import or an API write.
func TestFormatHTMLKeepsRawHTML(t *testing.T) {
const paragraphs = "<p>Grzyby rosną w lesie.</p>\n<p>Druga <b>część</b>.</p>"
out, err := FormatHTML(paragraphs)
if err != nil {
t.Fatalf("html paragraphs: %v", err)
}
if strings.TrimSpace(out) != paragraphs {
t.Fatalf("html paragraphs changed:\n%s", out)
}
mixed, err := FormatHTML("# Tytuł\n\nTekst.\n\n<footer class=\"author\">Autor</footer>")
if err != nil {
t.Fatalf("markdown with a raw footer: %v", err)
}
if !strings.Contains(mixed, "<h1>Tytuł</h1>") || !strings.Contains(mixed, `<footer class="author">Autor</footer>`) {
t.Fatalf("raw footer was dropped:\n%s", mixed)
}
// Imported-style bodies: each round-trips unchanged after trimming.
bodies := []struct {
name string
src string
}{
{"paragraphs", "<p>Pierwszy akapit.</p>\n<p>Drugi akapit z <em>kursywą</em>.</p>\n<p>Trzeci.</p>"},
{"class attributes", `<div class="post-body"><p class="lead">Wstęp</p></div>`},
{"https link", `<p>Zobacz <a href="https://example.test/grzyby?a=1" class="ext" target="_blank" rel="noopener">źródło</a>.</p>`},
{"https image", `<p><img src="https://cdn.example.test/img/fungi.jpg" alt="Grzyby" width="640" height="480"></p>`},
{"entity in an attribute", `<p><a href="https://example.test/?a=1&amp;b=2" title="Tom &amp; Jerry &quot;quoted&quot;">link</a></p>`},
{"table", "<table>\n<tr><td>A</td><td>B</td></tr>\n</table>"},
{"surrounding whitespace", "\n\n <p>Trimmed.</p> \n"},
}
for _, tc := range bodies {
t.Run(tc.name, func(t *testing.T) {
out, err := FormatHTML(tc.src)
if err != nil {
t.Fatalf("FormatHTML(%q): %v", tc.src, err)
}
if strings.TrimSpace(out) != strings.TrimSpace(tc.src) {
t.Fatalf("html changed:\n got %q\nwant %q", strings.TrimSpace(out), strings.TrimSpace(tc.src))
}
if strings.Contains(out, "raw HTML omitted") {
t.Fatalf("goldmark placeholder in output: %s", out)
}
})
}
t.Run("markdown with a trailing raw footer and style", func(t *testing.T) {
const footer = `<footer class="source"><p>Źródło: <a href="https://example.test/ref">ref</a></p></footer>`
const style = "<style>\n.source { color: #555; }\n</style>"
src := "## Nagłówek\n\nTekst z **pogrubieniem** i [linkiem](https://example.test).\n\n" + footer + "\n\n" + style
out, err := FormatHTML(src)
if err != nil {
t.Fatalf("markdown with footer and style: %v", err)
}
for _, want := range []string{
"<h2>Nagłówek</h2>",
"<strong>pogrubieniem</strong>",
`<a href="https://example.test">linkiem</a>`,
footer,
style,
} {
if !strings.Contains(out, want) {
t.Fatalf("output lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, "raw HTML omitted") {
t.Fatalf("goldmark placeholder in output: %s", out)
}
})
}
// TestJournalEndToEnd keeps `go test ./... -run TestJournalEndToEnd`
// from reporting [no tests to run] in this package.
func TestJournalEndToEnd(t *testing.T) {}