package classes import ( "encoding/json" "os" "slices" "strings" "testing" xhtml "golang.org/x/net/html" ) // editorPlaceholder is the Blog Editor's YouTube embed placeholder. const editorPlaceholder = `
` // editorCorpus reads plan 02's editor-corpus.json, the editor's own output. // A missing or empty file fails the test; it never skips. func editorCorpus(t *testing.T) []struct{ Name, HTML string } { t.Helper() raw, err := os.ReadFile("../tests/blogeditor/fixtures/editor-corpus.json") if err != nil { t.Fatalf("editor corpus: %v", err) } var entries []struct { Name string `json:"name"` HTML string `json:"html"` } if err := json.Unmarshal(raw, &entries); err != nil { t.Fatalf("editor corpus: %v", err) } if len(entries) == 0 { t.Fatal("editor corpus is empty") } out := make([]struct{ Name, HTML string }, 0, len(entries)) for _, e := range entries { if e.Name == "" || e.HTML == "" { t.Fatalf("editor corpus entry %+v has no name or html", e) } out = append(out, struct{ Name, HTML string }{e.Name, e.HTML}) } return out } // tagAttrs returns the attribute names of every start tag named tag in doc, // one slice per tag, repeats included. func tagAttrs(doc, tag string) [][]string { var out [][]string z := xhtml.NewTokenizer(strings.NewReader(doc)) for { tt := z.Next() if tt == xhtml.ErrorToken { return out } if tt != xhtml.StartTagToken && tt != xhtml.SelfClosingTagToken { continue } raw := rawAttrNames(z.Raw()) if z.Token().Data == tag { out = append(out, raw) } } } // TestEditorOutputPassesPolicy pins Phase 16.2 D-05 (amended): the HTML // policy keeps every piece of markup the Blog Editor writes, byte for byte // and with an empty removal report, and its four editor-output additions // (figure data-embed and data-id, img alt, ol start) admit nothing else. func TestEditorOutputPassesPolicy(t *testing.T) { t.Run("corpus is the editor's full output set", func(t *testing.T) { corpus := editorCorpus(t) if len(corpus) < 15 { t.Fatalf("corpus has %d entries, want at least 15", len(corpus)) } names := map[string]bool{} for _, e := range corpus { if e.Name == "" || strings.TrimSpace(e.HTML) == "" || names[e.Name] { t.Fatalf("corpus entry %q is empty or repeated", e.Name) } names[e.Name] = true } for _, want := range []string{"embed-youtube", "image-with-caption", "image-without-caption", "ordered-list-start-3", "table-header-row", "links", "combined"} { if !names[want] { t.Fatalf("corpus has no %s entry", want) } } }) for _, e := range editorCorpus(t) { t.Run("corpus "+e.Name, func(t *testing.T) { if got := SanitizeHTML(e.HTML); got != e.HTML { t.Fatalf("SanitizeHTML changed the editor's output:\n got %q\nwant %q", got, e.HTML) } if _, report := SanitizeHTMLReport(e.HTML); !report.Empty() { t.Fatalf("report = %+v, want empty", report) } if err := CheckHTML(e.HTML); err != nil { t.Fatalf("CheckHTML = %v", err) } assertNoActiveContent(t, e.HTML) }) } t.Run("placeholder is kept", func(t *testing.T) { if got := SanitizeHTML(editorPlaceholder); got != editorPlaceholder { t.Fatalf("SanitizeHTML(placeholder) = %q", got) } out, err := FormatHTML("Intro\n\n" + editorPlaceholder + "\n\nOutro") if err != nil { t.Fatal(err) } if !strings.Contains(out, editorPlaceholder) { t.Fatalf("FormatHTML dropped the placeholder: %q", out) } }) stripped := []struct { name, in, want string }{ {"other provider", `
`, `
`}, {"provider with a suffix", `
`, `
`}, {"id of 10 characters", `
`, `
`}, {"id of 12 characters", `
`, `
`}, {"id with a quote", `
`, `
`}, {"id with a space", `
`, `
`}, {"id with an angle bracket", `
`, `
`}, {"embed attributes on div", `
x
`, `
x
`}, {"embed attributes on img", ``, ``}, {"embed attributes on p", `

x

`, `

x

`}, {"other data attribute on figure", `
`, editorPlaceholder}, {"data attribute alone on figure", `
`, `
`}, {"onload on the placeholder", `
`, editorPlaceholder}, {"iframe next to the placeholder", editorPlaceholder + ``, editorPlaceholder}, {"start that is not a number", `
  1. a
`, `
  1. a
`}, {"negative start", `
  1. a
`, `
  1. a
`}, {"start on ul", ``, ``}, {"alt on p", `

y

`, `

y

`}, {"upper-case provider", `
`, `
`}, {"provider with white space", `
`, `
`}, {"empty id", `
`, `
`}, {"id with a URL", `
`, `
`}, {"iframe alone", ``, ``}, {"script inside the placeholder", `
`, editorPlaceholder}, {"embed attributes on section", `
x
`, `
x
`}, {"embed attributes on a", `x`, `x`}, {"start with a fraction", `
  1. a
`, `
  1. a
