Files
sm-journal-plugin/posts_admin_smoke_test.go
Jakub Zych a3551f2bdf fix(classes): sanitize content_html with bluemonday instead of regex checks
- FormatHTML sanitizes the goldmark output with a bluemonday UGC policy
  extended only with what goldmark and the imported posts and categories
  use; unsafe markup is stripped, as Winter's Html::clean strips it
- HTML the policy keeps whole is stored byte for byte (token comparison)
- CheckHTML keeps its signature and 422 contract, refusing HTML the
  policy would change
- the three regexes are gone: the CR-01 bypasses are neutralized and
  "Data:" or "metadata:" text is no longer refused (WR-01)
2026-10-09 23:35:23 +02:00

195 lines
7.2 KiB
Go

package journal_test
import (
"encoding/json"
"fmt"
"net/http"
"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, "mlmarkdown") {
t.Fatalf("form schema missing mlmarkdown: %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": "Hello **world**", "pl": "Witaj **świecie**"},
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
})
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)
}
if stored.ContentHTML == "" {
t.Fatal("content_html is empty")
}
if strings.Contains(strings.ToLower(stored.ContentHTML), "<script") {
t.Fatalf("content_html contains script: %s", stored.ContentHTML)
}
if !strings.Contains(stored.ContentHTML, "<strong>") && !strings.Contains(stored.ContentHTML, "<p>") {
t.Fatalf("content_html missing markup: %s", 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 strings.TrimSpace(stored.ContentHTML) != content {
t.Fatalf("%s: content_html = %q, want %q", stage, strings.TrimSpace(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 and WR-01 on the admin write
// path: unsafe markup in content is stripped from content_html, as Winter's
// Html::clean strips it, instead of refusing the save, and a "Data:" label is
// ordinary text. content itself is stored as submitted.
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&#58;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>"
if got := strings.TrimSpace(stored.ContentHTML); got != want {
t.Fatalf("content_html = %q, want %q", got, want)
}
if stored.Content != content {
t.Fatalf("content = %q, want it stored as submitted", stored.Content)
}
}