Files
sm-journal-plugin/journal_api_authors_test.go
Jakub Zych 908ade9c78 feat(api): anonymous author profile at /_journal/api/v1/authors/{author}
- Resolve {author} by golem15_bloghub_author_slug, then by login for users with a published post
- Project only id, names, login and author slug from backend_users; payload is id, slug, first_name, last_name
- JSON 404 {"error":"Author not found"} for unknown, soft-deleted and malformed values
- Document the route in README
2026-10-09 13:19:35 +02:00

152 lines
5.5 KiB
Go

package journal_test
import (
"encoding/json"
"net/http"
"slices"
"strings"
"testing"
"time"
"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)
}
}