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", `
a
`, `
a
`},
{"negative start", `
a
`, `
a
`},
{"start on ul", `
a
`, `
a
`},
{"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", `
a
`, `
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{
``,
``,
``,
``,
} {
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 = `
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: `