Files
sm-journal-plugin/classes/blog_editor_test.go
Jakub Zych 5af746f31f fix(16.2): WR-04 serve content_html for markdown that starts with a tag
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.
2026-10-10 16:34:16 +02:00

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> &amp; <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},
{"&lt;p&gt;", 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 &amp; chips &lt;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)
}
}
})
}