- add a content textarea (size huge) after description, with en and pl phrases - add content to Category.Fillable - export classes.CheckHTML and run it from FormBeforeCreate and FormBeforeUpdate: unsafe category content answers 422 on content, blank content is stored as NULL
277 lines
8.8 KiB
Go
277 lines
8.8 KiB
Go
package journal_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
|
)
|
|
|
|
// flattenCategoryRows walks the categories tree (data plus children) into one
|
|
// slice of rows.
|
|
func flattenCategoryRows(rows []map[string]any) []map[string]any {
|
|
var out []map[string]any
|
|
for _, row := range rows {
|
|
out = append(out, row)
|
|
if kids, ok := row["children"].([]any); ok {
|
|
nested := make([]map[string]any, 0, len(kids))
|
|
for _, k := range kids {
|
|
if m, ok := k.(map[string]any); ok {
|
|
nested = append(nested, m)
|
|
}
|
|
}
|
|
out = append(out, flattenCategoryRows(nested)...)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func categoryRowsBySlug(t *testing.T, env *adminEnv, path string) map[string]map[string]any {
|
|
t.Helper()
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, path, "", nil)
|
|
var body struct {
|
|
Data []map[string]any `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("categories json: %s err=%v", rec.Body.String(), err)
|
|
}
|
|
out := map[string]map[string]any{}
|
|
for _, row := range flattenCategoryRows(body.Data) {
|
|
slug, _ := row["slug"].(string)
|
|
out[slug] = row
|
|
}
|
|
return out
|
|
}
|
|
|
|
func assertNullContent(t *testing.T, rows map[string]map[string]any, slug string) {
|
|
t.Helper()
|
|
row, ok := rows[slug]
|
|
if !ok {
|
|
t.Fatalf("category %s not listed", slug)
|
|
}
|
|
v, present := row["content"]
|
|
if !present || v != nil {
|
|
t.Fatalf("%s content = %#v (present %v), want JSON null", slug, v, present)
|
|
}
|
|
}
|
|
|
|
func TestJournalCategoryContentAPI(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
html := "<h2>Acme news</h2>\n<p>Pillar copy</p>"
|
|
left, right, depth := 3, 4, 0
|
|
news := models.Category{Name: "News", Slug: "news", Content: &html, NestLeft: &left, NestRight: &right, NestDepth: &depth}
|
|
if err := env.gdb.Create(&news).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
quiet := models.Category{Name: "Quiet", Slug: "quiet"}
|
|
if err := env.gdb.Create(&quiet).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
past := time.Now().UTC().Add(-time.Hour)
|
|
post := models.Post{Title: "Listed", Slug: "listed-content", Content: "x", Published: true, PublishedAt: &past}
|
|
if err := env.gdb.Create(&post).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := env.gdb.Create(&models.PostCategory{PostID: post.ID, CategoryID: news.ID}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
all := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1")
|
|
if got, ok := all["news"]["content"].(string); !ok || got != html {
|
|
t.Fatalf("news content = %#v, want %q", all["news"]["content"], html)
|
|
}
|
|
assertNullContent(t, all, "quiet")
|
|
assertNullContent(t, all, "uncategorized")
|
|
|
|
listed := categoryRowsBySlug(t, env, "/_journal/api/v1/categories")
|
|
if got, ok := listed["news"]["content"].(string); !ok || got != html {
|
|
t.Fatalf("news content without include_empty = %#v, want %q", listed["news"]["content"], html)
|
|
}
|
|
if _, ok := listed["quiet"]; ok {
|
|
t.Fatal("empty category quiet listed without include_empty")
|
|
}
|
|
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_journal/api/v1/posts", "", nil)
|
|
var posts struct {
|
|
Data []map[string]any `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &posts); err != nil {
|
|
t.Fatalf("posts json: %s err=%v", rec.Body.String(), err)
|
|
}
|
|
var found bool
|
|
for _, p := range posts.Data {
|
|
if p["slug"] != "listed-content" {
|
|
continue
|
|
}
|
|
found = true
|
|
cats, ok := p["categories"].([]any)
|
|
if !ok || len(cats) == 0 {
|
|
t.Fatalf("post categories = %#v", p["categories"])
|
|
}
|
|
first, ok := cats[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("post category entry = %#v", cats[0])
|
|
}
|
|
keys := make([]string, 0, len(first))
|
|
for k := range first {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
if want := []string{"id", "name", "slug"}; !reflect.DeepEqual(keys, want) {
|
|
t.Fatalf("post category keys = %v, want %v", keys, want)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("post listed-content missing from /posts: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
const adminCategoriesPath = "/golem15/journal/categories"
|
|
|
|
func TestCategoriesAdminContent(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
token := env.admin(t, controllers.PermissionAccessCategories)
|
|
|
|
schema := adminDecode(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminCategoriesPath+"/schema/form"), token, nil))
|
|
var form struct {
|
|
Fields []struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Size string `json:"size"`
|
|
} `json:"fields"`
|
|
}
|
|
if err := json.Unmarshal(schema.Data, &form); err != nil {
|
|
t.Fatalf("form schema %s: %v", schema.Data, err)
|
|
}
|
|
contentAt := -1
|
|
for i, f := range form.Fields {
|
|
if f.Name != "content" {
|
|
continue
|
|
}
|
|
contentAt = i
|
|
if f.Type != "textarea" || f.Size != "huge" {
|
|
t.Fatalf("content field = %+v, want textarea size huge", f)
|
|
}
|
|
}
|
|
if contentAt < 1 || form.Fields[contentAt-1].Name != "description" {
|
|
t.Fatalf("content field must follow description: %s", schema.Data)
|
|
}
|
|
|
|
safe := "<h2>Acme</h2>\n<p>Pillar <a href=\"https://example.test/a\">copy</a></p>"
|
|
name := map[string]any{"en": "Acme news", "pl": "Wiadomości Acme"}
|
|
slug := map[string]any{"en": "acme-news", "pl": "wiadomosci-acme"}
|
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
|
|
"name": name,
|
|
"slug": slug,
|
|
"content": safe,
|
|
})
|
|
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)
|
|
}
|
|
storedContent := func(t *testing.T) *string {
|
|
t.Helper()
|
|
var cat models.Category
|
|
if err := env.gdb.First(&cat, created.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cat.Content
|
|
}
|
|
if got := storedContent(t); got == nil || *got != safe {
|
|
t.Fatalf("stored content = %#v, want %q", got, safe)
|
|
}
|
|
rows := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1")
|
|
if got, ok := rows["acme-news"]["content"].(string); !ok || got != safe {
|
|
t.Fatalf("API content = %#v, want %q", rows["acme-news"]["content"], safe)
|
|
}
|
|
|
|
assertContentRefused := func(t *testing.T, rec *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("status = %d, want 422: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Details map[string]any `json:"details"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("422 body %s: %v", rec.Body.String(), err)
|
|
}
|
|
if body.Error.Code != "validation_failed" {
|
|
t.Fatalf("error code = %q, want validation_failed", body.Error.Code)
|
|
}
|
|
if _, ok := body.Error.Details["content"]; !ok {
|
|
t.Fatalf("details do not name content: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
t.Run("unsafe create refused", func(t *testing.T) {
|
|
rec := env.call(t, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
|
|
"name": map[string]any{"en": "Unsafe", "pl": "Niebezpieczna"},
|
|
"slug": map[string]any{"en": "unsafe-acme", "pl": "niebezpieczna-acme"},
|
|
"content": "<p onclick=\"steal()\">x</p>",
|
|
})
|
|
assertContentRefused(t, rec)
|
|
var n int64
|
|
if err := env.gdb.Model(&models.Category{}).Where("slug = ?", "unsafe-acme").Count(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 0 {
|
|
t.Fatalf("unsafe-acme rows = %d, want 0", n)
|
|
}
|
|
})
|
|
|
|
itemPath := adminAPI(adminCategoriesPath + "/" + strconv.FormatUint(uint64(created.ID), 10))
|
|
|
|
t.Run("unsafe update refused", func(t *testing.T) {
|
|
rec := env.call(t, http.MethodPut, itemPath, token, map[string]any{
|
|
"name": name,
|
|
"slug": slug,
|
|
"content": "<a href=\"javascript:alert(1)\">x</a>",
|
|
})
|
|
assertContentRefused(t, rec)
|
|
if got := storedContent(t); got == nil || *got != safe {
|
|
t.Fatalf("stored content after refused update = %#v, want %q", got, safe)
|
|
}
|
|
})
|
|
|
|
t.Run("blank update stores null", func(t *testing.T) {
|
|
rec := env.call(t, http.MethodPut, itemPath, token, map[string]any{
|
|
"name": name,
|
|
"slug": slug,
|
|
"content": " ",
|
|
})
|
|
if rec.Code < 200 || rec.Code > 299 {
|
|
t.Fatalf("blank update = %d, want 2xx: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := storedContent(t); got != nil {
|
|
t.Fatalf("stored content = %q, want NULL", *got)
|
|
}
|
|
rows := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1")
|
|
assertNullContent(t, rows, "acme-news")
|
|
})
|
|
|
|
t.Run("posts-only token forbidden", func(t *testing.T) {
|
|
postsOnly := env.admin(t, controllers.PermissionAccessPosts)
|
|
rec := env.call(t, http.MethodPost, adminAPI(adminCategoriesPath), postsOnly, map[string]any{
|
|
"name": map[string]any{"en": "Denied", "pl": "Odmowa"},
|
|
"slug": map[string]any{"en": "denied-acme", "pl": "odmowa-acme"},
|
|
"content": safe,
|
|
})
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("posts-only create = %d, want 403: %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
}
|