- element.test.ts: an untouched value survives Source tab, tab buttons, preview and reattach byte for byte with no summer-change - TestBlogEditorLoad: HTML verbatim, markdown as content_html, FormatHTML fallback, nil and empty values - TestPostsAdminResaveKeepsHTML (Docker host): GET then PUT the served values for an HTML and a markdown post, exact bytes, no notices, then a clean edited save in both locales
453 lines
18 KiB
Go
453 lines
18 KiB
Go
package journal_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
|
)
|
|
|
|
func TestPostsFormCompiles(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
token := env.admin(t, controllers.PermissionAccessPosts)
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminPostsPath+"/schema/form"), token, nil)
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `"type":"mlblogeditor"`) {
|
|
t.Fatalf("form schema missing mlblogeditor: %s", body)
|
|
}
|
|
if !strings.Contains(body, "mltext") {
|
|
t.Fatalf("form schema missing mltext: %s", body)
|
|
}
|
|
if !strings.Contains(body, "mltextarea") {
|
|
t.Fatalf("form schema missing mltextarea: %s", body)
|
|
}
|
|
if !strings.Contains(body, `"preset":{"field":"title","type":"slug"}`) {
|
|
t.Fatalf("form schema missing slug preset: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestPostsAdminCreateSmoke(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
privileged := env.admin(t,
|
|
controllers.PermissionAccessPosts,
|
|
controllers.PermissionAccessPublish,
|
|
controllers.PermissionAccessOtherPosts,
|
|
)
|
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), privileged, map[string]any{
|
|
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
|
|
"slug": map[string]any{"en": "hello-admin", "pl": "witaj-admin"},
|
|
"content": map[string]any{"en": "<p>Hello <strong>world</strong></p>", "pl": "<p>Witaj <strong>świecie</strong></p>"},
|
|
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
|
|
})
|
|
if notices := adminNotices(t, rec); notices != nil {
|
|
t.Fatalf("clean create carries meta.notices: %s", rec.Body.String())
|
|
}
|
|
var created struct {
|
|
ID uint `json:"id"`
|
|
}
|
|
envData := adminDecode(t, rec)
|
|
if err := json.Unmarshal(envData.Data, &created); err != nil || created.ID == 0 {
|
|
t.Fatalf("create data %s: %v", rec.Body.String(), err)
|
|
}
|
|
var stored models.Post
|
|
if err := env.gdb.First(&stored, created.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.Title != "Hello" {
|
|
t.Fatalf("host title = %q, want Hello", stored.Title)
|
|
}
|
|
if stored.Slug != "hello-admin" {
|
|
t.Fatalf("host slug = %q, want hello-admin", stored.Slug)
|
|
}
|
|
// D-02: the admin path stores the same HTML in both columns.
|
|
if stored.Content != "<p>Hello <strong>world</strong></p>" || stored.ContentHTML != stored.Content {
|
|
t.Fatalf("content = %q, content_html = %q, want both the submitted HTML", stored.Content, stored.ContentHTML)
|
|
}
|
|
|
|
var plCount int64
|
|
if err := env.gdb.Table("golem15_translate_attributes").
|
|
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(stored.ID), stored.MorphName(), "pl").
|
|
Count(&plCount).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if plCount == 0 {
|
|
t.Fatal("Polish translate attributes were not written")
|
|
}
|
|
|
|
t.Run("publish without access_publish", func(t *testing.T) {
|
|
limited := env.admin(t, controllers.PermissionAccessPosts)
|
|
denied := env.call(t, http.MethodPost, adminAPI(adminPostsPath), limited, map[string]any{
|
|
"title": map[string]any{"en": "Draft", "pl": "Szkic"},
|
|
"slug": map[string]any{"en": "draft-admin", "pl": "szkic-admin"},
|
|
"content": map[string]any{"en": "Draft body", "pl": "Treść szkicu"},
|
|
"published": true,
|
|
})
|
|
if denied.Code != http.StatusForbidden {
|
|
t.Fatalf("publish without access_publish = %d, want 403: %s", denied.Code, denied.Body.String())
|
|
}
|
|
if adminDecode(t, denied).Error.Code != "forbidden" {
|
|
t.Fatalf("error code = %s", denied.Body.String())
|
|
}
|
|
var n int64
|
|
if err := env.gdb.Model(&models.Post{}).Where("slug = ?", "draft-admin").Count(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 0 {
|
|
t.Fatal("published=true persisted without access_publish")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPostsAdminResaveKeepsHTML is the acceptance test of the todo
|
|
// journal-imported-html-admin-resave (Phase 16.2 D-03, D-07) on the real
|
|
// admin API: the administrator opens a post and saves it without touching
|
|
// the editor. The test reads the form values the API serves with GET and
|
|
// PUTs them back unchanged, as the admin SPA does.
|
|
//
|
|
// - An HTML post keeps content and content_html byte for byte, gains no
|
|
// translated content for its untouched empty Polish value, and the
|
|
// response has no meta.notices key.
|
|
// - A markdown post (content markdown, content_html rendered at import, as
|
|
// Płytarium-era imports are) is served as content_html (D-07) and keeps
|
|
// its markdown content and its content_html byte for byte in both
|
|
// locales, even though its content_html holds markup the policy would
|
|
// strip: an untouched value is never decoded.
|
|
// - An edited save of clean HTML in both locales stores equal content and
|
|
// content_html per locale and reports nothing.
|
|
//
|
|
// The harness's default locale is en (host columns); pl is the translation.
|
|
func TestPostsAdminResaveKeepsHTML(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
token := env.admin(t,
|
|
controllers.PermissionAccessPosts,
|
|
controllers.PermissionAccessPublish,
|
|
controllers.PermissionAccessOtherPosts,
|
|
)
|
|
|
|
t.Run("HTML post", func(t *testing.T) {
|
|
const content = "<h2>Wstęp</h2>\n" +
|
|
"<p>Grzyby rosną w lesie.</p>\n" +
|
|
"<p>Druga <b>część</b> z <a href=\"https://example.test/zrodlo\">linkiem</a>.</p>\n" +
|
|
"<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>\n"
|
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
|
|
"title": map[string]any{"en": "Imported", "pl": "Zaimportowany"},
|
|
"slug": map[string]any{"en": "imported-html", "pl": "zaimportowany-html"},
|
|
"content": map[string]any{"en": content},
|
|
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
|
|
})
|
|
if notices := adminNotices(t, rec); notices != nil {
|
|
t.Fatalf("clean create carries meta.notices: %s", rec.Body.String())
|
|
}
|
|
id := adminCreatedID(t, rec)
|
|
assertHostContent(t, env, id, "create", content, content)
|
|
before := translatedAttrs(t, env, id, "pl")
|
|
rowsBefore := translatedRows(t, env, id)
|
|
|
|
served := servedForm(t, env, token, id)
|
|
if got := servedLocales(t, served, "content"); got["en"] != content || got["pl"] != "" {
|
|
t.Fatalf("served content = %#v, want en as stored and an empty pl", got)
|
|
}
|
|
put := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, servedBody(served, "title", "slug", "content", "excerpt"))
|
|
if _, ok := adminMeta(t, put)["notices"]; ok {
|
|
t.Fatalf("untouched save carries meta.notices: %s", put.Body.String())
|
|
}
|
|
assertHostContent(t, env, id, "untouched re-save", content, content)
|
|
after := translatedAttrs(t, env, id, "pl")
|
|
for _, key := range []string{"content", "content_html"} {
|
|
old, had := before[key]
|
|
now, has := after[key]
|
|
if had != has || old != now {
|
|
t.Fatalf("untouched re-save changed pl %s: before %#v (%v), after %#v (%v)", key, old, had, now, has)
|
|
}
|
|
if has {
|
|
t.Fatalf("pl %s written for an untouched empty value: %#v", key, now)
|
|
}
|
|
}
|
|
if rows := translatedRows(t, env, id); rows != rowsBefore {
|
|
t.Fatalf("untouched re-save added translation rows: %d -> %d", rowsBefore, rows)
|
|
}
|
|
|
|
const editedEN = "<p>Edited <em>English</em> text.</p>\n<ol start=\"3\"><li>three</li></ol>\n"
|
|
const editedPL = "<p>Poprawiony <strong>polski</strong> tekst.</p>\n<p><img src=\"https://cdn.example.test/a.jpg\" alt=\"grzyb\"></p>\n"
|
|
editBody := servedBody(served, "title", "slug", "excerpt")
|
|
editBody["content"] = map[string]any{"en": editedEN, "pl": editedPL}
|
|
edit := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, editBody)
|
|
if _, ok := adminMeta(t, edit)["notices"]; ok {
|
|
t.Fatalf("clean edited save carries meta.notices: %s", edit.Body.String())
|
|
}
|
|
assertHostContent(t, env, id, "edited save", editedEN, editedEN)
|
|
pl := translatedAttrs(t, env, id, "pl")
|
|
if pl["content"] != editedPL || pl["content_html"] != pl["content"] {
|
|
t.Fatalf("pl content = %#v, content_html = %#v, want both %q", pl["content"], pl["content_html"], editedPL)
|
|
}
|
|
})
|
|
|
|
t.Run("markdown post", func(t *testing.T) {
|
|
const markdownEN = "## Heading\n\nText with *italics* and **bold**.\n\n- one\n- two\n"
|
|
const markdownPL = "## Nagłówek\n\nTekst z *kursywą* i **pogrubieniem**.\n\n- jeden\n- dwa\n"
|
|
// The content_html an import stored: rendered markdown plus the raw
|
|
// author footer and its style block, which the policy would strip.
|
|
const footer = "<style>.author-footer{color:#555}</style>\n<footer class=\"author-footer\" itemscope=\"\" itemtype=\"https://schema.org/Person\"><p>Autor</p></footer>\n"
|
|
const renderedEN = "<h2>Heading</h2>\n<p>Text with <em>italics</em> and <strong>bold</strong>.</p>\n<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n" + footer
|
|
const renderedPL = "<h2>Nagłówek</h2>\n<p>Tekst z <em>kursywą</em> i <strong>pogrubieniem</strong>.</p>\n<ul>\n<li>jeden</li>\n<li>dwa</li>\n</ul>\n" + footer
|
|
|
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
|
|
"title": map[string]any{"en": "Markdown", "pl": "Markdown PL"},
|
|
"slug": map[string]any{"en": "imported-markdown", "pl": "zaimportowany-markdown"},
|
|
"content": map[string]any{"en": markdownEN, "pl": markdownPL},
|
|
})
|
|
id := adminCreatedID(t, rec)
|
|
assertHostContent(t, env, id, "markdown create", markdownEN, markdownEN)
|
|
// What an import leaves behind: content_html rendered from markdown.
|
|
if err := env.gdb.Model(&models.Post{}).Where("id = ?", id).Update("content_html", renderedEN).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
setTranslatedAttr(t, env, id, "pl", "content_html", renderedPL)
|
|
|
|
served := servedForm(t, env, token, id)
|
|
if got := servedLocales(t, served, "content"); got["en"] != renderedEN || got["pl"] != renderedPL {
|
|
t.Fatalf("served content = %#v, want each locale's content_html (D-07)", got)
|
|
}
|
|
put := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, servedBody(served, "title", "slug", "content", "excerpt"))
|
|
if _, ok := adminMeta(t, put)["notices"]; ok {
|
|
t.Fatalf("untouched markdown save carries meta.notices: %s", put.Body.String())
|
|
}
|
|
assertHostContent(t, env, id, "untouched markdown re-save", markdownEN, renderedEN)
|
|
pl := translatedAttrs(t, env, id, "pl")
|
|
if pl["content"] != markdownPL || pl["content_html"] != renderedPL {
|
|
t.Fatalf("pl content = %#v, content_html = %#v, want the markdown and the imported HTML unchanged", pl["content"], pl["content_html"])
|
|
}
|
|
})
|
|
}
|
|
|
|
// adminCreatedID returns the id of a create response's record.
|
|
func adminCreatedID(t *testing.T, rec *httptest.ResponseRecorder) uint {
|
|
t.Helper()
|
|
var created struct {
|
|
ID uint `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(adminDecode(t, rec).Data, &created); err != nil || created.ID == 0 {
|
|
t.Fatalf("create data %s: %v", rec.Body.String(), err)
|
|
}
|
|
return created.ID
|
|
}
|
|
|
|
// adminMeta returns a response's meta object by key.
|
|
func adminMeta(t *testing.T, rec *httptest.ResponseRecorder) map[string]json.RawMessage {
|
|
t.Helper()
|
|
var body struct {
|
|
Meta map[string]json.RawMessage `json:"meta"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("admin body %q: %v", rec.Body.String(), err)
|
|
}
|
|
return body.Meta
|
|
}
|
|
|
|
// assertHostContent compares the host columns byte for byte (no trimming).
|
|
func assertHostContent(t *testing.T, env *adminEnv, id uint, stage, content, contentHTML string) {
|
|
t.Helper()
|
|
var stored models.Post
|
|
if err := env.gdb.First(&stored, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(stored.ContentHTML, "raw HTML omitted") {
|
|
t.Fatalf("%s: content_html holds the goldmark placeholder: %q", stage, stored.ContentHTML)
|
|
}
|
|
if stored.Content != content {
|
|
t.Fatalf("%s: content = %q, want %q", stage, stored.Content, content)
|
|
}
|
|
if stored.ContentHTML != contentHTML {
|
|
t.Fatalf("%s: content_html = %q, want %q", stage, stored.ContentHTML, contentHTML)
|
|
}
|
|
}
|
|
|
|
// servedForm returns the record data the admin API serves the form with.
|
|
func servedForm(t *testing.T, env *adminEnv, token string, id uint) map[string]json.RawMessage {
|
|
t.Helper()
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, nil)
|
|
var data map[string]json.RawMessage
|
|
if err := json.Unmarshal(adminDecode(t, rec).Data, &data); err != nil {
|
|
t.Fatalf("show data %s: %v", rec.Body.String(), err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
// servedLocales decodes one served locale map.
|
|
func servedLocales(t *testing.T, served map[string]json.RawMessage, field string) map[string]string {
|
|
t.Helper()
|
|
var out map[string]string
|
|
if err := json.Unmarshal(served[field], &out); err != nil {
|
|
t.Fatalf("served %s %s: %v", field, served[field], err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// servedBody is a save body of the named served fields, unchanged.
|
|
func servedBody(served map[string]json.RawMessage, fields ...string) map[string]any {
|
|
out := map[string]any{}
|
|
for _, field := range fields {
|
|
if raw, ok := served[field]; ok {
|
|
out[field] = raw
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// translatedAttrs returns a post's attribute_data in locale, empty when it
|
|
// has no row.
|
|
func translatedAttrs(t *testing.T, env *adminEnv, id uint, locale string) map[string]any {
|
|
t.Helper()
|
|
var raw *string
|
|
if err := env.gdb.Table("golem15_translate_attributes").
|
|
Select("attribute_data").
|
|
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(id), models.Post{}.MorphName(), locale).
|
|
Limit(1).
|
|
Scan(&raw).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := map[string]any{}
|
|
if raw == nil || *raw == "" {
|
|
return out
|
|
}
|
|
if err := json.Unmarshal([]byte(*raw), &out); err != nil {
|
|
t.Fatalf("attribute_data %q: %v", *raw, err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// translatedRows counts a post's translation rows.
|
|
func translatedRows(t *testing.T, env *adminEnv, id uint) int64 {
|
|
t.Helper()
|
|
var n int64
|
|
if err := env.gdb.Table("golem15_translate_attributes").
|
|
Where("model_id = ? AND model_type = ?", fmt.Sprint(id), models.Post{}.MorphName()).
|
|
Count(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// setTranslatedAttr sets one key of a post's attribute_data in locale, as an
|
|
// import does.
|
|
func setTranslatedAttr(t *testing.T, env *adminEnv, id uint, locale, key, value string) {
|
|
t.Helper()
|
|
attrs := translatedAttrs(t, env, id, locale)
|
|
attrs[key] = value
|
|
raw, err := json.Marshal(attrs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res := env.gdb.Table("golem15_translate_attributes").
|
|
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(id), models.Post{}.MorphName(), locale).
|
|
Update("attribute_data", string(raw))
|
|
if res.Error != nil || res.RowsAffected != 1 {
|
|
t.Fatalf("set %s %s: rows=%d err=%v", locale, key, res.RowsAffected, res.Error)
|
|
}
|
|
}
|
|
|
|
// TestPostsAdminSaveSanitizesHTML pins CR-01, WR-01 and Phase 16.2 D-02 and
|
|
// D-10 on the admin write path: unsafe markup in content is stripped, as
|
|
// Winter's Html::clean strips it, instead of refusing the save; content and
|
|
// content_html both hold the sanitized HTML for the host locale (en) and the
|
|
// translation (pl); a "Data:" label is ordinary text; and the response's
|
|
// meta.notices names what was removed for each locale.
|
|
func TestPostsAdminSaveSanitizesHTML(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
token := env.admin(t,
|
|
controllers.PermissionAccessPosts,
|
|
controllers.PermissionAccessPublish,
|
|
controllers.PermissionAccessOtherPosts,
|
|
)
|
|
const content = "<p>Data: 12 maja 2024</p>\n" +
|
|
"<p><img src=\"https://cdn.example.test/a.jpg\" alt=\"a\"onerror=\"alert(1)\"></p>\n" +
|
|
"<p><a href=\"javascript:alert(1)\">x</a></p>\n" +
|
|
"<base href=\"https://evil.test/\">"
|
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
|
|
"title": map[string]any{"en": "Sanitized", "pl": "Oczyszczony"},
|
|
"slug": map[string]any{"en": "sanitized-html", "pl": "oczyszczony-html"},
|
|
"content": map[string]any{"en": content, "pl": content},
|
|
})
|
|
var created struct {
|
|
ID uint `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(adminDecode(t, rec).Data, &created); err != nil || created.ID == 0 {
|
|
t.Fatalf("create data %s: %v", rec.Body.String(), err)
|
|
}
|
|
var stored models.Post
|
|
if err := env.gdb.First(&stored, created.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const want = "<p>Data: 12 maja 2024</p>\n" +
|
|
"<p><img src=\"https://cdn.example.test/a.jpg\" alt=\"a\"></p>\n" +
|
|
"<p>x</p>\n"
|
|
if stored.Content != want || stored.ContentHTML != want {
|
|
t.Fatalf("host content = %q, content_html = %q, want both %q", stored.Content, stored.ContentHTML, want)
|
|
}
|
|
var raw string
|
|
if err := env.gdb.Table("golem15_translate_attributes").
|
|
Select("attribute_data").
|
|
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(stored.ID), stored.MorphName(), "pl").
|
|
Scan(&raw).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var pl map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &pl); err != nil {
|
|
t.Fatalf("attribute_data %q: %v", raw, err)
|
|
}
|
|
if pl["content"] != want || pl["content_html"] != want {
|
|
t.Fatalf("pl content = %#v, content_html = %#v, want both %q", pl["content"], pl["content_html"], want)
|
|
}
|
|
|
|
notices := adminNotices(t, rec)
|
|
for _, locale := range []string{"en", "pl"} {
|
|
var elements, attributes bool
|
|
for _, n := range notices {
|
|
if n.Field != "content" || n.Locale != locale {
|
|
continue
|
|
}
|
|
elements = elements || strings.Contains(n.Message, "2 elements were removed: a, base")
|
|
attributes = attributes || strings.Contains(n.Message, "1 attribute was removed: onerror")
|
|
}
|
|
if !elements || !attributes {
|
|
t.Fatalf("%s notices = %#v, want removed elements a, base and attribute onerror", locale, notices)
|
|
}
|
|
}
|
|
}
|
|
|
|
// adminNotice is one meta.notices entry of an admin save response.
|
|
type adminNotice struct {
|
|
Field string `json:"field"`
|
|
Locale string `json:"locale"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// adminNotices returns a save response's meta.notices, nil when the key is
|
|
// absent.
|
|
func adminNotices(t *testing.T, rec *httptest.ResponseRecorder) []adminNotice {
|
|
t.Helper()
|
|
var body struct {
|
|
Meta map[string]json.RawMessage `json:"meta"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("admin body %q: %v", rec.Body.String(), err)
|
|
}
|
|
raw, ok := body.Meta["notices"]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var notices []adminNotice
|
|
if err := json.Unmarshal(raw, ¬ices); err != nil {
|
|
t.Fatalf("meta.notices %s: %v", raw, err)
|
|
}
|
|
if notices == nil {
|
|
notices = []adminNotice{}
|
|
}
|
|
return notices
|
|
}
|