test(16.2-06): full unit coverage of the Journal Blog Editor field types

- Decode: clean values byte for byte, nil, non-string refusal, the 35
  unsafe payloads stripped with notices exactly when changed
- notices order, ten-name cut, plural forms in lang/en and lang/pl
- LooksLikeHTML, options, ListValue, Upload sniffing and safe names
- StoreMedia layout, suffixes, no overwrite, size and folder rules
- editor corpus and policy cross-check extended in place: more negatives,
  notices only when stripped, stable second pass
- TestPostsEditorHooks: create and update, default and non-default
  writes, no writes, no writer
- TestCategoriesAdminBlogEditor (Docker host): untouched saves keep
  imported markup and NULL, clean edit, strip and report, blank to NULL,
  editor upload
This commit is contained in:
2026-10-10 15:55:00 +02:00
parent d5a5413525
commit da7a0732c3
5 changed files with 1110 additions and 0 deletions

View File

@@ -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",
"<h2>Grzyby</h2>\n<p>Tekst z <a href=\"https://example.test/a\">linkiem</a> &amp; <strong>pogrubieniem</strong>.</p>\n",
"\n <p>leading and trailing white space</p>\n\n",
editorPlaceholder,
}
for _, e := range editorCorpus(t) {
clean = append(clean, e.HTML)
}
for _, in := range clean {
for _, got := range decodeBoth(t, in) {
if got.Value != in || len(got.Notices) != 0 {
t.Fatalf("Decode(%q) = %#v, want it unchanged without notices", in, got)
}
}
}
})
t.Run("nil decodes to the empty string", func(t *testing.T) {
for _, got := range decodeBoth(t, nil) {
if got.Value != "" || got.Notices != nil {
t.Fatalf("Decode(nil) = %#v", got)
}
}
})
t.Run("a value that is not a string is a FormFieldError", func(t *testing.T) {
for _, value := range []any{42.0, true, map[string]any{"html": "<p>x</p>"}, []any{"<p>x</p>"}} {
for _, typ := range BlogEditorTypes(nil) {
_, err := typ.Decode(context.Background(), pact.FormFieldValue{Value: value})
var refused *pact.FormFieldError
if !errors.As(err, &refused) || len(refused.Messages) != 1 || refused.Messages[0] != editorContentInvalid {
t.Fatalf("%s: Decode(%#v) error = %v", typ.Name, value, err)
}
}
}
})
t.Run("every unsafe payload is stripped with a notice, never refused", func(t *testing.T) {
stripped := 0
for _, tc := range unsafePayloads {
for _, got := range decodeBoth(t, tc.src) {
text, _ := got.Value.(string)
if text != SanitizeHTML(tc.src) {
t.Fatalf("%s: Decode = %q, want SanitizeHTML's %q", tc.name, text, SanitizeHTML(tc.src))
}
// Markdown-only payloads ("[x](javascript:...)") are inert text
// to the HTML policy: kept, and then without a notice.
if (text == tc.src) != (len(got.Notices) == 0) {
t.Fatalf("%s: Decode = %q with %d notices, want notices exactly when stripped", tc.name, text, len(got.Notices))
}
if text != tc.src {
stripped++
}
assertNoActiveContent(t, text)
}
}
// Every payload with HTML markup is stripped; only the markdown
// syntax ones (attribute block, javascript, vbscript and data links,
// data image) are plain text here.
if want := 2 * (len(unsafePayloads) - 5); stripped != want {
t.Fatalf("stripped %d decodes, want %d", stripped, want)
}
})
}
// langPluralForms returns the plural form names of editor.<key> in
// lang/<locale>/lang.yaml, or nil when the key is a single string.
func langPluralForms(t *testing.T, locale, key string) (forms []string, found bool) {
t.Helper()
raw, err := os.ReadFile("../lang/" + locale + "/lang.yaml")
if err != nil {
t.Fatal(err)
}
inEditor, inKey := false, false
sc := bufio.NewScanner(bytes.NewReader(raw))
for sc.Scan() {
line := sc.Text()
trimmed := strings.TrimSpace(line)
indent := len(line) - len(strings.TrimLeft(line, " "))
switch {
case trimmed == "" || strings.HasPrefix(trimmed, "#"):
case indent == 0:
inEditor, inKey = trimmed == "editor:", false
case inEditor && indent == 2:
name, rest, _ := strings.Cut(trimmed, ":")
inKey = name == key
if inKey {
found = true
if strings.TrimSpace(rest) != "" {
return nil, true
}
}
case inEditor && inKey && indent == 4:
name, _, _ := strings.Cut(trimmed, ":")
forms = append(forms, name)
}
}
return forms, found
}
func TestBlogEditorNotices(t *testing.T) {
t.Run("notices come in the order elements, attributes, comments, other", func(t *testing.T) {
_, report := SanitizeHTMLReport(`<p onclick="x" onload="y">a</p><script>s</script><style>p{}</style><!-- c -->`)
if report.Other {
t.Fatalf("report = %+v, want no unexplained change", report)
}
report.Other = true
got := removalNotices(report)
if len(got) != 4 {
t.Fatalf("notices = %#v, report %+v", got, report)
}
want := []struct {
message string
count int
names string
}{
{editorRemovedElements, 2, "script, style"},
{editorRemovedAttributes, 2, "onclick, onload"},
{editorRemovedComments, 1, ""},
{editorMarkupChanged, 0, ""},
}
for i, w := range want {
if got[i].Message != w.message || got[i].Count != w.count || got[i].Params["names"] != w.names {
t.Fatalf("notice %d = %#v, want %+v", i, got[i], w)
}
}
})
t.Run("an empty report has no notice", func(t *testing.T) {
if got := removalNotices(HTMLRemovals{}); got != nil {
t.Fatalf("notices = %#v", got)
}
})
t.Run("names are cut after ten with an ellipsis; the count stays exact", func(t *testing.T) {
var b strings.Builder
for _, tag := range []string{"script", "iframe", "object", "embed", "form", "input", "button", "select", "textarea", "base", "meta", "link"} {
b.WriteString("<" + tag + "></" + tag + ">")
}
_, report := SanitizeHTMLReport(b.String())
got := removalNotices(report)
if len(got) != 1 || got[0].Count != 12 {
t.Fatalf("notices = %#v", got)
}
names := strings.Split(got[0].Params["names"], ", ")
if len(names) != 11 || names[10] != "…" || names[0] != "script" || names[9] != "base" {
t.Fatalf("names = %q", got[0].Params["names"])
}
if removalNames([]string{"a", "b"}, false) != "a, b" || removalNames([]string{"a"}, true) != "a, …" {
t.Fatal("removalNames")
}
})
t.Run("every notice key is in lang/en and lang/pl with its plural forms", func(t *testing.T) {
for _, key := range []string{"removed_elements", "removed_attributes", "removed_comments"} {
en, ok := langPluralForms(t, "en", key)
if !ok || strings.Join(en, ",") != "one,other" {
t.Fatalf("en %s forms = %v (found %v), want one, other", key, en, ok)
}
pl, ok := langPluralForms(t, "pl", key)
if !ok || strings.Join(pl, ",") != "one,few,many,other" {
t.Fatalf("pl %s forms = %v (found %v), want one, few, many, other", key, pl, ok)
}
}
for _, key := range []string{"content_invalid", "image_invalid", "upload_failed", "markup_changed"} {
for _, locale := range []string{"en", "pl"} {
if forms, ok := langPluralForms(t, locale, key); !ok || forms != nil {
t.Fatalf("%s %s: forms %v found %v, want a single phrase", locale, key, forms, ok)
}
}
}
for key, constant := range map[string]string{
"content_invalid": editorContentInvalid, "image_invalid": editorImageInvalid,
"removed_elements": editorRemovedElements, "removed_attributes": editorRemovedAttributes,
"removed_comments": editorRemovedComments, "markup_changed": editorMarkupChanged,
} {
if constant != "golem15.journal::lang.editor."+key {
t.Fatalf("%s constant = %q", key, constant)
}
}
})
}
func TestLooksLikeHTML(t *testing.T) {
for _, tc := range []struct {
in string
want bool
}{
{"<p>a</p>", true},
{" \n\t<p>a</p>", true},
{"<!-- imported --><p>a</p>", true},
{"<!DOCTYPE html>", true},
{"\u00a0<p>a</p>", true},
{"", false},
{" ", false},
{"# Heading", false},
{"Text with <b>inline</b> markup", false},
{"> quote", false},
{"&lt;p&gt;", false},
} {
if got := LooksLikeHTML(tc.in); got != tc.want {
t.Fatalf("LooksLikeHTML(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestBlogEditorOptions(t *testing.T) {
types := BlogEditorTypes(nil)
if len(types) != 2 || types[0].Name != "blogeditor" || types[1].Name != "mlblogeditor" {
t.Fatalf("types = %+v", types)
}
for _, typ := range types {
if typ.Element != BlogEditorElement || typ.CheckOptions == nil || typ.Decode == nil || typ.Load == nil || typ.ListValue == nil || typ.Upload == nil {
t.Fatalf("%s = %+v", typ.Name, typ)
}
if strings.Join(typ.Scripts, ",") != "assets/dist/blogeditor.js" || strings.Join(typ.Styles, ",") != "assets/dist/blogeditor.css" {
t.Fatalf("%s assets = %v %v", typ.Name, typ.Scripts, typ.Styles)
}
}
if types[0].Translatable || strings.Join(types[0].Options, ",") != "uploadFolder" {
t.Fatalf("blogeditor = %+v", types[0])
}
if !types[1].Translatable || strings.Join(types[1].Options, ",") != "htmlColumn,uploadFolder" {
t.Fatalf("mlblogeditor = %+v", types[1])
}
for _, ok := range []map[string]any{
{},
{"htmlColumn": "content_html"},
{"htmlColumn": "_x9"},
{"uploadFolder": "posts"},
{"uploadFolder": "posts/2026_10-a"},
{"uploadFolder": ""},
{"uploadFolder": strings.Repeat("a", 100)},
} {
if err := checkBlogOptions(ok); err != nil {
t.Fatalf("checkBlogOptions(%v) = %v", ok, err)
}
}
for _, bad := range []map[string]any{
{"htmlColumn": ""},
{"htmlColumn": "content html"},
{"htmlColumn": "1column"},
{"htmlColumn": "content-html"},
{"htmlColumn": 3},
{"htmlColumn": nil},
{"uploadFolder": "../up"},
{"uploadFolder": "a..b"},
{"uploadFolder": "a b"},
{"uploadFolder": "a\\b"},
{"uploadFolder": strings.Repeat("a", 101)},
{"uploadFolder": 5},
} {
if err := checkBlogOptions(bad); err == nil {
t.Fatalf("checkBlogOptions(%v) = nil, want an error", bad)
}
}
}
func TestBlogEditorListValue(t *testing.T) {
html := "<p>Grzyby</p>"
for _, tc := range []struct {
in any
want string
}{
{"<h2>Title</h2><p>Some <strong>bold</strong>\n text</p>", "Title Some bold text"},
{"<p>Grz<strong>yby</strong> <em>leśne</em></p>", "Grzyby leśne"},
{"<p>a</p><p>b</p><ul><li>c</li><li>d</li></ul>", "a b c d"},
{"<p>x</p><script>alert(1)</script><style>p{}</style><p>y</p>", "x y"},
{"<p>Fish &amp; chips &lt;3</p>", "Fish & chips <3"},
{"<!-- hidden --><p>shown</p>", "shown"},
{editorPlaceholder + "<p>after</p>", "after"},
{"## Markdown *stays* text", "## Markdown *stays* text"},
{" \n ", ""},
{nil, ""},
{(*string)(nil), ""},
{&html, "Grzyby"},
{42, "42"},
} {
if got := blogListValue(context.Background(), tc.in); got != tc.want {
t.Fatalf("ListValue(%#v) = %q, want %q", tc.in, got, tc.want)
}
}
exact := strings.Repeat("ż", 120)
if got := blogListValue(context.Background(), "<p>"+exact+"</p>"); got != exact {
t.Fatalf("120 runes were cut: %q", got)
}
long := blogListValue(context.Background(), "<p>"+strings.Repeat("ż", 121)+"</p>")
if utf8.RuneCountInString(long) != 121 || !strings.HasSuffix(long, "…") {
t.Fatalf("121 runes = %q", long)
}
// White space before the cut is trimmed before the ellipsis.
spaced := blogListValue(context.Background(), "<p>"+strings.Repeat("a", 119)+" bcd</p>")
if spaced != strings.Repeat("a", 119)+"…" {
t.Fatalf("cut at a space = %q", spaced)
}
for _, typ := range BlogEditorTypes(nil) {
if got := typ.ListValue(context.Background(), "<p>a</p>"); got != "a" {
t.Fatalf("%s ListValue = %q", typ.Name, got)
}
}
}
// File heads http.DetectContentType recognizes.
var (
testPNG = append([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"), make([]byte, 64)...)
testJPEG = append([]byte("\xff\xd8\xff\xe0\x00\x10JFIF\x00"), make([]byte, 64)...)
testGIF = append([]byte("GIF89a"), make([]byte, 64)...)
testWebP = append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 64)...)
testHTML = []byte("<!DOCTYPE html><html><body><script>alert(1)</script></body></html>")
testSVG = []byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>`)
testText = []byte("just some text that is not an image at all")
)
func TestBlogEditorUpload(t *testing.T) {
ctx := context.Background()
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
app := backpack.New(nil)
if err := attach.Publish(app, bucket); err != nil {
t.Fatal(err)
}
types := BlogEditorTypes(func() *backpack.App { return app })
upload := func(typ pact.FormFieldType, options map[string]any, name string, body []byte) (pact.FormFieldUploadResult, error) {
return typ.Upload(ctx, pact.FormFieldUpload{Field: "content", Options: options, Filename: name, Body: bytes.NewReader(body)})
}
refused := func(t *testing.T, err error, message string) {
t.Helper()
var fe *pact.FormFieldError
if !errors.As(err, &fe) || len(fe.Messages) != 1 || fe.Messages[0] != message {
t.Fatalf("error = %v, want FormFieldError %q", err, message)
}
}
stored := func(t *testing.T, key string, want []byte) {
t.Helper()
data, err := bucket.ReadAll(ctx, key)
if err != nil || !bytes.Equal(data, want) {
t.Fatalf("read %s: %v (bytes equal %v)", key, err, bytes.Equal(data, want))
}
}
t.Run("each image type is stored when its bytes match its name", func(t *testing.T) {
for _, tc := range []struct {
name string
body []byte
key string
}{
{"a.png", testPNG, "journal/posts/a.png"},
{"b.jpg", testJPEG, "journal/posts/b.jpg"},
{"c.JPEG", testJPEG, "journal/posts/c.jpeg"},
{"d.gif", testGIF, "journal/posts/d.gif"},
{"e.webp", testWebP, "journal/posts/e.webp"},
{"noext", testPNG, "journal/posts/noext.png"},
{"photo", testJPEG, "journal/posts/photo.jpg"},
} {
got, err := upload(types[1], map[string]any{"uploadFolder": "posts"}, tc.name, tc.body)
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if got.URL != attach.PublicURL(tc.key) {
t.Fatalf("%s: URL = %q, want %q", tc.name, got.URL, attach.PublicURL(tc.key))
}
stored(t, tc.key, tc.body)
}
})
t.Run("a file that does not sniff as its image type is refused", func(t *testing.T) {
for _, tc := range []struct {
name string
body []byte
}{
{"page.png", testHTML},
{"logo.svg", testSVG},
{"vector.png", testSVG},
{"notes.txt", testText},
{"noext", testText},
{"photo.jpg", testPNG},
{"photo.png", testGIF},
{"empty.png", nil},
} {
_, err := upload(types[0], nil, tc.name, tc.body)
refused(t, err, editorImageInvalid)
}
if _, err := types[0].Upload(ctx, pact.FormFieldUpload{Filename: "a.png"}); err == nil {
t.Fatal("an upload without a body was accepted")
} else {
refused(t, err, editorImageInvalid)
}
})
t.Run("an image under a name StoreMedia refuses gets its message", func(t *testing.T) {
_, err := upload(types[0], nil, "photo.bmp", testPNG)
refused(t, err, "The file must be an image.")
big := append(append([]byte{}, testPNG...), make([]byte, MediaMaxBytes)...)
_, err = upload(types[0], nil, "big.png", big)
refused(t, err, "The file may not be greater than 10240 kilobytes.")
_, err = upload(types[0], map[string]any{"uploadFolder": "../x"}, "a.png", testPNG)
refused(t, err, "The folder format is invalid.")
})
t.Run("the folder is the field's uploadFolder, editor by default", func(t *testing.T) {
got, err := upload(types[0], map[string]any{"uploadFolder": "categories"}, "cat.png", testPNG)
if err != nil || got.URL != attach.PublicURL("journal/categories/cat.png") {
t.Fatalf("URL = %q, %v", got.URL, err)
}
got, err = upload(types[0], nil, "default.png", testPNG)
if err != nil || got.URL != attach.PublicURL("journal/editor/default.png") {
t.Fatalf("URL = %q, %v", got.URL, err)
}
})
t.Run("names are made safe", func(t *testing.T) {
for in, want := range map[string]string{
"Zdjęcie 1.PNG": "zdj-cie-1.png",
`C:\\Users\\x\\My Photo.jpg`: "my-photo.jpg",
"../../etc/passwd.png": "passwd.png",
"...": "image",
"": "image",
"-.-a-.png": "a.png",
"a--b__c.webp": "a-b__c.webp",
"<script>.gif": "script.gif",
`x" onerror="alert(1).png`: "x-onerror-alert-1.png",
"name.with.dots.jpeg": "name.with.dots.jpeg",
"weird.ext$": "weird.ext",
} {
if got := safeUploadName(in); got != want {
t.Fatalf("safeUploadName(%q) = %q, want %q", in, got, want)
}
}
})
t.Run("a missing application or bucket is an opaque error", func(t *testing.T) {
for name, typ := range map[string]pact.FormFieldType{
"nil app": BlogEditorTypes(nil)[0],
"nil value": BlogEditorTypes(func() *backpack.App { return nil })[0],
"no bucket": BlogEditorTypes(func() *backpack.App { return backpack.New(nil) })[0],
} {
_, err := upload(typ, nil, "a.png", testPNG)
if err == nil || errors.As(err, new(*pact.FormFieldError)) {
t.Fatalf("%s: error = %v, want an opaque error", name, err)
}
}
})
}

View File

@@ -66,6 +66,25 @@ func tagAttrs(doc, tag string) [][]string {
// and with an empty removal report, and its four editor-output additions
// (figure data-embed and data-id, img alt, ol start) admit nothing else.
func TestEditorOutputPassesPolicy(t *testing.T) {
t.Run("corpus is the editor's full output set", func(t *testing.T) {
corpus := editorCorpus(t)
if len(corpus) < 15 {
t.Fatalf("corpus has %d entries, want at least 15", len(corpus))
}
names := map[string]bool{}
for _, e := range corpus {
if e.Name == "" || strings.TrimSpace(e.HTML) == "" || names[e.Name] {
t.Fatalf("corpus entry %q is empty or repeated", e.Name)
}
names[e.Name] = true
}
for _, want := range []string{"embed-youtube", "image-with-caption", "image-without-caption", "ordered-list-start-3", "table-header-row", "links", "combined"} {
if !names[want] {
t.Fatalf("corpus has no %s entry", want)
}
}
})
for _, e := range editorCorpus(t) {
t.Run("corpus "+e.Name, func(t *testing.T) {
if got := SanitizeHTML(e.HTML); got != e.HTML {
@@ -77,6 +96,7 @@ func TestEditorOutputPassesPolicy(t *testing.T) {
if err := CheckHTML(e.HTML); err != nil {
t.Fatalf("CheckHTML = %v", err)
}
assertNoActiveContent(t, e.HTML)
})
}
@@ -114,6 +134,15 @@ func TestEditorOutputPassesPolicy(t *testing.T) {
{"negative start", `<ol start="-1"><li>a</li></ol>`, `<ol><li>a</li></ol>`},
{"start on ul", `<ul start="3"><li>a</li></ul>`, `<ul><li>a</li></ul>`},
{"alt on p", `<p alt="x">y</p>`, `<p>y</p>`},
{"upper-case provider", `<figure data-embed="YouTube" data-id="dQw4w9WgXcQ"></figure>`, `<figure data-id="dQw4w9WgXcQ"></figure>`},
{"provider with white space", `<figure data-embed=" youtube" data-id="dQw4w9WgXcQ"></figure>`, `<figure data-id="dQw4w9WgXcQ"></figure>`},
{"empty id", `<figure data-embed="youtube" data-id=""></figure>`, `<figure data-embed="youtube"></figure>`},
{"id with a URL", `<figure data-embed="youtube" data-id="javascript:1"></figure>`, `<figure data-embed="youtube"></figure>`},
{"iframe alone", `<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"></iframe>`, ``},
{"script inside the placeholder", `<figure data-embed="youtube" data-id="dQw4w9WgXcQ"><script>alert(1)</script></figure>`, editorPlaceholder},
{"embed attributes on section", `<section data-embed="youtube" data-id="dQw4w9WgXcQ">x</section>`, `<section>x</section>`},
{"embed attributes on a", `<a href="/x" data-embed="youtube" data-id="dQw4w9WgXcQ">x</a>`, `<a href="/x">x</a>`},
{"start with a fraction", `<ol start="1.5"><li>a</li></ol>`, `<ol><li>a</li></ol>`},
}
for _, tc := range stripped {
t.Run("strips "+tc.name, func(t *testing.T) {
@@ -248,6 +277,29 @@ func TestSanitizeHTMLReport(t *testing.T) {
if got != SanitizeHTML(in) {
t.Fatalf("SanitizeHTMLReport and SanitizeHTML disagree on %q", in)
}
// Notices only when something was stripped (D-10 amended).
if notices := removalNotices(r); (len(notices) == 0) != (got == in) {
t.Fatalf("SanitizeHTMLReport(%q): %d notices, output changed = %v", in, len(notices), got != in)
}
// The report is stable: sanitizing the output again changes nothing.
if again, r2 := SanitizeHTMLReport(got); again != got || !r2.Empty() {
t.Fatalf("second pass over %q = %q, %+v", got, again, r2)
}
}
})
t.Run("the imported post is stripped of exactly its style block", func(t *testing.T) {
fixture, err := os.ReadFile("../tests/blogeditor/fixtures/imported-post.html")
if err != nil {
t.Fatal(err)
}
got, r := SanitizeHTMLReport(string(fixture))
if got == string(fixture) || r.Empty() {
t.Fatalf("the imported post passed the policy unchanged; the fixture should hold markup the policy strips")
}
if !slices.Contains(r.Elements, "style") {
t.Fatalf("report = %+v, want the style element named", r)
}
assertNoActiveContent(t, got)
})
}

View File

@@ -1,8 +1,15 @@
package classes
import (
"bytes"
"context"
"errors"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gocloud.dev/blob/memblob"
)
func TestMediaObjectPath(t *testing.T) {
@@ -37,3 +44,121 @@ func TestMediaFolderPatternRejectsDotDot(t *testing.T) {
}
t.Fatal("ok_folder-1 should match")
}
// failingReader fails every read.
type failingReader struct{}
func (failingReader) Read([]byte) (int, error) { return 0, errors.New("disk on fire") }
func TestStoreMedia(t *testing.T) {
ctx := context.Background()
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
mediaErr := func(t *testing.T, err error, field, message string) {
t.Helper()
var refused *MediaError
if !errors.As(err, &refused) || refused.Field != field || refused.Message != message || refused.Error() != message {
t.Fatalf("error = %v, want MediaError on %s %q", err, field, message)
}
}
t.Run("allowed extensions, key layout and URL", func(t *testing.T) {
for _, tc := range []struct{ folder, name, path string }{
{"photos", "a.jpg", "/journal/photos/a.jpg"},
{"/journal/photos/", "b.JPEG", "/journal/photos/b.JPEG"},
{"", "c.png", "/journal/c.png"},
{"x//y", "d.gif", "/journal/x/y/d.gif"},
{"Journal", "e.webp", "/journal/e.webp"},
{"posts", `C:\uploads\f.png`, "/journal/posts/f.png"},
{"posts", "../../g.png", "/journal/posts/g.png"},
} {
got, err := StoreMedia(ctx, bucket, tc.folder, tc.name, bytes.NewReader(testPNG))
if err != nil {
t.Fatalf("%s/%s: %v", tc.folder, tc.name, err)
}
key := strings.TrimPrefix(tc.path, "/")
if got.Path != tc.path || got.URL != attach.PublicURL(key) {
t.Fatalf("%s/%s = %+v, want path %s", tc.folder, tc.name, got, tc.path)
}
data, err := bucket.ReadAll(ctx, key)
if err != nil || !bytes.Equal(data, testPNG) {
t.Fatalf("stored %s: %v", key, err)
}
}
})
t.Run("a taken name gets the next numbered suffix", func(t *testing.T) {
var paths []string
for i := 0; i < 3; i++ {
got, err := StoreMedia(ctx, bucket, "dup", "same.png", bytes.NewReader(testPNG))
if err != nil {
t.Fatal(err)
}
paths = append(paths, got.Path)
}
if strings.Join(paths, " ") != "/journal/dup/same.png /journal/dup/same-1.png /journal/dup/same-2.png" {
t.Fatalf("paths = %v", paths)
}
})
t.Run("a name with every suffix taken fails instead of overwriting", func(t *testing.T) {
full := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = full.Close() })
keys := []string{"journal/full/a.png"}
for n := 1; n < mediaMaxSuffix; n++ {
keys = append(keys, "journal/full/a-"+strconv.Itoa(n)+".png")
}
for _, key := range keys {
if err := full.WriteAll(ctx, key, []byte("old"), nil); err != nil {
t.Fatal(err)
}
}
_, err := StoreMedia(ctx, full, "full", "a.png", bytes.NewReader(testPNG))
if err == nil || errors.As(err, new(*MediaError)) {
t.Fatalf("error = %v, want an opaque error", err)
}
for _, key := range keys {
if data, _ := full.ReadAll(ctx, key); string(data) != "old" {
t.Fatalf("%s was overwritten", key)
}
}
})
t.Run("other extensions are refused", func(t *testing.T) {
for _, name := range []string{"a.svg", "a.html", "a.bmp", "a", "a.png.exe", ".png.txt"} {
_, err := StoreMedia(ctx, bucket, "", name, bytes.NewReader(testPNG))
mediaErr(t, err, "file", "The file must be an image.")
}
})
t.Run("the size limit is 10240 KiB", func(t *testing.T) {
if MediaMaxBytes != 10240*1024 {
t.Fatalf("MediaMaxBytes = %d", MediaMaxBytes)
}
exact := make([]byte, MediaMaxBytes)
if _, err := StoreMedia(ctx, bucket, "size", "exact.png", bytes.NewReader(exact)); err != nil {
t.Fatalf("exactly MediaMaxBytes: %v", err)
}
_, err := StoreMedia(ctx, bucket, "size", "over.png", bytes.NewReader(make([]byte, MediaMaxBytes+1)))
mediaErr(t, err, "file", "The file may not be greater than 10240 kilobytes.")
if ok, _ := bucket.Exists(ctx, "journal/size/over.png"); ok {
t.Fatal("an oversized file was stored")
}
})
t.Run("the folder rule is checked", func(t *testing.T) {
for _, folder := range []string{"../up", "a..b", "a b", `a\b`, strings.Repeat("a", 101), "a?b"} {
_, err := StoreMedia(ctx, bucket, folder, "a.png", bytes.NewReader(testPNG))
mediaErr(t, err, "folder", "The folder format is invalid.")
}
})
t.Run("a missing bucket and a read failure are opaque errors", func(t *testing.T) {
if _, err := StoreMedia(ctx, nil, "", "a.png", bytes.NewReader(testPNG)); err == nil || errors.As(err, new(*MediaError)) {
t.Fatalf("nil bucket error = %v", err)
}
if _, err := StoreMedia(ctx, bucket, "", "a.png", failingReader{}); err == nil || errors.As(err, new(*MediaError)) || !strings.Contains(err.Error(), "disk on fire") {
t.Fatalf("read failure error = %v", err)
}
})
}