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: "
body " + slug + "
", 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") }