`}, } for _, tc := range stripped { t.Run("strips "+tc.name, func(t *testing.T) { got, report := SanitizeHTMLReport(tc.in) if got != tc.want { t.Fatalf("SanitizeHTMLReport(%q) = %q, want %q", tc.in, got, tc.want) } if report.Empty() { t.Fatalf("report is empty for a stripped input %q", tc.in) } if CheckHTML(tc.in) == nil { t.Fatalf("CheckHTML(%q) = nil, want an error", tc.in) } }) } t.Run("alt keeps any text as one attribute", func(t *testing.T) { for _, in := range []string{ `Turkey Tail: czy to "grzyb odporności"?`, `x" onerror="alert(1)`, `x" onerror="alert(1)`, `<script>alert(1)</script>`, } { got, report := SanitizeHTMLReport(in) if got != in || !report.Empty() { t.Fatalf("SanitizeHTMLReport(%q) = %q, %+v, want it unchanged", in, got, report) } out := htmlPolicy.Sanitize(in) if attrs := tagAttrs(out, "img"); len(attrs) != 1 || !slices.Equal(attrs[0], []string{"src", "alt"}) { t.Fatalf("policy output %q has img attributes %v, want [src alt]", out, attrs) } } }) t.Run("ol start keeps a number", func(t *testing.T) { const in = `
  1. c
` if got := SanitizeHTML(in); got != in { t.Fatalf("SanitizeHTML(%q) = %q", in, got) } }) } // TestSanitizeHTMLReport pins the removal report the Blog Editor turns into // admin notices (D-10 amended). func TestSanitizeHTMLReport(t *testing.T) { cases := []struct { name string in, out string elements []string elementCount int attributes []string attributeCount int comments int other bool }{ {name: "clean", in: `

Plain link

`, out: `

Plain link

`}, {name: "style element", in: `

a

`, out: `

a

`, elements: []string{"style"}, elementCount: 1}, {name: "onclick attribute", in: `

x

`, out: `

x

`, attributes: []string{"onclick"}, attributeCount: 1}, {name: "comment", in: `

a

`, out: `

a

`, comments: 2}, {name: "repeated attribute", in: `

x

`, out: `

x

`, attributes: []string{"class"}, attributeCount: 1}, {name: "style loses a property", in: `

x

`, out: `

x

`, attributes: []string{"style"}, attributeCount: 1}, {name: "javascript link", in: `xy`, out: `xy`, elements: []string{"a"}, elementCount: 1}, { name: "more than ten elements", in: `
`, out: ``, elements: []string{"script", "iframe", "object", "embed", "form", "input", "button", "select", "textarea", "base"}, elementCount: 13, }, { name: "more than ten attributes", in: `

x

`, out: `

x

`, attributes: []string{"onclick", "onload", "onerror", "onmouseover", "onfocus", "onblur", "onkeyup", "onkeydown", "onchange", "onsubmit"}, attributeCount: 12, }, {name: "doctype", in: "

x

", out: "

x

", other: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, r := SanitizeHTMLReport(tc.in) if got != tc.out { t.Fatalf("output = %q, want %q", got, tc.out) } if !slices.Equal(r.Elements, tc.elements) || r.ElementCount != tc.elementCount || !slices.Equal(r.Attributes, tc.attributes) || r.AttributeCount != tc.attributeCount || r.Comments != tc.comments || r.Other != tc.other { t.Fatalf("report = %+v", r) } if r.Empty() != (got == tc.in) { t.Fatalf("Empty() = %v but output changed = %v", r.Empty(), got != tc.in) } }) } t.Run("cut lists end the notice names with an ellipsis", func(t *testing.T) { _, r := SanitizeHTMLReport(cases[7].in) notices := removalNotices(r) if len(notices) != 1 || notices[0].Count != 13 || !strings.HasSuffix(notices[0].Params["names"], ", …") || !strings.HasPrefix(notices[0].Params["names"], "script, iframe, ") { t.Fatalf("notices = %#v", notices) } _, r = SanitizeHTMLReport(`

x

`) notices = removalNotices(r) if len(notices) != 2 || notices[0].Params["names"] != "onclick" || notices[1].Message != editorRemovedComments || notices[1].Count != 1 { t.Fatalf("notices = %#v", notices) } _, r = SanitizeHTMLReport("

x

") if notices = removalNotices(r); len(notices) != 1 || notices[0].Message != editorMarkupChanged || notices[0].Count != 0 { t.Fatalf("notices = %#v", notices) } }) t.Run("report is empty exactly when the output equals the input", func(t *testing.T) { inputs := []string{} for _, tc := range unsafePayloads { inputs = append(inputs, tc.src) } fixture, err := os.ReadFile("../tests/blogeditor/fixtures/imported-post.html") if err != nil { t.Fatal(err) } inputs = append(inputs, string(fixture)) for _, e := range editorCorpus(t) { inputs = append(inputs, e.HTML) } for _, in := range inputs { got, r := SanitizeHTMLReport(in) if r.Empty() != (got == in) { t.Fatalf("SanitizeHTMLReport(%q): Empty() = %v, output %q", in, r.Empty(), got) } if got != SanitizeHTML(in) { t.Fatalf("SanitizeHTMLReport and SanitizeHTML disagree on %q", in) } // Notices only when something was stripped (D-10 amended). if notices := removalNotices(r); (len(notices) == 0) != (got == in) { t.Fatalf("SanitizeHTMLReport(%q): %d notices, output changed = %v", in, len(notices), got != in) } // The report is stable: sanitizing the output again changes nothing. if again, r2 := SanitizeHTMLReport(got); again != got || !r2.Empty() { t.Fatalf("second pass over %q = %q, %+v", got, again, r2) } } }) t.Run("the imported post is stripped of exactly its style block", func(t *testing.T) { fixture, err := os.ReadFile("../tests/blogeditor/fixtures/imported-post.html") if err != nil { t.Fatal(err) } got, r := SanitizeHTMLReport(string(fixture)) if got == string(fixture) || r.Empty() { t.Fatalf("the imported post passed the policy unchanged; the fixture should hold markup the policy strips") } if !slices.Contains(r.Elements, "style") { t.Fatalf("report = %+v, want the style element named", r) } assertNoActiveContent(t, got) }) }