- blogeditor and mlblogeditor field types (classes.BlogEditorTypes) on the plugin; post content is mlblogeditor with htmlColumn content_html - Decode stores SanitizeHTMLReport output per locale with notices naming what was removed; clean HTML is stored byte for byte, nothing is refused because of markup - Load serves HTML as stored and markdown posts as their content_html - the admin hooks copy written content to content_html per locale without goldmark; untouched content leaves both columns as they are - editor lang keys (en, pl plural notices), tracer smoke test, updated Postgres admin tests
253 lines
9.2 KiB
Go
253 lines
9.2 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 (D-03): an admin save of a post whose
|
|
// content is HTML, as imported posts are, keeps that HTML in content_html
|
|
// instead of goldmark's raw-HTML placeholder.
|
|
func TestPostsAdminResaveKeepsHTML(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
token := env.admin(t,
|
|
controllers.PermissionAccessPosts,
|
|
controllers.PermissionAccessPublish,
|
|
controllers.PermissionAccessOtherPosts,
|
|
)
|
|
const content = "<p>Grzyby rosną w lesie.</p>\n<p>Druga <b>część</b> z <a href=\"https://example.test/zrodlo\">linkiem</a>.</p>"
|
|
body := 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, "pl": content},
|
|
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
|
|
}
|
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, body)
|
|
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)
|
|
}
|
|
|
|
assertHTML := func(stage string) {
|
|
t.Helper()
|
|
var stored models.Post
|
|
if err := env.gdb.First(&stored, created.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.ContentHTML != content {
|
|
t.Fatalf("%s: content_html = %q, want %q", stage, stored.ContentHTML, content)
|
|
}
|
|
if stored.Content != content {
|
|
t.Fatalf("%s: content = %q, want %q", stage, stored.Content, content)
|
|
}
|
|
}
|
|
assertHTML("create")
|
|
|
|
env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, created.ID)), token, body)
|
|
assertHTML("re-save")
|
|
}
|
|
|
|
// 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
|
|
}
|