package controllers import ( "bytes" "context" "errors" "os" "strings" "testing" "testing/fstest" "git.golem15.com/golem15/sm-journal-plugin/classes" "git.golem15.com/golem15/sm-journal-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "gocloud.dev/blob/memblob" "gorm.io/gorm" ) // recordingWriter is a cabana.TranslationWriter that records writes. type recordingWriter struct { writes []recordedWrite } type recordedWrite struct { field, locale, value string } func (*recordingWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "pl", nil } func (*recordingWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) { return []string{"pl", "en"}, nil } func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, _ any, field, locale, value string) error { w.writes = append(w.writes, recordedWrite{field: field, locale: locale, value: value}) return nil } func (*recordingWriter) TranslatedExact(context.Context, *gorm.DB, any, string, string) (string, bool, error) { return "", false, nil } // blogType returns the named Blog Editor field type. func blogType(t *testing.T, name string) pact.FormFieldType { t.Helper() for _, ft := range classes.BlogEditorTypes(nil) { if ft.Name == name { return ft } } t.Fatalf("no field type %s", name) return pact.FormFieldType{} } // formField returns a compiled form field by name. func formField(t *testing.T, schema *cabana.FormSchema, name string) cabana.FormField { t.Helper() for _, f := range schema.Fields { if f.Name == name { return f } } t.Fatalf("form has no field %s", name) return cabana.FormField{} } // editorPrincipal is a backend administrator who may edit and publish any // post. func editorPrincipal() *bouncer.Principal { return &bouncer.Principal{ID: 7, Backend: true, IsSuperuser: true} } // TestBlogEditorTracerSmoke is the Phase 16.2 plan 03 tracer: post content // compiles as mlblogeditor, Decode stores the policy's output with notices // naming what it removed, Load serves HTML and markdown posts per D-07, and // the Form hooks write the written content to content_html per locale // without goldmark. func TestBlogEditorTracerSmoke(t *testing.T) { ml := blogType(t, "mlblogeditor") t.Run("posts form compiles with mlblogeditor", func(t *testing.T) { schema, err := cabana.CompileFormWithTypes("golem15.journal", postsAdminController{}, os.DirFS(".."), classes.BlogEditorTypes(nil)) if err != nil { t.Fatal(err) } content := formField(t, schema, "content") if content.Type != "mlblogeditor" || content.Element != classes.BlogEditorElement || !content.Translatable { t.Fatalf("content = type %q element %q translatable %v", content.Type, content.Element, content.Translatable) } if content.TypeOptions["htmlColumn"] != "content_html" || content.TypeOptions["uploadFolder"] != "posts" { t.Fatalf("content typeOptions = %#v", content.TypeOptions) } if excerpt := formField(t, schema, "excerpt"); excerpt.Type != "mltextarea" { t.Fatalf("excerpt type = %q, want mltextarea", excerpt.Type) } }) t.Run("type definitions", func(t *testing.T) { if ml.Element != "golem15-journal-blogeditor" || len(ml.Scripts) != 1 || ml.Scripts[0] != "assets/dist/blogeditor.js" || len(ml.Styles) != 1 || ml.Styles[0] != "assets/dist/blogeditor.css" { t.Fatalf("mlblogeditor = %+v", ml) } for _, asset := range append(append([]string{}, ml.Scripts...), ml.Styles...) { if _, err := os.Stat("../" + asset); err != nil { t.Fatalf("asset %s: %v", asset, err) } } plain := blogType(t, "blogeditor") if plain.Translatable || len(plain.Options) != 1 || plain.Options[0] != "uploadFolder" { t.Fatalf("blogeditor = %+v", plain) } for _, bad := range []map[string]any{ {"htmlColumn": "content html"}, {"htmlColumn": 3}, {"uploadFolder": "../up"}, {"uploadFolder": "a b"}, {"uploadFolder": strings.Repeat("a", 101)}, } { if err := ml.CheckOptions(bad); err == nil { t.Fatalf("CheckOptions(%v) = nil, want an error", bad) } } if err := ml.CheckOptions(map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts/2026"}); err != nil { t.Fatal(err) } }) decode := func(t *testing.T, value any) (pact.FormFieldDecoded, error) { t.Helper() return ml.Decode(context.Background(), pact.FormFieldValue{Field: "content", Locale: "pl", Default: true, Value: value}) } t.Run("decode keeps clean HTML byte for byte", func(t *testing.T) { const clean = "

Grzyby

\n

Tekst z linkiem & pogrubieniem.

\n" got, err := decode(t, clean) if err != nil { t.Fatal(err) } if got.Value != clean || len(got.Notices) != 0 { t.Fatalf("Decode(clean) = %#v", got) } }) t.Run("decode keeps the embed placeholder", func(t *testing.T) { const placeholder = `

Film:

` got, err := decode(t, placeholder) if err != nil { t.Fatal(err) } if got.Value != placeholder || len(got.Notices) != 0 { t.Fatalf("Decode(placeholder) = %#v", got) } }) t.Run("decode strips and reports", func(t *testing.T) { got, err := decode(t, `

a

x`) if err != nil { t.Fatal(err) } if got.Value != `

a

x` { t.Fatalf("Decode value = %q", got.Value) } if len(got.Notices) != 2 { t.Fatalf("notices = %#v, want two", got.Notices) } if n := got.Notices[0]; n.Message != "golem15.journal::lang.editor.removed_elements" || n.Count != 1 || n.Params["names"] != "script" { t.Fatalf("first notice = %#v", n) } if n := got.Notices[1]; n.Message != "golem15.journal::lang.editor.removed_attributes" || n.Count != 1 || n.Params["names"] != "onerror" { t.Fatalf("second notice = %#v", n) } }) t.Run("decode nil and non-string", func(t *testing.T) { got, err := decode(t, nil) if err != nil || got.Value != "" || len(got.Notices) != 0 { t.Fatalf("Decode(nil) = %#v, %v", got, err) } _, err = decode(t, 42.0) var refused *pact.FormFieldError if !errors.As(err, &refused) || len(refused.Messages) != 1 || refused.Messages[0] != "golem15.journal::lang.editor.content_invalid" { t.Fatalf("Decode(42) error = %v, want a FormFieldError", err) } }) load := func(t *testing.T, value any, translated func(string) (string, bool, error)) any { t.Helper() got, err := ml.Load(context.Background(), pact.FormFieldValue{ Field: "content", Options: map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"}, Locale: "en", Value: value, Translated: translated, }) if err != nil { t.Fatal(err) } return got } t.Run("load serves HTML content as it is", func(t *testing.T) { const html = " \n

Stored HTML

" for _, stored := range []string{"", html, html + "\n"} { if got := load(t, html, func(string) (string, bool, error) { return stored, true, nil }); got != html { t.Fatalf("Load(HTML) with content_html %q = %q", stored, got) } } if got := load(t, html, nil); got != html { t.Fatalf("Load(HTML, no reader) = %q", got) } }) t.Run("load serves markdown that starts with a tag as content_html", func(t *testing.T) { const source = "\"Hero\"\n\n## Heading\n\n**bold**" rendered, err := classes.FormatHTML(source) if err != nil { t.Fatal(err) } if got := load(t, source, func(string) (string, bool, error) { return rendered, true, nil }); got != rendered { t.Fatalf("Load(markdown starting with ) = %q, want content_html %q", got, rendered) } }) t.Run("load serves a markdown post's content_html", func(t *testing.T) { got := load(t, "Hello **world**", func(attribute string) (string, bool, error) { if attribute != "content_html" { t.Fatalf("Translated(%q), want content_html", attribute) } return "

Stored render

", true, nil }) if got != "

Stored render

" { t.Fatalf("Load(markdown) = %q", got) } }) t.Run("load renders markdown when content_html is empty", func(t *testing.T) { got := load(t, "Hello **world**", func(string) (string, bool, error) { return "", true, nil }) if got != "

Hello world

\n" { t.Fatalf("Load(markdown, empty content_html) = %q", got) } if got := load(t, "Hello **world**", nil); got != "

Hello world

\n" { t.Fatalf("Load(markdown, no reader) = %q", got) } }) // CR-01: a translation that carries only content_html opens with that // body. An untouched save echoes the served value, which cabana skips; // an edit writes the edited HTML to both content and content_html. t.Run("blank content opens with content_html and an edit writes both columns", func(t *testing.T) { const apiBody = "

Translated

\n

Written through the API.

\n" served := load(t, "", func(attribute string) (string, bool, error) { if attribute != "content_html" { t.Fatalf("Translated(%q), want content_html", attribute) } return apiBody, true, nil }) if served != apiBody { t.Fatalf("Load(blank content) = %q, want the stored content_html", served) } const edited = "

Translated

\n

Edited in the admin.

\n" decoded, err := ml.Decode(context.Background(), pact.FormFieldValue{Field: "content", Locale: "en", Value: edited}) if err != nil || decoded.Value != edited { t.Fatalf("Decode(edited) = %#v, %v", decoded, err) } writer := &recordingWriter{} app := backpack.New(nil) if err := app.Publish[cabana.TranslationWriter](writer); err != nil { t.Fatal(err) } ctl := postsAdminController{app: func() *backpack.App { return app }} ctx := bouncer.WithUser(context.Background(), editorPrincipal()) ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{ "content": {{Locale: "en", Value: decoded.Value}}, }) post := &models.Post{Content: "

Host

", ContentHTML: "

Host

"} if err := ctl.FormAfterUpdate(ctx, post); err != nil { t.Fatal(err) } if len(writer.writes) != 1 || writer.writes[0] != (recordedWrite{field: "content_html", locale: "en", value: edited}) { t.Fatalf("translation writes = %#v, want en content_html = the edited value", writer.writes) } }) t.Run("list value", func(t *testing.T) { got := ml.ListValue(context.Background(), "

Title

Some bold\n text

") if got != "Title Some bold text" { t.Fatalf("ListValue = %q", got) } long := ml.ListValue(context.Background(), "

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

") if []rune(long)[120] != '…' || len([]rune(long)) != 121 { t.Fatalf("ListValue(long) = %q", long) } }) t.Run("hooks write content_html per written locale", func(t *testing.T) { writer := &recordingWriter{} app := backpack.New(nil) if err := app.Publish[cabana.TranslationWriter](writer); err != nil { t.Fatal(err) } ctl := postsAdminController{app: func() *backpack.App { return app }} ctx := bouncer.WithUser(context.Background(), editorPrincipal()) ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{ "content": { {Locale: "pl", Default: true, Value: "

A

"}, {Locale: "en", Value: "

B

"}, }, }) post := &models.Post{Content: "

A

", ContentHTML: "old"} if err := ctl.FormBeforeUpdate(ctx, post); err != nil { t.Fatal(err) } if post.ContentHTML != "

A

" { t.Fatalf("ContentHTML = %q, want

A

", post.ContentHTML) } if err := ctl.FormAfterUpdate(ctx, post); err != nil { t.Fatal(err) } if len(writer.writes) != 1 || writer.writes[0] != (recordedWrite{field: "content_html", locale: "en", value: "

B

"}) { t.Fatalf("translation writes = %#v", writer.writes) } if err := ctl.FormAfterCreate(ctx, post); err != nil { t.Fatal(err) } if len(writer.writes) != 2 { t.Fatalf("FormAfterCreate writes = %#v", writer.writes) } }) t.Run("hooks leave untouched content alone", func(t *testing.T) { writer := &recordingWriter{} app := backpack.New(nil) if err := app.Publish[cabana.TranslationWriter](writer); err != nil { t.Fatal(err) } ctl := postsAdminController{app: func() *backpack.App { return app }} ctx := bouncer.WithUser(context.Background(), editorPrincipal()) const markdown = "Hello **world**" post := &models.Post{Content: markdown, ContentHTML: "

kept

"} if err := ctl.FormBeforeUpdate(ctx, post); err != nil { t.Fatal(err) } if post.ContentHTML != "

kept

" { t.Fatalf("ContentHTML = %q, want it unchanged (no goldmark)", post.ContentHTML) } ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{"title": {{Locale: "en", Value: "x"}}}) if err := ctl.FormBeforeUpdate(ctx, post); err != nil { t.Fatal(err) } if err := ctl.FormAfterUpdate(ctx, post); err != nil { t.Fatal(err) } if post.ContentHTML != "

kept

" || len(writer.writes) != 0 { t.Fatalf("ContentHTML = %q, writes = %#v", post.ContentHTML, writer.writes) } }) t.Run("notice phrases have plural forms", func(t *testing.T) { fsys := fstest.MapFS{} for _, locale := range []string{"en", "pl"} { raw, err := os.ReadFile("../lang/" + locale + "/lang.yaml") if err != nil { t.Fatal(err) } fsys["lang/"+locale+"/lang.yaml"] = &fstest.MapFile{Data: raw} } cat := phrasebook.NewCatalog() if err := cat.Load("golem15.journal", fsys); err != nil { t.Fatal(err) } tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"}) names := map[string]string{"names": "script, iframe"} for _, tc := range []struct { locale, key string count int want string }{ {"en", "removed_elements", 1, "1 element was removed: script, iframe"}, {"en", "removed_elements", 2, "2 elements were removed: script, iframe"}, {"en", "removed_attributes", 3, "3 attributes were removed: script, iframe"}, {"en", "removed_comments", 1, "1 HTML comment was removed"}, {"pl", "removed_elements", 1, "Usunięto 1 element: script, iframe"}, {"pl", "removed_elements", 3, "Usunięto 3 elementy: script, iframe"}, {"pl", "removed_attributes", 5, "Usunięto 5 atrybutów: script, iframe"}, {"pl", "removed_comments", 2, "Usunięto 2 komentarze HTML"}, } { if got := tr.ChoiceIn(tc.locale, "golem15.journal::lang.editor."+tc.key, tc.count, names); got != tc.want { t.Fatalf("%s %s %d = %q, want %q", tc.locale, tc.key, tc.count, got, tc.want) } } if got := tr.GetIn("pl", "golem15.journal::lang.editor.content_invalid", nil); got != "Treść musi być tekstem HTML." { t.Fatalf("pl content_invalid = %q", got) } }) } // pngBytes, jpegBytes and htmlBytes are file heads http.DetectContentType // recognizes. var ( pngBytes = append([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"), make([]byte, 64)...) jpegBytes = append([]byte("\xff\xd8\xff\xe0\x00\x10JFIF\x00"), make([]byte, 64)...) gifBytes = append([]byte("GIF89a"), make([]byte, 64)...) webpBytes = append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 64)...) htmlBytes = []byte("") svgBytes = []byte(``) ) // TestBlogEditorUploadSmoke covers plan 03 Task 2: the media storage shared // by the public upload route and the editor Upload, the editor Upload's // magic-byte check and safe names, and category content on blogeditor. func TestBlogEditorUploadSmoke(t *testing.T) { bucket := memblob.OpenBucket(nil) t.Cleanup(func() { _ = bucket.Close() }) app := backpack.New(nil) if err := app.Publish(bucket); err != nil { t.Fatal(err) } ctx := context.Background() stored := func(t *testing.T, key string) []byte { t.Helper() data, err := bucket.ReadAll(ctx, key) if err != nil { t.Fatalf("read %s: %v", key, err) } return data } mediaError := func(t *testing.T, err error, message string) { t.Helper() var refused *classes.MediaError if !errors.As(err, &refused) || refused.Field != "file" || refused.Message != message { t.Fatalf("error = %v, want MediaError on file %q", err, message) } } t.Run("StoreMedia keeps the public route's layout", func(t *testing.T) { got, err := classes.StoreMedia(ctx, bucket, "photos", "cover.png", bytes.NewReader(pngBytes)) if err != nil { t.Fatal(err) } if got.Path != "/journal/photos/cover.png" || got.URL != attach.PublicURL("journal/photos/cover.png") { t.Fatalf("StoreMedia = %+v", got) } if !bytes.Equal(stored(t, "journal/photos/cover.png"), pngBytes) { t.Fatal("stored bytes differ") } again, err := classes.StoreMedia(ctx, bucket, "/journal/photos/", `C:\up\cover.png`, bytes.NewReader(pngBytes)) if err != nil { t.Fatal(err) } if again.Path != "/journal/photos/cover-1.png" { t.Fatalf("second StoreMedia path = %q, want the unique suffix", again.Path) } }) t.Run("StoreMedia refuses other extensions and large files", func(t *testing.T) { _, err := classes.StoreMedia(ctx, bucket, "", "logo.svg", bytes.NewReader(svgBytes)) mediaError(t, err, "The file must be an image.") big := append(append([]byte{}, pngBytes...), make([]byte, classes.MediaMaxBytes)...) _, err = classes.StoreMedia(ctx, bucket, "", "big.png", bytes.NewReader(big)) mediaError(t, err, "The file may not be greater than 10240 kilobytes.") if exists, _ := bucket.Exists(ctx, "journal/big.png"); exists { t.Fatal("a refused file was stored") } if _, err := classes.StoreMedia(ctx, nil, "", "a.png", bytes.NewReader(pngBytes)); err == nil { t.Fatal("StoreMedia without a bucket = nil error") } else if errors.As(err, new(*classes.MediaError)) { t.Fatalf("StoreMedia without a bucket = %v, want an opaque error", err) } }) types := classes.BlogEditorTypes(func() *backpack.App { return app }) for _, ft := range types { if ft.Upload == nil { t.Fatalf("%s has no Upload", ft.Name) } } upload := func(ft pact.FormFieldType, options map[string]any, name string, body []byte) (pact.FormFieldUploadResult, error) { return ft.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) } } posts := map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"} t.Run("editor upload stores a sniffed image under a safe name", func(t *testing.T) { got, err := upload(types[1], posts, "Zdjęcie 1.PNG", pngBytes) if err != nil { t.Fatal(err) } if got.URL != attach.PublicURL("journal/posts/zdj-cie-1.png") { t.Fatalf("URL = %q", got.URL) } if !bytes.Equal(stored(t, "journal/posts/zdj-cie-1.png"), pngBytes) { t.Fatal("stored bytes differ from the upload") } for name, body := range map[string][]byte{"a.jpg": jpegBytes, "b.jpeg": jpegBytes, "c.gif": gifBytes, "d.webp": webpBytes} { if _, err := upload(types[0], map[string]any{"uploadFolder": "categories"}, name, body); err != nil { t.Fatalf("upload %s: %v", name, err) } } got, err = upload(types[0], nil, "...", pngBytes) if err != nil { t.Fatal(err) } if !strings.HasPrefix(got.URL, attach.PublicURL("journal/editor/")) { t.Fatalf("default folder URL = %q, want journal/editor/", got.URL) } }) t.Run("editor upload refuses what does not sniff as its image type", func(t *testing.T) { const invalid = "golem15.journal::lang.editor.image_invalid" _, err := upload(types[1], posts, "x.png", htmlBytes) refused(t, err, invalid) _, err = upload(types[1], posts, "logo.svg", svgBytes) refused(t, err, invalid) _, err = upload(types[1], posts, "photo.jpg", pngBytes) refused(t, err, invalid) _, err = upload(types[1], posts, "photo.svg", pngBytes) refused(t, err, "The file must be an image.") big := append(append([]byte{}, pngBytes...), make([]byte, classes.MediaMaxBytes)...) _, err = upload(types[1], posts, "big.png", big) refused(t, err, "The file may not be greater than 10240 kilobytes.") }) t.Run("editor upload without a bucket is an opaque error", func(t *testing.T) { bare := classes.BlogEditorTypes(func() *backpack.App { return backpack.New(nil) }) _, err := bare[1].Upload(ctx, pact.FormFieldUpload{Field: "content", Options: posts, Filename: "a.png", Body: bytes.NewReader(pngBytes)}) if err == nil || errors.As(err, new(*pact.FormFieldError)) { t.Fatalf("error = %v, want an opaque error", err) } nilApp := classes.BlogEditorTypes(nil) if _, err := nilApp[1].Upload(ctx, pact.FormFieldUpload{Field: "content", Filename: "a.png", Body: bytes.NewReader(pngBytes)}); err == nil { t.Fatal("upload without an app = nil error") } }) t.Run("category content compiles as blogeditor", func(t *testing.T) { schema, err := cabana.CompileFormWithTypes("golem15.journal", categoriesAdminController{}, os.DirFS(".."), classes.BlogEditorTypes(nil)) if err != nil { t.Fatal(err) } content := formField(t, schema, "content") if content.Type != "blogeditor" || content.Element != classes.BlogEditorElement || content.Translatable { t.Fatalf("content = type %q element %q translatable %v", content.Type, content.Element, content.Translatable) } if content.TypeOptions["uploadFolder"] != "categories" || content.Size != "huge" { t.Fatalf("content typeOptions = %#v size %q", content.TypeOptions, content.Size) } }) plain := types[0] t.Run("category load serves the stored HTML", func(t *testing.T) { html := "

Pillar

" for _, tc := range []struct { value any want string }{{&html, html}, {(*string)(nil), ""}, {nil, ""}, {"plain text", "plain text"}} { got, err := plain.Load(ctx, pact.FormFieldValue{Field: "content", Options: map[string]any{"uploadFolder": "categories"}, Default: true, Value: tc.value}) if err != nil || got != tc.want { t.Fatalf("Load(%#v) = %#v, %v, want %q", tc.value, got, err, tc.want) } } }) t.Run("category decode strips with a notice", func(t *testing.T) { got, err := plain.Decode(ctx, pact.FormFieldValue{Field: "content", Default: true, Value: `

x

`}) if err != nil { t.Fatal(err) } if got.Value != "

x

" || len(got.Notices) != 1 { t.Fatalf("Decode = %#v", got) } if n := got.Notices[0]; n.Message != "golem15.journal::lang.editor.removed_attributes" || n.Count != 1 || n.Params["names"] != "onclick" { t.Fatalf("notice = %#v", n) } }) t.Run("category hooks map blank to NULL and accept markup", func(t *testing.T) { ctl := categoriesAdminController{} blank := " \n\t" cat := &models.Category{Content: &blank} if err := ctl.FormBeforeUpdate(ctx, cat); err != nil || cat.Content != nil { t.Fatalf("blank: content = %v, err = %v, want NULL", cat.Content, err) } body := `

x

` cat = &models.Category{Content: &body} if err := ctl.FormBeforeCreate(ctx, cat); err != nil { t.Fatalf("FormBeforeCreate refused markup: %v", err) } if cat.Content == nil || *cat.Content != body { t.Fatalf("content = %v, want it as received", cat.Content) } }) }