Files
sm-journal-plugin/classes/format_translation_test.go
Jakub Zych 0d00ec8e0d fix(api): sanitize translated content_html on every write
A translated content_html sent through the Journal API (syncTranslations
formatted only when content_html was empty) or through
journal:import-posts (no check) was stored verbatim and served by the
anonymous GET /_journal/api/v1/posts and /posts/{slug}. A translation
keyed on the default locale also wrote the host content_html column
past FormatHTML.

classes.FormatTranslation now sets content_html for every translated
locale before it is stored: rendered from a non-blank content with
FormatHTML (host-column parity; a supplied content_html is replaced),
otherwise a supplied content_html is sanitized with the new
classes.SanitizeHTML, which FormatHTML reuses. Both write paths call it.

PHP parity note: Winter's PostApiController::syncTranslations called
Post::formatHtml only when content_html was empty and otherwise stored
the supplied content_html verbatim; ImportPosts::syncTranslations stored
it verbatim; the MLJournalMarkdown admin widget always derived it from
content via formatHtml. The Go port now follows the admin widget on
every path. Response shapes and status codes for valid input are
unchanged; only the stored content_html of unsafe or content-mismatched
translations differs.
2026-10-10 00:09:47 +02:00

125 lines
4.0 KiB
Go

package classes
import "testing"
func TestSanitizeHTML(t *testing.T) {
const clean = `<p>Zobacz <a href="https://example.test/a?b=1&amp;c=2" class="ext">źródło</a>.</p>`
if got := SanitizeHTML(clean); got != clean {
t.Fatalf("clean HTML changed: %q", got)
}
if got, want := SanitizeHTML(`<p>a<img src=x onerror="alert(1)"></p>`), `<p>a<img src="x"></p>`; got != want {
t.Fatalf("got %q, want %q", got, want)
}
// SanitizeHTML takes HTML, not markdown: markdown syntax stays text.
if got := SanitizeHTML("**bold**"); got != "**bold**" {
t.Fatalf("markdown was rendered: %q", got)
}
for _, tc := range unsafePayloads {
if tc.src[0] != '<' {
continue // markdown, not HTML
}
t.Run(tc.name, func(t *testing.T) {
assertNoActiveContent(t, SanitizeHTML(tc.src))
})
}
}
func TestFormatTranslation(t *testing.T) {
const payload = `<p>Treść</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">x</a>`
t.Run("content derives content_html", func(t *testing.T) {
attrs := map[string]any{"title": "Witaj", "content": "Witaj **świecie**"}
if err := FormatTranslation(attrs); err != nil {
t.Fatal(err)
}
if got, want := attrs["content_html"], "<p>Witaj <strong>świecie</strong></p>\n"; got != want {
t.Fatalf("content_html = %#v, want %q", got, want)
}
if attrs["content"] != "Witaj **świecie**" || attrs["title"] != "Witaj" {
t.Fatalf("other attributes changed: %#v", attrs)
}
})
t.Run("content wins over a supplied content_html", func(t *testing.T) {
attrs := map[string]any{"content": "Tekst", "content_html": payload}
if err := FormatTranslation(attrs); err != nil {
t.Fatal(err)
}
if got, want := attrs["content_html"], "<p>Tekst</p>\n"; got != want {
t.Fatalf("content_html = %#v, want %q", got, want)
}
})
t.Run("raw HTML in content is sanitized", func(t *testing.T) {
attrs := map[string]any{"content": payload}
if err := FormatTranslation(attrs); err != nil {
t.Fatal(err)
}
assertNoActiveContent(t, attrs["content_html"].(string))
})
t.Run("content_html alone is sanitized", func(t *testing.T) {
for _, attrs := range []map[string]any{
{"content_html": payload},
{"content": "", "content_html": payload},
{"content": " ", "content_html": payload},
{"content": nil, "content_html": payload},
} {
if err := FormatTranslation(attrs); err != nil {
t.Fatal(err)
}
got, ok := attrs["content_html"].(string)
if !ok {
t.Fatalf("content_html = %#v, want a string", attrs["content_html"])
}
assertNoActiveContent(t, got)
if want := `<p>Treść</p><img src="x">x`; got != want {
t.Fatalf("content_html = %q, want %q", got, want)
}
}
})
t.Run("clean content_html alone is kept byte for byte", func(t *testing.T) {
const clean = "<p>Lion's Mane, \"superior herb\" &amp; inne</p>"
attrs := map[string]any{"content_html": clean}
if err := FormatTranslation(attrs); err != nil {
t.Fatal(err)
}
if attrs["content_html"] != clean {
t.Fatalf("content_html = %#v, want %q", attrs["content_html"], clean)
}
})
t.Run("non-string content_html is sanitized as text", func(t *testing.T) {
attrs := map[string]any{"content_html": []any{`<img src=x onerror="alert(1)">`}}
if err := FormatTranslation(attrs); err != nil {
t.Fatal(err)
}
got, ok := attrs["content_html"].(string)
if !ok {
t.Fatalf("content_html = %#v, want a string", attrs["content_html"])
}
assertNoActiveContent(t, got)
})
t.Run("absent and null content_html stay as they are", func(t *testing.T) {
attrs := map[string]any{"title": "Tytuł"}
if err := FormatTranslation(attrs); err != nil {
t.Fatal(err)
}
if _, has := attrs["content_html"]; has {
t.Fatalf("content_html added: %#v", attrs)
}
null := map[string]any{"content_html": nil}
if err := FormatTranslation(null); err != nil {
t.Fatal(err)
}
if v, has := null["content_html"]; !has || v != nil {
t.Fatalf("null content_html changed: %#v", null)
}
if err := FormatTranslation(nil); err != nil {
t.Fatal(err)
}
})
}