LooksLikeHTML decided by the first character alone, so a markdown post opening with a hero <img>, a <div> or a comment was served as raw markdown and its first edit stored ## and ** markers as HTML in both columns. Load now serves such a value as HTML only when content_html is empty or missing, or holds that same HTML (the value or SanitizeHTML of it, white space at the ends ignored); otherwise it serves content_html.
604 lines
23 KiB
Go
604 lines
23 KiB
Go
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 = "<h2>Grzyby</h2>\n<p>Tekst z <a href=\"https://example.test/a\">linkiem</a> & <strong>pogrubieniem</strong>.</p>\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 = `<p>Film:</p><figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>`
|
|
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, `<p>a<script>alert(1)</script></p><img src="https://example.test/x.png" alt="x" onerror="alert(1)">`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Value != `<p>a</p><img src="https://example.test/x.png" alt="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 = " <!-- lead -->\n<p>Stored <em>HTML</em></p>"
|
|
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 = "<img src=\"/storage/hero.png\" alt=\"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 <img>) = %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 "<p>Stored render</p>", true, nil
|
|
})
|
|
if got != "<p>Stored render</p>" {
|
|
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 != "<p>Hello <strong>world</strong></p>\n" {
|
|
t.Fatalf("Load(markdown, empty content_html) = %q", got)
|
|
}
|
|
if got := load(t, "Hello **world**", nil); got != "<p>Hello <strong>world</strong></p>\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 = "<h2>Translated</h2>\n<p>Written through the API.</p>\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 = "<h2>Translated</h2>\n<p>Edited in the admin.</p>\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: "<p>Host</p>", ContentHTML: "<p>Host</p>"}
|
|
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(), "<h2>Title</h2><p>Some <strong>bold</strong>\n text</p><style>p{}</style>")
|
|
if got != "Title Some bold text" {
|
|
t.Fatalf("ListValue = %q", got)
|
|
}
|
|
long := ml.ListValue(context.Background(), "<p>"+strings.Repeat("ż", 130)+"</p>")
|
|
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: "<p>A</p>"},
|
|
{Locale: "en", Value: "<p>B</p>"},
|
|
},
|
|
})
|
|
post := &models.Post{Content: "<p>A</p>", ContentHTML: "old"}
|
|
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if post.ContentHTML != "<p>A</p>" {
|
|
t.Fatalf("ContentHTML = %q, want <p>A</p>", 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: "<p>B</p>"}) {
|
|
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: "<p>kept</p>"}
|
|
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if post.ContentHTML != "<p>kept</p>" {
|
|
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 != "<p>kept</p>" || 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("<!DOCTYPE html><html><body><script>alert(1)</script></body></html>")
|
|
svgBytes = []byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>`)
|
|
)
|
|
|
|
// 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 := "<p>Pillar</p>"
|
|
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: `<p onclick="steal()">x</p>`})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Value != "<p>x</p>" || 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 := `<p onclick="steal()">x</p>`
|
|
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)
|
|
}
|
|
})
|
|
}
|