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:
263
categories_admin_editor_test.go
Normal file
263
categories_admin_editor_test.go
Normal file
@@ -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 := "<h2>Acme</h2>\n<p>Pillar <a href=\"https://example.test/a\">copy</a></p>\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<!-- imported -->\n<style>.x{color:red}</style>\n<p onclick=\"x()\">Kept as stored</p>\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 := "<p>Edited <strong>copy</strong></p>\n<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>\n"
|
||||
rec := put(t, edited)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := adminMeta(t, rec)["notices"]; ok {
|
||||
t.Fatalf("clean edit carries meta.notices: %s", rec.Body.String())
|
||||
}
|
||||
if got := stored(t); got == nil || *got != edited {
|
||||
t.Fatalf("stored = %#v, want %q", got, edited)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unsafe edit is stripped and reported, not refused", func(t *testing.T) {
|
||||
const unsafe = "<p>a</p><style>p{}</style><!-- c --><p onclick=\"x()\">b</p>"
|
||||
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("<!DOCTYPE html><script>alert(1)</script>"))
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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> & <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},
|
||||
{"<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 := "<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 & chips <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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
197
controllers/posts_editor_test.go
Normal file
197
controllers/posts_editor_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestPostsEditorHooks pins the posts controller's Form hooks around the
|
||||
// Blog Editor (Phase 16.2 D-02, D-03): the default-locale content write is
|
||||
// copied to content_html before the save, each written non-default locale's
|
||||
// content is written as that locale's content_html after it, and content the
|
||||
// save does not write is never touched.
|
||||
func TestPostsEditorHooks(t *testing.T) {
|
||||
withWriter := func(t *testing.T) (postsAdminController, *recordingWriter) {
|
||||
t.Helper()
|
||||
writer := &recordingWriter{}
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish[cabana.TranslationWriter](writer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return postsAdminController{app: func() *backpack.App { return app }}, writer
|
||||
}
|
||||
principal := func() context.Context {
|
||||
return bouncer.WithUser(context.Background(), editorPrincipal())
|
||||
}
|
||||
writes := func(ctx context.Context, list ...cabana.FieldWrite) context.Context {
|
||||
return cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{"content": list})
|
||||
}
|
||||
|
||||
type hook func(postsAdminController, context.Context, any) error
|
||||
before := map[string]hook{
|
||||
"create": postsAdminController.FormBeforeCreate,
|
||||
"update": postsAdminController.FormBeforeUpdate,
|
||||
}
|
||||
after := map[string]hook{
|
||||
"create": postsAdminController.FormAfterCreate,
|
||||
"update": postsAdminController.FormAfterUpdate,
|
||||
}
|
||||
|
||||
for _, op := range []string{"create", "update"} {
|
||||
t.Run(op+": default and non-default writes", func(t *testing.T) {
|
||||
ctl, writer := withWriter(t)
|
||||
ctx := writes(principal(),
|
||||
cabana.FieldWrite{Locale: "pl", Default: true, Value: "<p>PL</p>"},
|
||||
cabana.FieldWrite{Locale: "en", Value: "<p>EN</p>"},
|
||||
cabana.FieldWrite{Locale: "de", Value: "<p>DE</p>"},
|
||||
)
|
||||
post := &models.Post{Content: "<p>PL</p>", ContentHTML: "old"}
|
||||
if err := before[op](ctl, ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if post.ContentHTML != "<p>PL</p>" {
|
||||
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", "<p>EN</p>"}, {"content_html", "de", "<p>DE</p>"}}
|
||||
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: "<p>PL</p>"})
|
||||
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 != "<p>PL</p>" || 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: "<p>EN</p>"})
|
||||
post := &models.Post{Content: "markdown", ContentHTML: "<p>imported</p>"}
|
||||
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 != "<p>imported</p>" {
|
||||
t.Fatalf("ContentHTML = %q, want the untouched default kept", post.ContentHTML)
|
||||
}
|
||||
if len(writer.writes) != 1 || writer.writes[0] != (recordedWrite{"content_html", "en", "<p>EN</p>"}) {
|
||||
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: "<p>imported</p>"}
|
||||
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 != "<p>imported</p>" || 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: "<p>EN</p>"})
|
||||
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: "<p>EN</p>"})
|
||||
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: "<p>EN</p>"})
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user