- AuthorPosts reuses the /posts published predicate, per_page clamp, sort allow-list and row serializer - Bearer tokens are ignored; drafts and scheduled posts never appear - Both author routes share one resolve-or-respond helper for identical 404/500 paths - Document query keys, envelope and 404 in README
286 lines
10 KiB
Go
286 lines
10 KiB
Go
package journal_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
|
)
|
|
|
|
const (
|
|
authorSecretHash = "$2y$10$authorSecretMarker"
|
|
authorPersistCode = "persist-marker"
|
|
authorAPIPrefix = "/_journal/api/v1/authors/"
|
|
authorNotFoundText = "Author not found"
|
|
)
|
|
|
|
// insertBackendUser writes one backend_users row with private columns set to
|
|
// marker values, so a test can prove they never reach a public payload.
|
|
func insertBackendUser(t *testing.T, env *adminEnv, first, last, login, slug string, deleted bool) uint {
|
|
t.Helper()
|
|
var id uint
|
|
if err := env.gdb.Raw(`INSERT INTO backend_users
|
|
(first_name, last_name, login, email, password, persist_code, is_activated, golem15_bloghub_author_slug, deleted_at, created_at, updated_at)
|
|
VALUES (NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?, ?, TRUE, NULLIF(?, ''), CASE WHEN ? THEN NOW() ELSE NULL END, NOW(), NOW())
|
|
RETURNING id`,
|
|
first, last, login, login+"@example.test", authorSecretHash, authorPersistCode, slug, deleted,
|
|
).Scan(&id).Error; err != nil || id == 0 {
|
|
t.Fatalf("insert backend user %s: id=%d err=%v", login, id, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// insertAuthorPost creates one post owned by userID. at is published_at.
|
|
func insertAuthorPost(t *testing.T, env *adminEnv, userID uint, slug string, published bool, at *time.Time) {
|
|
t.Helper()
|
|
uid := userID
|
|
post := models.Post{
|
|
UserID: &uid,
|
|
Title: "Post " + slug,
|
|
Slug: slug,
|
|
Content: "body " + slug,
|
|
ContentHTML: "<p>body " + slug + "</p>",
|
|
Published: published,
|
|
PublishedAt: at,
|
|
}
|
|
if err := env.gdb.Create(&post).Error; err != nil {
|
|
t.Fatalf("insert post %s: %v", slug, err)
|
|
}
|
|
}
|
|
|
|
func authorProfileOK(t *testing.T, env *adminEnv, param string) (map[string]any, string) {
|
|
t.Helper()
|
|
rec := env.call(t, http.MethodGet, authorAPIPrefix+param, "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET authors/%s = %d, want 200: %s", param, rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data == nil {
|
|
t.Fatalf("author body %s: err=%v", rec.Body.String(), err)
|
|
}
|
|
keys := make([]string, 0, len(body.Data))
|
|
for k := range body.Data {
|
|
keys = append(keys, k)
|
|
}
|
|
slices.Sort(keys)
|
|
if want := []string{"first_name", "id", "last_name", "slug"}; !slices.Equal(keys, want) {
|
|
t.Fatalf("author keys = %v, want %v", keys, want)
|
|
}
|
|
return body.Data, rec.Body.String()
|
|
}
|
|
|
|
func expectAuthorNotFound(t *testing.T, env *adminEnv, path string) {
|
|
t.Helper()
|
|
rec := env.call(t, http.MethodGet, path, "", nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("GET %s = %d, want 404: %s", path, rec.Code, rec.Body.String())
|
|
}
|
|
msg, body := phpErrorString(t, rec.Body.Bytes())
|
|
if msg != authorNotFoundText || len(body) != 1 {
|
|
t.Fatalf("GET %s body = %s, want only {\"error\":%q}", path, rec.Body.String(), authorNotFoundText)
|
|
}
|
|
}
|
|
|
|
func TestJournalAuthorProfile(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
now := time.Now().UTC()
|
|
past := now.Add(-time.Hour)
|
|
future := now.Add(time.Hour)
|
|
|
|
janeID := insertBackendUser(t, env, "Jane", "Doe", "jdoe", "jane-doe", false)
|
|
data, raw := authorProfileOK(t, env, "jane-doe")
|
|
if data["slug"] != "jane-doe" || data["first_name"] != "Jane" || data["last_name"] != "Doe" {
|
|
t.Fatalf("jane-doe profile = %v", data)
|
|
}
|
|
if id, _ := data["id"].(float64); uint(id) != janeID {
|
|
t.Fatalf("jane-doe id = %v, want %d", data["id"], janeID)
|
|
}
|
|
for _, secret := range []string{"jdoe@example.test", "authorSecretMarker", authorPersistCode, `"jdoe"`} {
|
|
if strings.Contains(raw, secret) {
|
|
t.Fatalf("author body leaks %q: %s", secret, raw)
|
|
}
|
|
}
|
|
|
|
// A slugged author's login resolves once they have a published post; the
|
|
// payload names the slug as the canonical identifier.
|
|
expectAuthorNotFound(t, env, authorAPIPrefix+"jdoe")
|
|
insertAuthorPost(t, env, janeID, "jane-first", true, &past)
|
|
data, raw = authorProfileOK(t, env, "jdoe")
|
|
if data["slug"] != "jane-doe" {
|
|
t.Fatalf("jdoe canonical slug = %v, want jane-doe", data["slug"])
|
|
}
|
|
if strings.Contains(raw, `"jdoe"`) {
|
|
t.Fatalf("login fallback body leaks login: %s", raw)
|
|
}
|
|
|
|
ewaID := insertBackendUser(t, env, "", "", "ewa", "", false)
|
|
insertAuthorPost(t, env, ewaID, "ewa-first", true, &past)
|
|
data, _ = authorProfileOK(t, env, "ewa")
|
|
if data["slug"] != "ewa" || data["first_name"] != "" || data["last_name"] != "" {
|
|
t.Fatalf("ewa profile = %v", data)
|
|
}
|
|
|
|
// Enumeration guard: a login with only a draft and a scheduled post is 404.
|
|
carolID := insertBackendUser(t, env, "Carol", "Smith", "carol", "", false)
|
|
insertAuthorPost(t, env, carolID, "carol-draft", false, nil)
|
|
insertAuthorPost(t, env, carolID, "carol-future", true, &future)
|
|
expectAuthorNotFound(t, env, authorAPIPrefix+"carol")
|
|
|
|
// Soft-deleted users never resolve, by slug or by login.
|
|
goneID := insertBackendUser(t, env, "Gone", "Author", "gone", "gone-author", true)
|
|
insertAuthorPost(t, env, goneID, "gone-first", true, &past)
|
|
expectAuthorNotFound(t, env, authorAPIPrefix+"gone-author")
|
|
expectAuthorNotFound(t, env, authorAPIPrefix+"gone")
|
|
|
|
expectAuthorNotFound(t, env, authorAPIPrefix+"nobody")
|
|
expectAuthorNotFound(t, env, authorAPIPrefix+strings.Repeat("a", 300))
|
|
expectAuthorNotFound(t, env, authorAPIPrefix+"a%09b")
|
|
|
|
// A slug is an explicit opt-in: it resolves with no posts at all.
|
|
insertBackendUser(t, env, "Fresh", "Author", "fresh", "fresh-author", false)
|
|
data, _ = authorProfileOK(t, env, "fresh-author")
|
|
if data["slug"] != "fresh-author" || data["first_name"] != "Fresh" {
|
|
t.Fatalf("fresh-author profile = %v", data)
|
|
}
|
|
}
|
|
|
|
type authorPostsPage struct {
|
|
Data []map[string]any `json:"data"`
|
|
Meta struct {
|
|
CurrentPage int `json:"current_page"`
|
|
LastPage int `json:"last_page"`
|
|
PerPage int `json:"per_page"`
|
|
Total int64 `json:"total"`
|
|
} `json:"meta"`
|
|
}
|
|
|
|
func (p authorPostsPage) slugs() []string {
|
|
out := make([]string, 0, len(p.Data))
|
|
for _, row := range p.Data {
|
|
slug, _ := row["slug"].(string)
|
|
out = append(out, slug)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func getAuthorPosts(t *testing.T, env *adminEnv, path, token string) authorPostsPage {
|
|
t.Helper()
|
|
rec := env.call(t, http.MethodGet, path, token, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET %s = %d, want 200: %s", path, rec.Code, rec.Body.String())
|
|
}
|
|
var page authorPostsPage
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
|
|
t.Fatalf("GET %s body %s: %v", path, rec.Body.String(), err)
|
|
}
|
|
if page.Data == nil {
|
|
t.Fatalf("GET %s: data is not an array: %s", path, rec.Body.String())
|
|
}
|
|
return page
|
|
}
|
|
|
|
func sortedKeys(m map[string]any) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
slices.Sort(keys)
|
|
return keys
|
|
}
|
|
|
|
func expectMeta(t *testing.T, label string, p authorPostsPage, current, last, perPage int, total int64) {
|
|
t.Helper()
|
|
m := p.Meta
|
|
if m.CurrentPage != current || m.LastPage != last || m.PerPage != perPage || m.Total != total {
|
|
t.Fatalf("%s meta = %+v, want {current_page:%d last_page:%d per_page:%d total:%d}", label, m, current, last, perPage, total)
|
|
}
|
|
}
|
|
|
|
func TestJournalAuthorPosts(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
now := time.Now().UTC()
|
|
at := func(d time.Duration) *time.Time { v := now.Add(d); return &v }
|
|
|
|
janeID := insertBackendUser(t, env, "Jane", "Doe", "jdoe", "jane-doe", false)
|
|
insertAuthorPost(t, env, janeID, "a1", true, at(-3*time.Hour))
|
|
insertAuthorPost(t, env, janeID, "a2", true, at(-2*time.Hour))
|
|
insertAuthorPost(t, env, janeID, "a3", true, at(-time.Hour))
|
|
insertAuthorPost(t, env, janeID, "a-draft", false, at(-30*time.Minute))
|
|
insertAuthorPost(t, env, janeID, "a-future", true, at(time.Hour))
|
|
bobID := insertBackendUser(t, env, "Bob", "Brown", "bob", "", false)
|
|
insertAuthorPost(t, env, bobID, "b1", true, at(-90*time.Minute))
|
|
carolID := insertBackendUser(t, env, "Carol", "Smith", "carol", "", false)
|
|
insertAuthorPost(t, env, carolID, "carol-draft", false, nil)
|
|
insertBackendUser(t, env, "Fresh", "Author", "fresh", "fresh-author", false)
|
|
|
|
base := authorAPIPrefix + "jane-doe/posts"
|
|
page := getAuthorPosts(t, env, base, "")
|
|
if got := page.slugs(); !slices.Equal(got, []string{"a3", "a2", "a1"}) {
|
|
t.Fatalf("default order = %v, want [a3 a2 a1]", got)
|
|
}
|
|
expectMeta(t, "default", page, 1, 1, 9, 3)
|
|
|
|
// Each row has the GET /posts row shape.
|
|
all := getAuthorPosts(t, env, "/_journal/api/v1/posts", "")
|
|
var listRow map[string]any
|
|
for _, row := range all.Data {
|
|
if row["slug"] == "a3" {
|
|
listRow = row
|
|
}
|
|
}
|
|
if listRow == nil {
|
|
t.Fatalf("a3 missing from GET /posts: %v", all.slugs())
|
|
}
|
|
if got, want := sortedKeys(page.Data[0]), sortedKeys(listRow); !slices.Equal(got, want) {
|
|
t.Fatalf("author row keys = %v, want /posts row keys %v", got, want)
|
|
}
|
|
author, _ := page.Data[0]["author"].(map[string]any)
|
|
if author == nil || author["login"] != "jdoe" {
|
|
t.Fatalf("row author = %v, want login jdoe", page.Data[0]["author"])
|
|
}
|
|
|
|
page = getAuthorPosts(t, env, base+"?per_page=2&page=2", "")
|
|
if got := page.slugs(); !slices.Equal(got, []string{"a1"}) {
|
|
t.Fatalf("page 2 = %v, want [a1]", got)
|
|
}
|
|
expectMeta(t, "page 2", page, 2, 2, 2, 3)
|
|
|
|
page = getAuthorPosts(t, env, base+"?per_page=100", "")
|
|
expectMeta(t, "per_page=100", page, 1, 1, 30, 3)
|
|
|
|
page = getAuthorPosts(t, env, base+"?page=abc", "")
|
|
expectMeta(t, "page=abc", page, 1, 1, 9, 3)
|
|
|
|
page = getAuthorPosts(t, env, base+"?sort=published_at+asc", "")
|
|
if got := page.slugs(); !slices.Equal(got, []string{"a1", "a2", "a3"}) {
|
|
t.Fatalf("asc order = %v, want [a1 a2 a3]", got)
|
|
}
|
|
|
|
// An editor token is ignored: drafts and scheduled posts stay hidden.
|
|
token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
|
|
page = getAuthorPosts(t, env, base+"?published=0", token)
|
|
if got := page.slugs(); !slices.Equal(got, []string{"a3", "a2", "a1"}) {
|
|
t.Fatalf("editor list = %v, want published only [a3 a2 a1]", got)
|
|
}
|
|
expectMeta(t, "editor", page, 1, 1, 9, 3)
|
|
|
|
page = getAuthorPosts(t, env, authorAPIPrefix+"jdoe/posts", "")
|
|
expectMeta(t, "login", page, 1, 1, 9, 3)
|
|
|
|
page = getAuthorPosts(t, env, authorAPIPrefix+"fresh-author/posts", "")
|
|
if len(page.Data) != 0 {
|
|
t.Fatalf("fresh-author rows = %v, want none", page.slugs())
|
|
}
|
|
expectMeta(t, "fresh-author", page, 1, 1, 9, 0)
|
|
|
|
expectAuthorNotFound(t, env, authorAPIPrefix+"nobody/posts")
|
|
expectAuthorNotFound(t, env, authorAPIPrefix+"carol/posts")
|
|
}
|