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 := "

Grzyby

" empty := "" t.Run("HTML content is served verbatim", func(t *testing.T) { for _, stored := range []string{ "

Grzyby rosną.

\n\n", "\n \t

leading white space

", "

leading comment

", "\n\n

both

\n", "

unsafe markup is served too; Decode strips it on save

", } { // 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{ "\"Hero\"\n\n## Nagłówek\n\nTekst z **pogrubieniem**.", "
Wstęp
\n\n## Nagłówek\n\n*kursywa*", "\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: "

x

", 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, "

Imported

\n", true, nil), }) if err != nil || got != "

Imported

\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 = "

Tłumaczenie

\n

Treść z API.

\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

x

"}) if err != nil || got != "\n

x

" { 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", "

Grzyby

\n

Tekst z linkiem & pogrubieniem.

\n", "\n

leading and trailing white space

\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": "

x

"}, []any{"

x

"}} { 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. in // lang//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(`

a

`) 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 + ">") } _, 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 }{ {"

a

", true}, {" \n\t

a

", true}, {"

a

", true}, {"", true}, {"\u00a0

a

", true}, {"", false}, {" ", false}, {"# Heading", false}, {"Text with inline 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 := "

Grzyby

" for _, tc := range []struct { in any want string }{ {"

Title

Some bold\n text

", "Title Some bold text"}, {"

Grzyby leśne

", "Grzyby leśne"}, {"

a

b

", "a b c d"}, {"

x

y

", "x y"}, {"

Fish & chips <3

", "Fish & chips <3"}, {"

shown

", "shown"}, {editorPlaceholder + "

after

", "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(), "

"+exact+"

"); got != exact { t.Fatalf("120 runes were cut: %q", got) } long := blogListValue(context.Background(), "

"+strings.Repeat("ż", 121)+"

") 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(), "

"+strings.Repeat("a", 119)+" bcd

") 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(), "

a

"); 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("") testSVG = []byte(``) 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", "