Files
sm-journal-plugin/journal_api_writes_test.go
Jakub Zych d09edde080 test(15-04): complete PHPUnit map, migrations, FormatHTML, and search-gate tests
Named JOURNAL-001/002 fillable, FormatHTML unsafe tags, rollback/remigrate, RSS/categories/tags/media, and TestSearchGateOff with zero Typesense HTTP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 19:18:53 +02:00

312 lines
11 KiB
Go

package journal_test
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
func (e *adminEnv) lastUserID(t *testing.T) uint {
t.Helper()
var id uint
if err := e.gdb.Raw(`SELECT id FROM backend_users ORDER BY id DESC LIMIT 1`).Scan(&id).Error; err != nil || id == 0 {
t.Fatalf("last backend user id: id=%d err=%v", id, err)
}
return id
}
func phpErrorString(t *testing.T, recBody []byte) (string, map[string]any) {
t.Helper()
var body map[string]any
if err := json.Unmarshal(recBody, &body); err != nil {
t.Fatalf("json: %s err=%v", recBody, err)
}
return fmt.Sprint(body["error"]), body
}
func TestJournalWriteUnauthenticated(t *testing.T) {
env := newPostsEnv(t)
rec := env.call(t, http.MethodPost, "/_journal/api/v1/posts", "", map[string]any{
"title": "Nope",
"content": "Body",
})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("POST without Bearer = %d, want 401: %s", rec.Code, rec.Body.String())
}
msg, body := phpErrorString(t, rec.Body.Bytes())
if msg != "Authentication required" {
t.Fatalf("error = %#v, want Authentication required; body=%s", body["error"], rec.Body.String())
}
if _, isObj := body["error"].(map[string]any); isObj {
t.Fatalf("401 used cabana admin envelope: %s", rec.Body.String())
}
if _, hasData := body["data"]; hasData {
t.Fatalf("401 must not include data: %s", rec.Body.String())
}
front, _, err := bouncer.MintAudience(adminJWTSecret, "1", "https://app.example.test/login", time.Hour, bouncer.AudienceUser)
if err != nil {
t.Fatal(err)
}
denied := env.call(t, http.MethodPost, "/_journal/api/v1/posts", front, map[string]any{
"title": "Frontend",
"content": "Should not write",
})
if denied.Code != http.StatusUnauthorized {
t.Fatalf("frontend-audience POST = %d, want 401: %s", denied.Code, denied.Body.String())
}
if got, _ := phpErrorString(t, denied.Body.Bytes()); got != "Authentication required" {
t.Fatalf("frontend-audience error = %q", got)
}
limited := env.admin(t, controllers.PermissionAccessPosts)
pubDenied := env.call(t, http.MethodPost, "/_journal/api/v1/posts", limited, map[string]any{
"title": "Draft publish",
"content": "Cannot publish",
"published": true,
})
if pubDenied.Code != http.StatusForbidden {
t.Fatalf("publish without access_publish = %d, want 403: %s", pubDenied.Code, pubDenied.Body.String())
}
if got, _ := phpErrorString(t, pubDenied.Body.Bytes()); got != "You do not have permission to publish posts" {
t.Fatalf("publish error = %q body=%s", got, pubDenied.Body.String())
}
okTok := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish)
created := env.call(t, http.MethodPost, "/_journal/api/v1/posts", okTok, map[string]any{
"title": "Created post",
"content": "Hello **world**",
})
if created.Code != http.StatusCreated {
t.Fatalf("authenticated create = %d, want 201: %s", created.Code, created.Body.String())
}
}
func TestJournal005DraftShow(t *testing.T) {
env := newPostsEnv(t)
authorTok := env.admin(t, controllers.PermissionAccessPosts)
authorID := env.lastUserID(t)
otherTok := env.admin(t, controllers.PermissionAccessPosts)
privTok := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
draft := models.Post{
Title: "Draft for JOURNAL-005",
Slug: "draft-journal-005",
Content: "draft content",
Published: false,
UserID: &authorID,
}
if err := env.gdb.Create(&draft).Error; err != nil {
t.Fatal(err)
}
anon := env.call(t, http.MethodGet, "/_journal/api/v1/posts/draft-journal-005", "", nil)
if anon.Code != http.StatusNotFound {
t.Fatalf("anonymous draft show = %d, want 404: %s", anon.Code, anon.Body.String())
}
_, anonBody := phpErrorString(t, anon.Body.Bytes())
if _, has := anonBody["data"]; has {
t.Fatalf("JOURNAL-005: 404 must not include data: %s", anon.Body.String())
}
stranger := env.call(t, http.MethodGet, "/_journal/api/v1/posts/draft-journal-005", otherTok, nil)
if stranger.Code != http.StatusNotFound {
t.Fatalf("stranger draft show = %d, want 404: %s", stranger.Code, stranger.Body.String())
}
_, strangerBody := phpErrorString(t, stranger.Body.Bytes())
if _, has := strangerBody["data"]; has {
t.Fatalf("JOURNAL-005 stranger 404 must not include data: %s", stranger.Body.String())
}
owner := env.call(t, http.MethodGet, "/_journal/api/v1/posts/draft-journal-005", authorTok, nil)
if owner.Code != http.StatusOK {
t.Fatalf("owner draft show = %d, want 200: %s", owner.Code, owner.Body.String())
}
var ownerBody struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(owner.Body.Bytes(), &ownerBody); err != nil || ownerBody.Data.ID != draft.ID {
t.Fatalf("owner payload: %s err=%v", owner.Body.String(), err)
}
byID := env.call(t, http.MethodGet, "/_journal/api/v1/posts/"+strconv.FormatUint(uint64(draft.ID), 10), authorTok, nil)
if byID.Code != http.StatusOK {
t.Fatalf("owner numeric id show = %d, want 200: %s", byID.Code, byID.Body.String())
}
priv := env.call(t, http.MethodGet, "/_journal/api/v1/posts/draft-journal-005", privTok, nil)
if priv.Code != http.StatusOK {
t.Fatalf("access_other_posts draft show = %d, want 200: %s", priv.Code, priv.Body.String())
}
}
func TestJournalPublicCategories(t *testing.T) {
env := newPostsEnv(t)
left, right, depth := 1, 2, 0
cat := models.Category{Name: "Essays", Slug: "essays", NestLeft: &left, NestRight: &right, NestDepth: &depth}
if err := env.gdb.Create(&cat).Error; err != nil {
t.Fatal(err)
}
past := time.Now().UTC().Add(-time.Hour)
post := models.Post{Title: "Listed", Slug: "listed-cat", 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: cat.ID}).Error; err != nil {
t.Fatal(err)
}
rec := env.call(t, http.MethodGet, "/_journal/api/v1/categories", "", nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET categories = %d, want 200: %s", rec.Code, rec.Body.String())
}
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)
}
if len(body.Data) == 0 {
t.Fatalf("categories data empty: %s", rec.Body.String())
}
row := body.Data[0]
for _, key := range []string{"id", "name", "slug", "description", "parent_id", "nest_depth", "post_count"} {
if _, ok := row[key]; !ok {
t.Fatalf("categories row missing %s: %s", key, rec.Body.String())
}
}
empty := models.Category{Name: "Empty nest", Slug: "empty-nest"}
if err := env.gdb.Create(&empty).Error; err != nil {
t.Fatal(err)
}
hidden := env.call(t, http.MethodGet, "/_journal/api/v1/categories", "", nil)
if strings.Contains(hidden.Body.String(), "empty-nest") {
t.Fatalf("empty category listed without include_empty: %s", hidden.Body.String())
}
shown := env.call(t, http.MethodGet, "/_journal/api/v1/categories?include_empty=1", "", nil)
if shown.Code != http.StatusOK {
t.Fatalf("include_empty = %d: %s", shown.Code, shown.Body.String())
}
if !strings.Contains(shown.Body.String(), "empty-nest") {
t.Fatalf("include_empty=1 omitted empty category: %s", shown.Body.String())
}
}
func TestJournalPublicTags(t *testing.T) {
env := newPostsEnv(t)
if err := env.gdb.Create(&models.Tag{Name: "Zebra", Slug: "zebra-tag"}).Error; err != nil {
t.Fatal(err)
}
tag := models.Tag{Name: "Field notes", Slug: "field-notes"}
if err := env.gdb.Create(&tag).Error; err != nil {
t.Fatal(err)
}
past := time.Now().UTC().Add(-time.Hour)
post := models.Post{Title: "Tagged", Slug: "tagged-row", Content: "x", Published: true, PublishedAt: &past}
if err := env.gdb.Create(&post).Error; err != nil {
t.Fatal(err)
}
if err := env.gdb.Create(&models.PostTag{PostID: post.ID, TagID: tag.ID}).Error; err != nil {
t.Fatal(err)
}
rec := env.call(t, http.MethodGet, "/_journal/api/v1/tags", "", nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET tags = %d, want 200: %s", rec.Code, rec.Body.String())
}
var body struct {
Data []map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("tags json: %s err=%v", rec.Body.String(), err)
}
if len(body.Data) == 0 {
t.Fatalf("tags data empty: %s", rec.Body.String())
}
row := body.Data[0]
for _, key := range []string{"id", "name", "slug", "post_count"} {
if _, ok := row[key]; !ok {
t.Fatalf("tags row missing %s: %s", key, rec.Body.String())
}
}
names := make([]string, 0, len(body.Data))
for _, item := range body.Data {
names = append(names, fmt.Sprint(item["name"]))
}
joined := strings.Join(names, ",")
if !strings.Contains(joined, "Field notes") || !strings.Contains(joined, "Zebra") {
t.Fatalf("tags missing expected names: %s", rec.Body.String())
}
fieldIdx, zebraIdx := -1, -1
for i, name := range names {
if name == "Field notes" {
fieldIdx = i
}
if name == "Zebra" {
zebraIdx = i
}
}
if fieldIdx < 0 || zebraIdx < 0 || fieldIdx > zebraIdx {
t.Fatalf("tags not ordered by name: %v", names)
}
}
func TestJournalFeaturedImageUnauthenticated(t *testing.T) {
env := newPostsEnv(t)
authorTok := env.admin(t, controllers.PermissionAccessPosts)
authorID := env.lastUserID(t)
otherTok := env.admin(t, controllers.PermissionAccessPosts)
post := models.Post{Title: "Has images", Slug: "has-images", Content: "x", Published: false, UserID: &authorID}
if err := env.gdb.Create(&post).Error; err != nil {
t.Fatal(err)
}
path := fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", post.ID)
del := fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images/%d", post.ID, 1)
anonPost := env.call(t, http.MethodPost, path, "", map[string]any{})
if anonPost.Code != http.StatusUnauthorized {
t.Fatalf("POST featured-images without Bearer = %d, want 401: %s", anonPost.Code, anonPost.Body.String())
}
if got, _ := phpErrorString(t, anonPost.Body.Bytes()); got != "Authentication required" {
t.Fatalf("POST featured error = %q body=%s", got, anonPost.Body.String())
}
anonDel := env.call(t, http.MethodDelete, del, "", nil)
if anonDel.Code != http.StatusUnauthorized {
t.Fatalf("DELETE featured-images without Bearer = %d, want 401: %s", anonDel.Code, anonDel.Body.String())
}
if got, _ := phpErrorString(t, anonDel.Body.Bytes()); got != "Authentication required" {
t.Fatalf("DELETE featured error = %q body=%s", got, anonDel.Body.String())
}
otherPost := env.call(t, http.MethodPost, path, otherTok, map[string]any{})
if otherPost.Code != http.StatusForbidden {
t.Fatalf("POST featured as non-editor = %d, want 403: %s", otherPost.Code, otherPost.Body.String())
}
if got, _ := phpErrorString(t, otherPost.Body.Bytes()); got != "You do not have permission to edit this post" {
t.Fatalf("POST featured 403 = %q body=%s", got, otherPost.Body.String())
}
otherDel := env.call(t, http.MethodDelete, del, otherTok, nil)
if otherDel.Code != http.StatusForbidden {
t.Fatalf("DELETE featured as non-editor = %d, want 403: %s", otherDel.Code, otherDel.Body.String())
}
if got, _ := phpErrorString(t, otherDel.Body.Bytes()); got != "You do not have permission to edit this post" {
t.Fatalf("DELETE featured 403 = %q body=%s", got, otherDel.Body.String())
}
_ = authorTok
}