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 }