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
This commit is contained in:
@@ -31,26 +31,40 @@ func TestFormatHTMLRejectsUnsafeHTML(t *testing.T) {
|
||||
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
|
||||
needle string
|
||||
name string
|
||||
src string
|
||||
}{
|
||||
{"script", "<script>alert(1)</script>", "<script"},
|
||||
{"iframe", `<iframe src="https://evil.test"></iframe>`, "<iframe"},
|
||||
{"event", `<img src=x onerror="alert(1)">`, "onerror"},
|
||||
{"javascript", "[x](javascript:alert(1))", "javascript:"},
|
||||
{"vbscript", "[x](vbscript:msgbox(1))", "vbscript:"},
|
||||
{"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", "data:"},
|
||||
{"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", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := FormatHTML(tc.src)
|
||||
if err == nil && strings.Contains(strings.ToLower(out), tc.needle) {
|
||||
t.Fatalf("unsafe HTML survived: %s", out)
|
||||
if err == nil {
|
||||
t.Fatalf("FormatHTML accepted unsafe input %q: %s", tc.src, out)
|
||||
}
|
||||
if err == nil && (tc.name == "script" || tc.name == "iframe" || tc.name == "event") && strings.Contains(strings.ToLower(out), tc.needle) {
|
||||
t.Fatalf("FormatHTML accepted %s: %s", tc.name, out)
|
||||
if out != "" {
|
||||
t.Fatalf("FormatHTML returned HTML with its error: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -76,6 +90,58 @@ func TestFormatHTMLKeepsRawHTML(t *testing.T) {
|
||||
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&b=2" title="Tom & Jerry "quoted"">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`
|
||||
|
||||
@@ -103,3 +103,51 @@ func TestPostsAdminCreateSmoke(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPostsAdminResaveKeepsHTML is the acceptance test of the todo
|
||||
// journal-imported-html-admin-resave (D-03): an admin save of a post whose
|
||||
// content is HTML, as imported posts are, keeps that HTML in content_html
|
||||
// instead of goldmark's raw-HTML placeholder.
|
||||
func TestPostsAdminResaveKeepsHTML(t *testing.T) {
|
||||
env := newPostsEnv(t)
|
||||
token := env.admin(t,
|
||||
controllers.PermissionAccessPosts,
|
||||
controllers.PermissionAccessPublish,
|
||||
controllers.PermissionAccessOtherPosts,
|
||||
)
|
||||
const content = "<p>Grzyby rosną w lesie.</p>\n<p>Druga <b>część</b> z <a href=\"https://example.test/zrodlo\">linkiem</a>.</p>"
|
||||
body := map[string]any{
|
||||
"title": map[string]any{"en": "Imported", "pl": "Zaimportowany"},
|
||||
"slug": map[string]any{"en": "imported-html", "pl": "zaimportowany-html"},
|
||||
"content": map[string]any{"en": content, "pl": content},
|
||||
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
|
||||
}
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, body)
|
||||
var created struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(adminDecode(t, rec).Data, &created); err != nil || created.ID == 0 {
|
||||
t.Fatalf("create data %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
|
||||
assertHTML := func(stage string) {
|
||||
t.Helper()
|
||||
var stored models.Post
|
||||
if err := env.gdb.First(&stored, created.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(stored.ContentHTML, "raw HTML omitted") {
|
||||
t.Fatalf("%s: content_html holds the goldmark placeholder: %q", stage, stored.ContentHTML)
|
||||
}
|
||||
if strings.TrimSpace(stored.ContentHTML) != content {
|
||||
t.Fatalf("%s: content_html = %q, want %q", stage, strings.TrimSpace(stored.ContentHTML), content)
|
||||
}
|
||||
if stored.Content != content {
|
||||
t.Fatalf("%s: content = %q, want %q", stage, stored.Content, content)
|
||||
}
|
||||
}
|
||||
assertHTML("create")
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, created.ID)), token, body)
|
||||
assertHTML("re-save")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user