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

@@ -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())
}
})
}

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)
}
})
}

View 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
}