From 03907ee387ba603914a982c64e301a76ed3dc054 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 9 Oct 2026 22:48:59 +0200 Subject: [PATCH] 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 --- classes/format_html_test.go | 92 +++++++++++++++++++++++++++++++------ posts_admin_smoke_test.go | 48 +++++++++++++++++++ 2 files changed, 127 insertions(+), 13 deletions(-) diff --git a/classes/format_html_test.go b/classes/format_html_test.go index d7ec99e..d8beba2 100644 --- a/classes/format_html_test.go +++ b/classes/format_html_test.go @@ -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", "", "`, "`, "onerror"}, - {"javascript", "[x](javascript:alert(1))", "javascript:"}, - {"vbscript", "[x](vbscript:msgbox(1))", "vbscript:"}, - {"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", "data:"}, + {"script", ""}, + {"mixed-case script", ""}, + {"script inside an HTML block", "
\n

Intro

\n\n
"}, + {"script after markdown", "# Title\n\nText.\n\n"}, + {"iframe", ``}, + {"upper-case iframe", ``}, + {"object", ``}, + {"embed", ``}, + {"event", ``}, + {"svg onload", ``}, + {"mixed-case event handler", `

Click

`}, + {"event handler in an inline element", "Text with hover."}, + {"javascript", "[x](javascript:alert(1))"}, + {"mixed-case javascript href", `x`}, + {"vbscript", "[x](vbscript:msgbox(1))"}, + {"raw vbscript href", `x`}, + {"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)"}, + {"raw data URL attribute", ``}, + {"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 && 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, "

Tytuł

") || !strings.Contains(mixed, `
Autor
`) { 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", "

Pierwszy akapit.

\n

Drugi akapit z kursywą.

\n

Trzeci.

"}, + {"class attributes", `

Wstęp

`}, + {"https link", `

Zobacz źródło.

`}, + {"https image", `

Grzyby

`}, + {"entity in an attribute", `

link

`}, + {"table", "\n\n
AB
"}, + {"surrounding whitespace", "\n\n

Trimmed.

\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 = `` + const 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{ + "

Nagłówek

", + "pogrubieniem", + `linkiem`, + 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` diff --git a/posts_admin_smoke_test.go b/posts_admin_smoke_test.go index 4f71173..19564ab 100644 --- a/posts_admin_smoke_test.go +++ b/posts_admin_smoke_test.go @@ -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 = "

Grzyby rosną w lesie.

\n

Druga część z linkiem.

" + 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") +}