Files
sm-journal-plugin/classes/blog_editor_test.go
Jakub Zych 159323197d fix(16.2): CR-01 serve content_html when a locale's content is blank
A translation that carries only content_html (an API write through
FormatTranslation) opened as an empty editor, and the first edit in
that locale silently replaced the translated body. Load now serves the
htmlColumn attribute whenever the stored content is blank and that
attribute is not empty; an untouched save echoes it and is skipped, an
edit writes the edited HTML to both columns.
2026-10-10 16:33:08 +02:00

667 lines
24 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>",
} {
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
Options: blogOptions, Locale: "pl", Default: true, Value: stored, Translated: noTranslated(t),
})
if err != nil || got != stored {
t.Fatalf("Load(%q) = %q, %v; want the stored bytes", stored, got, err)
}
}
})
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)
}
}
})
}