diff --git a/categories_admin_editor_test.go b/categories_admin_editor_test.go new file mode 100644 index 0000000..b23fbc2 --- /dev/null +++ b/categories_admin_editor_test.go @@ -0,0 +1,263 @@ +package journal_test + +import ( + "bytes" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.golem15.com/golem15/sm-journal-plugin/classes" + "git.golem15.com/golem15/sm-journal-plugin/controllers" + "git.golem15.com/golem15/sm-journal-plugin/models" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "gocloud.dev/blob" +) + +// editorPNG is a file head http.DetectContentType recognizes as PNG. +var editorPNG = append([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"), make([]byte, 64)...) + +// uploadEditorFile posts one file to a Blog Editor field's upload route. +func uploadEditorFile(t *testing.T, env *adminEnv, token, path, filename string, content []byte) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile("file", filename) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(content); err != nil { + t.Fatal(err) + } + if err := mw.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, path, &buf) + req.Host = "example.com" + req.RemoteAddr = "198.51.100.9:4000" + req.Header.Set("Accept-Language", "en") + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + env.h.ServeHTTP(rec, req) + return rec +} + +// TestCategoriesAdminBlogEditor covers category content on the blogeditor +// field type through the real admin API (Phase 16.2 D-03, D-10 amended, +// D-18, D-19): the field-types route lists the editor, an untouched save +// keeps stored content byte for byte (imported markup and NULL included) +// and reports nothing, a clean edit is stored as sent, an unsafe edit is +// stripped and reported (never a 422), blank content becomes NULL, and the +// editor's image upload stores under journal/categories. +func TestCategoriesAdminBlogEditor(t *testing.T) { + env := newPostsEnv(t) + token := env.admin(t, controllers.PermissionAccessCategories) + + t.Run("field-types lists the Blog Editor with versioned assets", func(t *testing.T) { + rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/field-types"), token, nil) + var types []struct { + Name string `json:"name"` + Element string `json:"element"` + Translatable bool `json:"translatable"` + Scripts []string `json:"scripts"` + Styles []string `json:"styles"` + } + if err := json.Unmarshal(adminDecode(t, rec).Data, &types); err != nil { + t.Fatalf("field types %s: %v", rec.Body.String(), err) + } + found := map[string]bool{} + for _, ft := range types { + if ft.Name != "blogeditor" && ft.Name != "mlblogeditor" { + continue + } + found[ft.Name] = true + if ft.Element != classes.BlogEditorElement || ft.Translatable != (ft.Name == "mlblogeditor") { + t.Fatalf("%s = %+v", ft.Name, ft) + } + if len(ft.Scripts) != 1 || !strings.Contains(ft.Scripts[0], "/assets/golem15/journal/dist/blogeditor.js?v=") || + len(ft.Styles) != 1 || !strings.Contains(ft.Styles[0], "/assets/golem15/journal/dist/blogeditor.css?v=") { + t.Fatalf("%s assets = %v %v", ft.Name, ft.Scripts, ft.Styles) + } + } + if !found["blogeditor"] || !found["mlblogeditor"] { + t.Fatalf("field types = %s", rec.Body.String()) + } + }) + + safe := "
Pillar copy
\n" + rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{ + "name": map[string]any{"en": "Editor news", "pl": "Wiadomości edytora"}, + "slug": map[string]any{"en": "editor-news", "pl": "wiadomosci-edytora"}, + "content": safe, + }) + if notices := adminNotices(t, rec); notices != nil { + t.Fatalf("safe create carries meta.notices: %s", rec.Body.String()) + } + id := adminCreatedID(t, rec) + itemPath := adminAPI(fmt.Sprintf("%s/%d", adminCategoriesPath, id)) + stored := func(t *testing.T) *string { + t.Helper() + var cat models.Category + if err := env.gdb.First(&cat, id).Error; err != nil { + t.Fatal(err) + } + return cat.Content + } + if got := stored(t); got == nil || *got != safe { + t.Fatalf("created content = %#v, want %q", got, safe) + } + served := func(t *testing.T) (map[string]json.RawMessage, string) { + t.Helper() + rec := env.expect(t, http.StatusOK, http.MethodGet, itemPath, token, nil) + var data map[string]json.RawMessage + if err := json.Unmarshal(adminDecode(t, rec).Data, &data); err != nil { + t.Fatalf("show %s: %v", rec.Body.String(), err) + } + var content *string + if raw, ok := data["content"]; ok { + if err := json.Unmarshal(raw, &content); err != nil { + t.Fatalf("served content %s: %v", raw, err) + } + } + if content == nil { + return data, "" + } + return data, *content + } + resave := func(t *testing.T) { + t.Helper() + data, _ := served(t) + rec := env.expect(t, http.StatusOK, http.MethodPut, itemPath, token, servedBody(data, "name", "slug", "content")) + if _, ok := adminMeta(t, rec)["notices"]; ok { + t.Fatalf("untouched save carries meta.notices: %s", rec.Body.String()) + } + } + + t.Run("an untouched save keeps imported markup byte for byte", func(t *testing.T) { + // Imported content the policy would strip, written by SQL. + const imported = "\n\n\nKept as stored
\n" + if err := env.gdb.Model(&models.Category{}).Where("id = ?", id).Update("content", imported).Error; err != nil { + t.Fatal(err) + } + if _, content := served(t); content != imported { + t.Fatalf("served content = %q, want the stored bytes", content) + } + resave(t) + if got := stored(t); got == nil || *got != imported { + t.Fatalf("content after an untouched save = %#v, want %q", got, imported) + } + }) + + t.Run("an untouched save keeps NULL content NULL", func(t *testing.T) { + if err := env.gdb.Model(&models.Category{}).Where("id = ?", id).Update("content", nil).Error; err != nil { + t.Fatal(err) + } + if _, content := served(t); content != "" { + t.Fatalf("served NULL content = %q, want empty", content) + } + resave(t) + if got := stored(t); got != nil { + t.Fatalf("content after an untouched save = %q, want NULL", *got) + } + }) + + put := func(t *testing.T, content string) *httptest.ResponseRecorder { + t.Helper() + data, _ := served(t) + body := servedBody(data, "name", "slug") + body["content"] = content + return env.call(t, http.MethodPut, itemPath, token, body) + } + + t.Run("a clean edit is stored as sent without notices", func(t *testing.T) { + edited := "Edited copy
\na
b
" + rec := put(t, unsafe) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + want := classes.SanitizeHTML(unsafe) + if want == unsafe { + t.Fatal("the unsafe input passes the policy; pick another") + } + if got := stored(t); got == nil || *got != want { + t.Fatalf("stored = %#v, want %q", got, want) + } + notices := adminNotices(t, rec) + var messages []string + for _, n := range notices { + if n.Field != "content" || n.Locale != "" { + t.Fatalf("notice %+v, want field content without a locale", n) + } + messages = append(messages, n.Message) + } + wantMessages := []string{"1 element was removed: style", "1 attribute was removed: onclick", "1 HTML comment was removed"} + if strings.Join(messages, "|") != strings.Join(wantMessages, "|") { + t.Fatalf("notices = %q, want %q", messages, wantMessages) + } + }) + + t.Run("blank content becomes NULL", func(t *testing.T) { + rec := put(t, " \n\t") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + if got := stored(t); got != nil { + t.Fatalf("stored = %q, want NULL", *got) + } + }) + + t.Run("the editor's image upload stores under journal/categories", func(t *testing.T) { + path := adminAPI(fmt.Sprintf("%s/%d/fields/content/upload", adminCategoriesPath, id)) + rec := uploadEditorFile(t, env, token, path, "Okładka.PNG", editorPNG) + if rec.Code != http.StatusCreated { + t.Fatalf("upload = %d: %s", rec.Code, rec.Body.String()) + } + var result struct { + URL string `json:"url"` + } + if err := json.Unmarshal(adminDecode(t, rec).Data, &result); err != nil { + t.Fatal(err) + } + if result.URL != attach.PublicURL("journal/categories/ok-adka.png") { + t.Fatalf("url = %q", result.URL) + } + bucket, ok := env.app.Lookup[*blob.Bucket]() + if !ok { + t.Fatal("no media bucket") + } + if data, err := bucket.ReadAll(t.Context(), "journal/categories/ok-adka.png"); err != nil || !bytes.Equal(data, editorPNG) { + t.Fatalf("stored upload: %v", err) + } + + disguised := uploadEditorFile(t, env, token, path, "page.png", []byte("")) + if disguised.Code != http.StatusUnprocessableEntity || !strings.Contains(disguised.Body.String(), "The image must be a JPEG, PNG, GIF or WebP file.") { + t.Fatalf("disguised upload = %d: %s", disguised.Code, disguised.Body.String()) + } + create := uploadEditorFile(t, env, token, adminAPI(adminCategoriesPath+"/0/fields/content/upload"), "new.png", editorPNG) + if create.Code != http.StatusCreated { + t.Fatalf("create-form upload = %d: %s", create.Code, create.Body.String()) + } + other := env.admin(t, controllers.PermissionAccessPosts) + if denied := uploadEditorFile(t, env, other, path, "a.png", editorPNG); denied.Code != http.StatusForbidden { + t.Fatalf("upload without access_categories = %d: %s", denied.Code, denied.Body.String()) + } + }) +} diff --git a/classes/blog_editor_test.go b/classes/blog_editor_test.go index cc2a8ac..24dc2d7 100644 --- a/classes/blog_editor_test.go +++ b/classes/blog_editor_test.go @@ -1,11 +1,19 @@ 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). @@ -136,3 +144,468 @@ func TestBlogEditorLoad(t *testing.T) { } }) } + +// 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", + "Tekst z linkiem & pogrubieniem.
\n", + "\nleading 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.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 + ">" + 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\ta
", true}, + {"a
", true}, + {"", true}, + {"\u00a0a
", 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 + }{ + {"Some bold\n text
", "Title Some bold text"}, + {"Grzyby leśne
", "Grzyby leśne"}, + {"a
b
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", + "`, editorPlaceholder}, + {"embed attributes on section", `PL
"}, + cabana.FieldWrite{Locale: "en", Value: "EN
"}, + cabana.FieldWrite{Locale: "de", Value: "DE
"}, + ) + post := &models.Post{Content: "PL
", ContentHTML: "old"} + if err := before[op](ctl, ctx, post); err != nil { + t.Fatal(err) + } + if post.ContentHTML != "PL
" { + t.Fatalf("ContentHTML = %q, want the default write", post.ContentHTML) + } + if len(writer.writes) != 0 { + t.Fatalf("translations written before the save: %#v", writer.writes) + } + if err := after[op](ctl, ctx, post); err != nil { + t.Fatal(err) + } + want := []recordedWrite{{"content_html", "en", "EN
"}, {"content_html", "de", "DE
"}} + if len(writer.writes) != 2 || writer.writes[0] != want[0] || writer.writes[1] != want[1] { + t.Fatalf("writes = %#v, want %#v", writer.writes, want) + } + }) + + t.Run(op+": only the default locale written", func(t *testing.T) { + ctl, writer := withWriter(t) + ctx := writes(principal(), cabana.FieldWrite{Locale: "pl", Default: true, Value: "PL
"}) + post := &models.Post{ContentHTML: "old"} + if err := before[op](ctl, ctx, post); err != nil { + t.Fatal(err) + } + if err := after[op](ctl, ctx, post); err != nil { + t.Fatal(err) + } + if post.ContentHTML != "PL
" || len(writer.writes) != 0 { + t.Fatalf("ContentHTML = %q, writes = %#v", post.ContentHTML, writer.writes) + } + }) + + t.Run(op+": only a non-default locale written", func(t *testing.T) { + ctl, writer := withWriter(t) + ctx := writes(principal(), cabana.FieldWrite{Locale: "en", Value: "EN
"}) + post := &models.Post{Content: "markdown", ContentHTML: "imported
"} + if err := before[op](ctl, ctx, post); err != nil { + t.Fatal(err) + } + if err := after[op](ctl, ctx, post); err != nil { + t.Fatal(err) + } + if post.ContentHTML != "imported
" { + t.Fatalf("ContentHTML = %q, want the untouched default kept", post.ContentHTML) + } + if len(writer.writes) != 1 || writer.writes[0] != (recordedWrite{"content_html", "en", "EN
"}) { + t.Fatalf("writes = %#v", writer.writes) + } + }) + + t.Run(op+": no content written", func(t *testing.T) { + ctl, writer := withWriter(t) + for name, ctx := range map[string]context.Context{ + "outside a save": principal(), + "no plugin field": cabana.WithFieldWrites(principal(), nil), + "other fields": cabana.WithFieldWrites(principal(), map[string][]cabana.FieldWrite{"excerpt": {{Locale: "en", Value: "x"}}}), + } { + post := &models.Post{Content: "markdown", ContentHTML: "imported
"} + if err := before[op](ctl, ctx, post); err != nil { + t.Fatalf("%s: %v", name, err) + } + if err := after[op](ctl, ctx, post); err != nil { + t.Fatalf("%s: %v", name, err) + } + if post.ContentHTML != "imported
" || len(writer.writes) != 0 { + t.Fatalf("%s: ContentHTML = %q, writes = %#v", name, post.ContentHTML, writer.writes) + } + } + }) + + t.Run(op+": no translation writer or no application", func(t *testing.T) { + ctx := writes(principal(), cabana.FieldWrite{Locale: "en", Value: "EN
"}) + for name, ctl := range map[string]postsAdminController{ + "no writer": {app: func() *backpack.App { return backpack.New(nil) }}, + "nil app": {app: func() *backpack.App { return nil }}, + "no app": {}, + } { + if err := after[op](ctl, ctx, &models.Post{}); err != nil { + t.Fatalf("%s: %v", name, err) + } + } + }) + } + + t.Run("a non-string value is not copied and fails the translation write", func(t *testing.T) { + ctl, writer := withWriter(t) + post := &models.Post{ContentHTML: "old"} + ctx := writes(principal(), cabana.FieldWrite{Locale: "pl", Default: true, Value: 42}) + if err := ctl.FormBeforeUpdate(ctx, post); err != nil { + t.Fatal(err) + } + if post.ContentHTML != "old" { + t.Fatalf("ContentHTML = %q", post.ContentHTML) + } + ctx = writes(principal(), cabana.FieldWrite{Locale: "en", Value: 42}) + if err := ctl.FormAfterUpdate(ctx, post); err == nil || len(writer.writes) != 0 { + t.Fatalf("err = %v, writes = %#v", err, writer.writes) + } + }) + + t.Run("a model that is not a post is an error", func(t *testing.T) { + ctl, _ := withWriter(t) + ctx := writes(principal(), cabana.FieldWrite{Locale: "en", Value: "EN
"}) + for name, fn := range map[string]hook{ + "FormBeforeCreate": postsAdminController.FormBeforeCreate, + "FormBeforeUpdate": postsAdminController.FormBeforeUpdate, + "FormAfterCreate": postsAdminController.FormAfterCreate, + "FormAfterUpdate": postsAdminController.FormAfterUpdate, + } { + if err := fn(ctl, ctx, &models.Category{}); err == nil { + t.Fatalf("%s accepted a category", name) + } + if err := fn(ctl, ctx, (*models.Post)(nil)); err == nil { + t.Fatalf("%s accepted a nil post", name) + } + } + }) + + t.Run("a translation writer error fails the save", func(t *testing.T) { + boom := errors.New("write failed") + app := backpack.New(nil) + if err := app.Publish[cabana.TranslationWriter](failingWriter{recordingWriter: &recordingWriter{}, err: boom}); err != nil { + t.Fatal(err) + } + ctl := postsAdminController{app: func() *backpack.App { return app }} + ctx := writes(principal(), cabana.FieldWrite{Locale: "en", Value: "EN
"}) + if err := ctl.FormAfterCreate(ctx, &models.Post{}); !errors.Is(err, boom) { + t.Fatalf("err = %v, want %v", err, boom) + } + }) +} + +// failingWriter is a cabana.TranslationWriter whose writes fail. +type failingWriter struct { + *recordingWriter + err error +} + +func (w failingWriter) WriteTranslated(context.Context, *gorm.DB, any, string, string, string) error { + return w.err +}