LooksLikeHTML decided by the first character alone, so a markdown post opening with a hero <img>, a <div> or a comment was served as raw markdown and its first edit stored ## and ** markers as HTML in both columns. Load now serves such a value as HTML only when content_html is empty or missing, or holds that same HTML (the value or SanitizeHTML of it, white space at the ends ignored); otherwise it serves content_html.
733 lines
26 KiB
Go
733 lines
26 KiB
Go
package classes
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gocloud.dev/blob/memblob"
|
|
)
|
|
|
|
// Unit tests of the Blog Editor field types (Phase 16.2 plan 06).
|
|
|
|
// blogOptions are the post content field's options.
|
|
var blogOptions = map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"}
|
|
|
|
// noTranslated is a Translated reader that fails the test when called.
|
|
func noTranslated(t *testing.T) func(string) (string, bool, error) {
|
|
return func(attribute string) (string, bool, error) {
|
|
t.Errorf("Translated(%q) called for HTML content", attribute)
|
|
return "", false, nil
|
|
}
|
|
}
|
|
|
|
// translatedReader serves one attribute and records what was asked.
|
|
func translatedReader(asked *[]string, value string, found bool, err error) func(string) (string, bool, error) {
|
|
return func(attribute string) (string, bool, error) {
|
|
*asked = append(*asked, attribute)
|
|
return value, found, err
|
|
}
|
|
}
|
|
|
|
func TestBlogEditorLoad(t *testing.T) {
|
|
const markdown = "## Nagłówek\n\nTekst z *kursywą*."
|
|
rendered, err := FormatHTML(markdown)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rendered == markdown || rendered == "" {
|
|
t.Fatalf("FormatHTML(%q) = %q", markdown, rendered)
|
|
}
|
|
html := "<p>Grzyby</p>"
|
|
empty := ""
|
|
|
|
t.Run("HTML content is served verbatim", func(t *testing.T) {
|
|
for _, stored := range []string{
|
|
"<p>Grzyby rosną.</p>\n<footer class=\"author-footer\"><p>Autor</p></footer>\n",
|
|
"\n \t<p>leading white space</p>",
|
|
"<!-- imported --><p>leading comment</p>",
|
|
"\n<!-- imported -->\n<p>both</p>\n",
|
|
"<style>.x{}</style><p>unsafe markup is served too; Decode strips it on save</p>",
|
|
} {
|
|
// content_html missing, empty, the stored HTML itself (an admin
|
|
// save), the policy's output for it, or either with other white
|
|
// space at the ends (FormatHTML trims and goldmark appends "\n").
|
|
for _, tc := range []struct {
|
|
name string
|
|
html string
|
|
found bool
|
|
}{
|
|
{"missing", "", false},
|
|
{"empty", "", true},
|
|
{"equal", stored, true},
|
|
{"sanitized", SanitizeHTML(stored), true},
|
|
{"trimmed", strings.TrimSpace(stored) + "\n", true},
|
|
{"sanitized and trimmed", "\n" + strings.TrimSpace(SanitizeHTML(stored)), true},
|
|
} {
|
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
|
Options: blogOptions, Locale: "pl", Default: true, Value: stored,
|
|
Translated: translatedReader(new([]string), tc.html, tc.found, nil),
|
|
})
|
|
if err != nil || got != stored {
|
|
t.Fatalf("Load(%q) with content_html %s = %q, %v; want the stored bytes", stored, tc.name, got, err)
|
|
}
|
|
}
|
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
|
Options: blogOptions, Locale: "pl", Default: true, Value: stored,
|
|
})
|
|
if err != nil || got != stored {
|
|
t.Fatalf("Load(%q) without a reader = %q, %v; want the stored bytes", stored, got, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
// WR-04: markdown whose first character is '<' is not HTML when its
|
|
// content_html is a different rendering of it; serving the raw markdown
|
|
// would make the first edit store "##" and "**" as HTML.
|
|
t.Run("markdown that starts with a tag is served as content_html", func(t *testing.T) {
|
|
for _, source := range []string{
|
|
"<img src=\"/storage/hero.png\" alt=\"Hero\">\n\n## Nagłówek\n\nTekst z **pogrubieniem**.",
|
|
"<div class=\"lead\">Wstęp</div>\n\n## Nagłówek\n\n*kursywa*",
|
|
"<!-- imported -->\n\n## Heading\n\n**bold**",
|
|
} {
|
|
stored, err := FormatHTML(source)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !LooksLikeHTML(source) || sameStoredHTML(source, stored) {
|
|
t.Fatalf("fixture %q does not exercise WR-04 (content_html %q)", source, stored)
|
|
}
|
|
var asked []string
|
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
|
Options: blogOptions, Locale: "en", Value: source,
|
|
Translated: translatedReader(&asked, stored, true, nil),
|
|
})
|
|
if err != nil || got != stored {
|
|
t.Fatalf("Load(%q) = %q, %v; want content_html %q", source, got, err, stored)
|
|
}
|
|
if strings.Contains(got.(string), "**") || strings.Contains(got.(string), "## ") {
|
|
t.Fatalf("Load(%q) served markdown markers: %q", source, got)
|
|
}
|
|
if len(asked) != 1 || asked[0] != "content_html" {
|
|
t.Fatalf("asked %v, want [content_html]", asked)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("an HTML value with a failing content_html read is the Load error", func(t *testing.T) {
|
|
boom := errors.New("read failed")
|
|
_, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
|
Options: blogOptions, Value: "<p>x</p>", Translated: translatedReader(new([]string), "", false, boom),
|
|
})
|
|
if !errors.Is(err, boom) {
|
|
t.Fatalf("err = %v, want %v", err, boom)
|
|
}
|
|
})
|
|
|
|
t.Run("markdown content is served as that locale's content_html", func(t *testing.T) {
|
|
var asked []string
|
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
|
Options: blogOptions, Locale: "en", Value: markdown,
|
|
Translated: translatedReader(&asked, "<h2>Imported</h2>\n", true, nil),
|
|
})
|
|
if err != nil || got != "<h2>Imported</h2>\n" {
|
|
t.Fatalf("Load = %q, %v", got, err)
|
|
}
|
|
if len(asked) != 1 || asked[0] != "content_html" {
|
|
t.Fatalf("asked %v, want [content_html]", asked)
|
|
}
|
|
})
|
|
|
|
t.Run("an empty or missing content_html falls back to FormatHTML", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
translated func(string) (string, bool, error)
|
|
}{
|
|
{"empty", translatedReader(new([]string), "", true, nil)},
|
|
{"missing", translatedReader(new([]string), "", false, nil)},
|
|
{"no writer", nil},
|
|
} {
|
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
|
Options: blogOptions, Locale: "pl", Default: true, Value: markdown, Translated: tc.translated,
|
|
})
|
|
if err != nil || got != rendered {
|
|
t.Fatalf("%s: Load = %q, %v; want %q", tc.name, got, err, rendered)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("a Translated error is the Load error", func(t *testing.T) {
|
|
boom := errors.New("read failed")
|
|
_, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
|
Options: blogOptions, Value: markdown, Translated: translatedReader(new([]string), "", false, boom),
|
|
})
|
|
if !errors.Is(err, boom) {
|
|
t.Fatalf("err = %v, want %v", err, boom)
|
|
}
|
|
})
|
|
|
|
t.Run("nil and empty values give the empty string", func(t *testing.T) {
|
|
for _, value := range []any{nil, (*string)(nil), &empty, "", " \n"} {
|
|
for _, translated := range []func(string) (string, bool, error){
|
|
nil,
|
|
translatedReader(new([]string), "", false, nil),
|
|
translatedReader(new([]string), "", true, nil),
|
|
} {
|
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Options: blogOptions, Value: value, Translated: translated})
|
|
want := storedText(value)
|
|
if err != nil || got != want {
|
|
t.Fatalf("Load(%#v) = %q, %v; want %q", value, got, err, want)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
// CR-01: a locale whose content is blank but whose content_html holds a
|
|
// body (an API-written translation, FormatTranslation) opens with that
|
|
// body, not an empty editor whose first edit would replace it.
|
|
t.Run("blank content is served as that locale's content_html", func(t *testing.T) {
|
|
const translatedBody = "<h2>Tłumaczenie</h2>\n<p>Treść z API.</p>\n"
|
|
for _, tc := range []struct {
|
|
name string
|
|
value any
|
|
locale string
|
|
isDefault bool
|
|
}{
|
|
{"translation with empty content", "", "en", false},
|
|
{"translation with blank content", " \n\t", "en", false},
|
|
{"host row with nil content", nil, "pl", true},
|
|
{"host row with a nil *string", (*string)(nil), "pl", true},
|
|
} {
|
|
var asked []string
|
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
|
Options: blogOptions, Locale: tc.locale, Default: tc.isDefault, Value: tc.value,
|
|
Translated: translatedReader(&asked, translatedBody, true, nil),
|
|
})
|
|
if err != nil || got != translatedBody {
|
|
t.Fatalf("%s: Load = %q, %v; want the stored content_html", tc.name, got, err)
|
|
}
|
|
if len(asked) != 1 || asked[0] != "content_html" {
|
|
t.Fatalf("%s: asked %v, want [content_html]", tc.name, asked)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("blank content with a failing content_html read is the Load error", func(t *testing.T) {
|
|
boom := errors.New("read failed")
|
|
_, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
|
Options: blogOptions, Locale: "en", Value: "", Translated: translatedReader(new([]string), "", false, boom),
|
|
})
|
|
if !errors.Is(err, boom) {
|
|
t.Fatalf("err = %v, want %v", err, boom)
|
|
}
|
|
})
|
|
|
|
t.Run("blank content without htmlColumn never reads another attribute", func(t *testing.T) {
|
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
|
Options: map[string]any{"uploadFolder": "categories"}, Value: "", Translated: noTranslated(t),
|
|
})
|
|
if err != nil || got != "" {
|
|
t.Fatalf("Load = %q, %v; want the empty string", got, err)
|
|
}
|
|
})
|
|
|
|
t.Run("a *string is dereferenced", func(t *testing.T) {
|
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Value: &html})
|
|
if err != nil || got != html {
|
|
t.Fatalf("Load = %q, %v", got, err)
|
|
}
|
|
})
|
|
|
|
t.Run("without htmlColumn every value is served as stored", func(t *testing.T) {
|
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Options: map[string]any{"uploadFolder": "categories"}, Value: markdown, Translated: noTranslated(t)})
|
|
if err != nil || got != markdown {
|
|
t.Fatalf("Load = %q, %v; want the stored markdown", got, err)
|
|
}
|
|
})
|
|
|
|
t.Run("the registered types use this Load", func(t *testing.T) {
|
|
for _, typ := range BlogEditorTypes(nil) {
|
|
if typ.Load == nil {
|
|
t.Fatalf("%s has no Load", typ.Name)
|
|
}
|
|
got, err := typ.Load(context.Background(), pact.FormFieldValue{Options: blogOptions, Value: "\n<!-- c --><p>x</p>"})
|
|
if err != nil || got != "\n<!-- c --><p>x</p>" {
|
|
t.Fatalf("%s: Load = %q, %v", typ.Name, got, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// decodeBoth runs both Blog Editor types' Decode on value.
|
|
func decodeBoth(t *testing.T, value any) []pact.FormFieldDecoded {
|
|
t.Helper()
|
|
var out []pact.FormFieldDecoded
|
|
for _, typ := range BlogEditorTypes(nil) {
|
|
got, err := typ.Decode(context.Background(), pact.FormFieldValue{Field: "content", Locale: "pl", Default: true, Value: value})
|
|
if err != nil {
|
|
t.Fatalf("%s: Decode(%#v) error %v", typ.Name, value, err)
|
|
}
|
|
out = append(out, got)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestBlogEditorDecode(t *testing.T) {
|
|
t.Run("clean values are stored byte for byte with no notice", func(t *testing.T) {
|
|
clean := []string{
|
|
"",
|
|
"plain text, no markup",
|
|
"## Markdown heading\n\nText with *emphasis*.\n",
|
|
"<h2>Grzyby</h2>\n<p>Tekst z <a href=\"https://example.test/a\">linkiem</a> & <strong>pogrubieniem</strong>.</p>\n",
|
|
"\n <p>leading and trailing white space</p>\n\n",
|
|
editorPlaceholder,
|
|
}
|
|
for _, e := range editorCorpus(t) {
|
|
clean = append(clean, e.HTML)
|
|
}
|
|
for _, in := range clean {
|
|
for _, got := range decodeBoth(t, in) {
|
|
if got.Value != in || len(got.Notices) != 0 {
|
|
t.Fatalf("Decode(%q) = %#v, want it unchanged without notices", in, got)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("nil decodes to the empty string", func(t *testing.T) {
|
|
for _, got := range decodeBoth(t, nil) {
|
|
if got.Value != "" || got.Notices != nil {
|
|
t.Fatalf("Decode(nil) = %#v", got)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("a value that is not a string is a FormFieldError", func(t *testing.T) {
|
|
for _, value := range []any{42.0, true, map[string]any{"html": "<p>x</p>"}, []any{"<p>x</p>"}} {
|
|
for _, typ := range BlogEditorTypes(nil) {
|
|
_, err := typ.Decode(context.Background(), pact.FormFieldValue{Value: value})
|
|
var refused *pact.FormFieldError
|
|
if !errors.As(err, &refused) || len(refused.Messages) != 1 || refused.Messages[0] != editorContentInvalid {
|
|
t.Fatalf("%s: Decode(%#v) error = %v", typ.Name, value, err)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("every unsafe payload is stripped with a notice, never refused", func(t *testing.T) {
|
|
stripped := 0
|
|
for _, tc := range unsafePayloads {
|
|
for _, got := range decodeBoth(t, tc.src) {
|
|
text, _ := got.Value.(string)
|
|
if text != SanitizeHTML(tc.src) {
|
|
t.Fatalf("%s: Decode = %q, want SanitizeHTML's %q", tc.name, text, SanitizeHTML(tc.src))
|
|
}
|
|
// Markdown-only payloads ("[x](javascript:...)") are inert text
|
|
// to the HTML policy: kept, and then without a notice.
|
|
if (text == tc.src) != (len(got.Notices) == 0) {
|
|
t.Fatalf("%s: Decode = %q with %d notices, want notices exactly when stripped", tc.name, text, len(got.Notices))
|
|
}
|
|
if text != tc.src {
|
|
stripped++
|
|
}
|
|
assertNoActiveContent(t, text)
|
|
}
|
|
}
|
|
// Every payload with HTML markup is stripped; only the markdown
|
|
// syntax ones (attribute block, javascript, vbscript and data links,
|
|
// data image) are plain text here.
|
|
if want := 2 * (len(unsafePayloads) - 5); stripped != want {
|
|
t.Fatalf("stripped %d decodes, want %d", stripped, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
// langPluralForms returns the plural form names of editor.<key> in
|
|
// lang/<locale>/lang.yaml, or nil when the key is a single string.
|
|
func langPluralForms(t *testing.T, locale, key string) (forms []string, found bool) {
|
|
t.Helper()
|
|
raw, err := os.ReadFile("../lang/" + locale + "/lang.yaml")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inEditor, inKey := false, false
|
|
sc := bufio.NewScanner(bytes.NewReader(raw))
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
trimmed := strings.TrimSpace(line)
|
|
indent := len(line) - len(strings.TrimLeft(line, " "))
|
|
switch {
|
|
case trimmed == "" || strings.HasPrefix(trimmed, "#"):
|
|
case indent == 0:
|
|
inEditor, inKey = trimmed == "editor:", false
|
|
case inEditor && indent == 2:
|
|
name, rest, _ := strings.Cut(trimmed, ":")
|
|
inKey = name == key
|
|
if inKey {
|
|
found = true
|
|
if strings.TrimSpace(rest) != "" {
|
|
return nil, true
|
|
}
|
|
}
|
|
case inEditor && inKey && indent == 4:
|
|
name, _, _ := strings.Cut(trimmed, ":")
|
|
forms = append(forms, name)
|
|
}
|
|
}
|
|
return forms, found
|
|
}
|
|
|
|
func TestBlogEditorNotices(t *testing.T) {
|
|
t.Run("notices come in the order elements, attributes, comments, other", func(t *testing.T) {
|
|
_, report := SanitizeHTMLReport(`<p onclick="x" onload="y">a</p><script>s</script><style>p{}</style><!-- c -->`)
|
|
if report.Other {
|
|
t.Fatalf("report = %+v, want no unexplained change", report)
|
|
}
|
|
report.Other = true
|
|
got := removalNotices(report)
|
|
if len(got) != 4 {
|
|
t.Fatalf("notices = %#v, report %+v", got, report)
|
|
}
|
|
want := []struct {
|
|
message string
|
|
count int
|
|
names string
|
|
}{
|
|
{editorRemovedElements, 2, "script, style"},
|
|
{editorRemovedAttributes, 2, "onclick, onload"},
|
|
{editorRemovedComments, 1, ""},
|
|
{editorMarkupChanged, 0, ""},
|
|
}
|
|
for i, w := range want {
|
|
if got[i].Message != w.message || got[i].Count != w.count || got[i].Params["names"] != w.names {
|
|
t.Fatalf("notice %d = %#v, want %+v", i, got[i], w)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("an empty report has no notice", func(t *testing.T) {
|
|
if got := removalNotices(HTMLRemovals{}); got != nil {
|
|
t.Fatalf("notices = %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("names are cut after ten with an ellipsis; the count stays exact", func(t *testing.T) {
|
|
var b strings.Builder
|
|
for _, tag := range []string{"script", "iframe", "object", "embed", "form", "input", "button", "select", "textarea", "base", "meta", "link"} {
|
|
b.WriteString("<" + tag + "></" + tag + ">")
|
|
}
|
|
_, report := SanitizeHTMLReport(b.String())
|
|
got := removalNotices(report)
|
|
if len(got) != 1 || got[0].Count != 12 {
|
|
t.Fatalf("notices = %#v", got)
|
|
}
|
|
names := strings.Split(got[0].Params["names"], ", ")
|
|
if len(names) != 11 || names[10] != "…" || names[0] != "script" || names[9] != "base" {
|
|
t.Fatalf("names = %q", got[0].Params["names"])
|
|
}
|
|
if removalNames([]string{"a", "b"}, false) != "a, b" || removalNames([]string{"a"}, true) != "a, …" {
|
|
t.Fatal("removalNames")
|
|
}
|
|
})
|
|
|
|
t.Run("every notice key is in lang/en and lang/pl with its plural forms", func(t *testing.T) {
|
|
for _, key := range []string{"removed_elements", "removed_attributes", "removed_comments"} {
|
|
en, ok := langPluralForms(t, "en", key)
|
|
if !ok || strings.Join(en, ",") != "one,other" {
|
|
t.Fatalf("en %s forms = %v (found %v), want one, other", key, en, ok)
|
|
}
|
|
pl, ok := langPluralForms(t, "pl", key)
|
|
if !ok || strings.Join(pl, ",") != "one,few,many,other" {
|
|
t.Fatalf("pl %s forms = %v (found %v), want one, few, many, other", key, pl, ok)
|
|
}
|
|
}
|
|
for _, key := range []string{"content_invalid", "image_invalid", "upload_failed", "markup_changed"} {
|
|
for _, locale := range []string{"en", "pl"} {
|
|
if forms, ok := langPluralForms(t, locale, key); !ok || forms != nil {
|
|
t.Fatalf("%s %s: forms %v found %v, want a single phrase", locale, key, forms, ok)
|
|
}
|
|
}
|
|
}
|
|
for key, constant := range map[string]string{
|
|
"content_invalid": editorContentInvalid, "image_invalid": editorImageInvalid,
|
|
"removed_elements": editorRemovedElements, "removed_attributes": editorRemovedAttributes,
|
|
"removed_comments": editorRemovedComments, "markup_changed": editorMarkupChanged,
|
|
} {
|
|
if constant != "golem15.journal::lang.editor."+key {
|
|
t.Fatalf("%s constant = %q", key, constant)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestLooksLikeHTML(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
in string
|
|
want bool
|
|
}{
|
|
{"<p>a</p>", true},
|
|
{" \n\t<p>a</p>", true},
|
|
{"<!-- imported --><p>a</p>", true},
|
|
{"<!DOCTYPE html>", true},
|
|
{"\u00a0<p>a</p>", true},
|
|
{"", false},
|
|
{" ", false},
|
|
{"# Heading", false},
|
|
{"Text with <b>inline</b> markup", false},
|
|
{"> quote", false},
|
|
{"<p>", false},
|
|
} {
|
|
if got := LooksLikeHTML(tc.in); got != tc.want {
|
|
t.Fatalf("LooksLikeHTML(%q) = %v, want %v", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBlogEditorOptions(t *testing.T) {
|
|
types := BlogEditorTypes(nil)
|
|
if len(types) != 2 || types[0].Name != "blogeditor" || types[1].Name != "mlblogeditor" {
|
|
t.Fatalf("types = %+v", types)
|
|
}
|
|
for _, typ := range types {
|
|
if typ.Element != BlogEditorElement || typ.CheckOptions == nil || typ.Decode == nil || typ.Load == nil || typ.ListValue == nil || typ.Upload == nil {
|
|
t.Fatalf("%s = %+v", typ.Name, typ)
|
|
}
|
|
if strings.Join(typ.Scripts, ",") != "assets/dist/blogeditor.js" || strings.Join(typ.Styles, ",") != "assets/dist/blogeditor.css" {
|
|
t.Fatalf("%s assets = %v %v", typ.Name, typ.Scripts, typ.Styles)
|
|
}
|
|
}
|
|
if types[0].Translatable || strings.Join(types[0].Options, ",") != "uploadFolder" {
|
|
t.Fatalf("blogeditor = %+v", types[0])
|
|
}
|
|
if !types[1].Translatable || strings.Join(types[1].Options, ",") != "htmlColumn,uploadFolder" {
|
|
t.Fatalf("mlblogeditor = %+v", types[1])
|
|
}
|
|
|
|
for _, ok := range []map[string]any{
|
|
{},
|
|
{"htmlColumn": "content_html"},
|
|
{"htmlColumn": "_x9"},
|
|
{"uploadFolder": "posts"},
|
|
{"uploadFolder": "posts/2026_10-a"},
|
|
{"uploadFolder": ""},
|
|
{"uploadFolder": strings.Repeat("a", 100)},
|
|
} {
|
|
if err := checkBlogOptions(ok); err != nil {
|
|
t.Fatalf("checkBlogOptions(%v) = %v", ok, err)
|
|
}
|
|
}
|
|
for _, bad := range []map[string]any{
|
|
{"htmlColumn": ""},
|
|
{"htmlColumn": "content html"},
|
|
{"htmlColumn": "1column"},
|
|
{"htmlColumn": "content-html"},
|
|
{"htmlColumn": 3},
|
|
{"htmlColumn": nil},
|
|
{"uploadFolder": "../up"},
|
|
{"uploadFolder": "a..b"},
|
|
{"uploadFolder": "a b"},
|
|
{"uploadFolder": "a\\b"},
|
|
{"uploadFolder": strings.Repeat("a", 101)},
|
|
{"uploadFolder": 5},
|
|
} {
|
|
if err := checkBlogOptions(bad); err == nil {
|
|
t.Fatalf("checkBlogOptions(%v) = nil, want an error", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBlogEditorListValue(t *testing.T) {
|
|
html := "<p>Grzyby</p>"
|
|
for _, tc := range []struct {
|
|
in any
|
|
want string
|
|
}{
|
|
{"<h2>Title</h2><p>Some <strong>bold</strong>\n text</p>", "Title Some bold text"},
|
|
{"<p>Grz<strong>yby</strong> <em>leśne</em></p>", "Grzyby leśne"},
|
|
{"<p>a</p><p>b</p><ul><li>c</li><li>d</li></ul>", "a b c d"},
|
|
{"<p>x</p><script>alert(1)</script><style>p{}</style><p>y</p>", "x y"},
|
|
{"<p>Fish & chips <3</p>", "Fish & chips <3"},
|
|
{"<!-- hidden --><p>shown</p>", "shown"},
|
|
{editorPlaceholder + "<p>after</p>", "after"},
|
|
{"## Markdown *stays* text", "## Markdown *stays* text"},
|
|
{" \n ", ""},
|
|
{nil, ""},
|
|
{(*string)(nil), ""},
|
|
{&html, "Grzyby"},
|
|
{42, "42"},
|
|
} {
|
|
if got := blogListValue(context.Background(), tc.in); got != tc.want {
|
|
t.Fatalf("ListValue(%#v) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
|
|
exact := strings.Repeat("ż", 120)
|
|
if got := blogListValue(context.Background(), "<p>"+exact+"</p>"); got != exact {
|
|
t.Fatalf("120 runes were cut: %q", got)
|
|
}
|
|
long := blogListValue(context.Background(), "<p>"+strings.Repeat("ż", 121)+"</p>")
|
|
if utf8.RuneCountInString(long) != 121 || !strings.HasSuffix(long, "…") {
|
|
t.Fatalf("121 runes = %q", long)
|
|
}
|
|
// White space before the cut is trimmed before the ellipsis.
|
|
spaced := blogListValue(context.Background(), "<p>"+strings.Repeat("a", 119)+" bcd</p>")
|
|
if spaced != strings.Repeat("a", 119)+"…" {
|
|
t.Fatalf("cut at a space = %q", spaced)
|
|
}
|
|
for _, typ := range BlogEditorTypes(nil) {
|
|
if got := typ.ListValue(context.Background(), "<p>a</p>"); got != "a" {
|
|
t.Fatalf("%s ListValue = %q", typ.Name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// File heads http.DetectContentType recognizes.
|
|
var (
|
|
testPNG = append([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"), make([]byte, 64)...)
|
|
testJPEG = append([]byte("\xff\xd8\xff\xe0\x00\x10JFIF\x00"), make([]byte, 64)...)
|
|
testGIF = append([]byte("GIF89a"), make([]byte, 64)...)
|
|
testWebP = append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 64)...)
|
|
testHTML = []byte("<!DOCTYPE html><html><body><script>alert(1)</script></body></html>")
|
|
testSVG = []byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>`)
|
|
testText = []byte("just some text that is not an image at all")
|
|
)
|
|
|
|
func TestBlogEditorUpload(t *testing.T) {
|
|
ctx := context.Background()
|
|
bucket := memblob.OpenBucket(nil)
|
|
t.Cleanup(func() { _ = bucket.Close() })
|
|
app := backpack.New(nil)
|
|
if err := attach.Publish(app, bucket); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
types := BlogEditorTypes(func() *backpack.App { return app })
|
|
upload := func(typ pact.FormFieldType, options map[string]any, name string, body []byte) (pact.FormFieldUploadResult, error) {
|
|
return typ.Upload(ctx, pact.FormFieldUpload{Field: "content", Options: options, Filename: name, Body: bytes.NewReader(body)})
|
|
}
|
|
refused := func(t *testing.T, err error, message string) {
|
|
t.Helper()
|
|
var fe *pact.FormFieldError
|
|
if !errors.As(err, &fe) || len(fe.Messages) != 1 || fe.Messages[0] != message {
|
|
t.Fatalf("error = %v, want FormFieldError %q", err, message)
|
|
}
|
|
}
|
|
stored := func(t *testing.T, key string, want []byte) {
|
|
t.Helper()
|
|
data, err := bucket.ReadAll(ctx, key)
|
|
if err != nil || !bytes.Equal(data, want) {
|
|
t.Fatalf("read %s: %v (bytes equal %v)", key, err, bytes.Equal(data, want))
|
|
}
|
|
}
|
|
|
|
t.Run("each image type is stored when its bytes match its name", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
body []byte
|
|
key string
|
|
}{
|
|
{"a.png", testPNG, "journal/posts/a.png"},
|
|
{"b.jpg", testJPEG, "journal/posts/b.jpg"},
|
|
{"c.JPEG", testJPEG, "journal/posts/c.jpeg"},
|
|
{"d.gif", testGIF, "journal/posts/d.gif"},
|
|
{"e.webp", testWebP, "journal/posts/e.webp"},
|
|
{"noext", testPNG, "journal/posts/noext.png"},
|
|
{"photo", testJPEG, "journal/posts/photo.jpg"},
|
|
} {
|
|
got, err := upload(types[1], map[string]any{"uploadFolder": "posts"}, tc.name, tc.body)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", tc.name, err)
|
|
}
|
|
if got.URL != attach.PublicURL(tc.key) {
|
|
t.Fatalf("%s: URL = %q, want %q", tc.name, got.URL, attach.PublicURL(tc.key))
|
|
}
|
|
stored(t, tc.key, tc.body)
|
|
}
|
|
})
|
|
|
|
t.Run("a file that does not sniff as its image type is refused", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
body []byte
|
|
}{
|
|
{"page.png", testHTML},
|
|
{"logo.svg", testSVG},
|
|
{"vector.png", testSVG},
|
|
{"notes.txt", testText},
|
|
{"noext", testText},
|
|
{"photo.jpg", testPNG},
|
|
{"photo.png", testGIF},
|
|
{"empty.png", nil},
|
|
} {
|
|
_, err := upload(types[0], nil, tc.name, tc.body)
|
|
refused(t, err, editorImageInvalid)
|
|
}
|
|
if _, err := types[0].Upload(ctx, pact.FormFieldUpload{Filename: "a.png"}); err == nil {
|
|
t.Fatal("an upload without a body was accepted")
|
|
} else {
|
|
refused(t, err, editorImageInvalid)
|
|
}
|
|
})
|
|
|
|
t.Run("an image under a name StoreMedia refuses gets its message", func(t *testing.T) {
|
|
_, err := upload(types[0], nil, "photo.bmp", testPNG)
|
|
refused(t, err, "The file must be an image.")
|
|
big := append(append([]byte{}, testPNG...), make([]byte, MediaMaxBytes)...)
|
|
_, err = upload(types[0], nil, "big.png", big)
|
|
refused(t, err, "The file may not be greater than 10240 kilobytes.")
|
|
_, err = upload(types[0], map[string]any{"uploadFolder": "../x"}, "a.png", testPNG)
|
|
refused(t, err, "The folder format is invalid.")
|
|
})
|
|
|
|
t.Run("the folder is the field's uploadFolder, editor by default", func(t *testing.T) {
|
|
got, err := upload(types[0], map[string]any{"uploadFolder": "categories"}, "cat.png", testPNG)
|
|
if err != nil || got.URL != attach.PublicURL("journal/categories/cat.png") {
|
|
t.Fatalf("URL = %q, %v", got.URL, err)
|
|
}
|
|
got, err = upload(types[0], nil, "default.png", testPNG)
|
|
if err != nil || got.URL != attach.PublicURL("journal/editor/default.png") {
|
|
t.Fatalf("URL = %q, %v", got.URL, err)
|
|
}
|
|
})
|
|
|
|
t.Run("names are made safe", func(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
"Zdjęcie 1.PNG": "zdj-cie-1.png",
|
|
`C:\\Users\\x\\My Photo.jpg`: "my-photo.jpg",
|
|
"../../etc/passwd.png": "passwd.png",
|
|
"...": "image",
|
|
"": "image",
|
|
"-.-a-.png": "a.png",
|
|
"a--b__c.webp": "a-b__c.webp",
|
|
"<script>.gif": "script.gif",
|
|
`x" onerror="alert(1).png`: "x-onerror-alert-1.png",
|
|
"name.with.dots.jpeg": "name.with.dots.jpeg",
|
|
"weird.ext$": "weird.ext",
|
|
} {
|
|
if got := safeUploadName(in); got != want {
|
|
t.Fatalf("safeUploadName(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("a missing application or bucket is an opaque error", func(t *testing.T) {
|
|
for name, typ := range map[string]pact.FormFieldType{
|
|
"nil app": BlogEditorTypes(nil)[0],
|
|
"nil value": BlogEditorTypes(func() *backpack.App { return nil })[0],
|
|
"no bucket": BlogEditorTypes(func() *backpack.App { return backpack.New(nil) })[0],
|
|
} {
|
|
_, err := upload(typ, nil, "a.png", testPNG)
|
|
if err == nil || errors.As(err, new(*pact.FormFieldError)) {
|
|
t.Fatalf("%s: error = %v, want an opaque error", name, err)
|
|
}
|
|
}
|
|
})
|
|
}
|