- Expand Postgres smoke for journal:read, unrelated probe scope, drafts, featured images, revoke and code-declared developer roles - Document scopes, ResolveBackend reversal of Phase 15 D-15, and throttle Co-authored-by: Cursor <cursoragent@cursor.com>
283 lines
10 KiB
Go
283 lines
10 KiB
Go
package journal_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
)
|
|
|
|
// probeTokenPlugin registers a non-Journal scope so expansion tests can mint a
|
|
// token that authenticates but fails the golem15.journal.* cover check.
|
|
type probeTokenPlugin struct{}
|
|
|
|
func init() { party.Register(&probeTokenPlugin{}) }
|
|
|
|
func (p *probeTokenPlugin) ID() string { return "acme.probe" }
|
|
func (p *probeTokenPlugin) Requires() []string { return nil }
|
|
func (p *probeTokenPlugin) Register(app *backpack.App) error { return nil }
|
|
func (p *probeTokenPlugin) Boot(app *backpack.App) error { return nil }
|
|
func (p *probeTokenPlugin) Permissions() []pact.Permission {
|
|
return []pact.Permission{{
|
|
Code: "acme.probe.access",
|
|
Label: "Probe access",
|
|
Roles: []string{"developer"},
|
|
}}
|
|
}
|
|
func (p *probeTokenPlugin) TokenScopes() []pact.TokenScope {
|
|
return []pact.TokenScope{{
|
|
Name: "probe",
|
|
Label: "Probe",
|
|
Description: "Test scope that does not cover Journal",
|
|
Permissions: []string{"acme.probe.*"},
|
|
Methods: []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"},
|
|
}}
|
|
}
|
|
|
|
var (
|
|
_ pact.HasTokenScopes = (*probeTokenPlugin)(nil)
|
|
_ pact.HasPermissions = (*probeTokenPlugin)(nil)
|
|
)
|
|
|
|
func (e *adminEnv) mintToken(t *testing.T, jwt string, scopes []string) (plain string, id uint) {
|
|
t.Helper()
|
|
create := e.call(t, http.MethodPost, adminAPI("/account/tokens"), jwt, map[string]any{
|
|
"name": "smoke-" + strings.Join(scopes, "-"),
|
|
"expires": "90d",
|
|
"scopes": scopes,
|
|
})
|
|
if create.Code != http.StatusCreated {
|
|
t.Fatalf("create token scopes=%v: %d %s", scopes, create.Code, create.Body.String())
|
|
}
|
|
var created struct {
|
|
Data cabana.AdminAPITokenCreated `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(created.Data.Token, cabana.APITokenPrefix) {
|
|
t.Fatalf("token shape %q", created.Data.Token)
|
|
}
|
|
return created.Data.Token, created.Data.ID
|
|
}
|
|
|
|
func (e *adminEnv) developerJWT(t *testing.T) string {
|
|
t.Helper()
|
|
stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1))
|
|
login := "dev-" + stamp
|
|
password := "dev-secret-" + stamp
|
|
hash, err := bouncer.HashPassword(10, password)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var roleID uint
|
|
if err := e.gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil || roleID == 0 {
|
|
t.Fatalf("developer role: id=%d err=%v", roleID, err)
|
|
}
|
|
if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`,
|
|
login, login+"@example.test", hash, roleID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := e.call(t, http.MethodPost, adminAPI("/auth/login"), "", map[string]string{"login": login, "password": password})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("developer login: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Data struct {
|
|
AccessToken string `json:"access_token"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.AccessToken == "" {
|
|
t.Fatalf("developer login body: %s err=%v", rec.Body.String(), err)
|
|
}
|
|
return body.Data.AccessToken
|
|
}
|
|
|
|
func assertPHPError(t *testing.T, rec *httptest.ResponseRecorder, status int, want string) {
|
|
t.Helper()
|
|
if rec.Code != status {
|
|
t.Fatalf("status = %d, want %d: %s", rec.Code, status, rec.Body.String())
|
|
}
|
|
got, _ := phpErrorString(t, rec.Body.Bytes())
|
|
if got != want {
|
|
t.Fatalf("error = %q, want %q; body=%s", got, want, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestJournalTokenTracer mints a journal-scoped personal API token through the
|
|
// admin account API and creates a Journal post as that token's owner (16.3-03).
|
|
func TestJournalTokenTracer(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
jwt := env.admin(t, controllers.PermissionAccessPosts)
|
|
adminID := env.lastUserID(t)
|
|
|
|
create := env.call(t, http.MethodPost, adminAPI("/account/tokens"), jwt, map[string]any{
|
|
"name": "journal-agent",
|
|
"expires": "90d",
|
|
"scopes": []string{"journal"},
|
|
})
|
|
if create.Code != http.StatusCreated {
|
|
t.Fatalf("create token: %d %s", create.Code, create.Body.String())
|
|
}
|
|
var created struct {
|
|
Data cabana.AdminAPITokenCreated `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plain := created.Data.Token
|
|
if !strings.HasPrefix(plain, cabana.APITokenPrefix) || len(plain) != len(cabana.APITokenPrefix)+40 {
|
|
t.Fatalf("token shape %q", plain)
|
|
}
|
|
|
|
postRec := env.call(t, http.MethodPost, "/_journal/api/v1/posts", plain, map[string]any{
|
|
"title": "Token-authored post",
|
|
"content": "Written with a journal scope token",
|
|
})
|
|
if postRec.Code != http.StatusCreated {
|
|
t.Fatalf("POST /posts with journal token: %d %s", postRec.Code, postRec.Body.String())
|
|
}
|
|
var postBody struct {
|
|
Data struct {
|
|
ID uint `json:"id"`
|
|
UserID *uint `json:"user_id"`
|
|
Slug string `json:"slug"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(postRec.Body.Bytes(), &postBody); err != nil {
|
|
t.Fatalf("post body: %s err=%v", postRec.Body.String(), err)
|
|
}
|
|
if postBody.Data.ID == 0 {
|
|
t.Fatalf("missing post id: %s", postRec.Body.String())
|
|
}
|
|
|
|
var row models.Post
|
|
if err := env.gdb.First(&row, postBody.Data.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if row.UserID == nil || *row.UserID != adminID {
|
|
t.Fatalf("stored user_id = %v, want %d", row.UserID, adminID)
|
|
}
|
|
}
|
|
|
|
// TestJournalTokenExpansion pins D-15 read/unrelated/revoked scopes, optional
|
|
// draft unlock, featured-image canEdit parity, and code-declared developer roles.
|
|
func TestJournalTokenExpansion(t *testing.T) {
|
|
env := bootJournal(t, []string{"golem15.translate", "golem15.journal", "acme.probe"})
|
|
editorJWT := env.admin(t, controllers.PermissionAccessPosts, "acme.probe.access")
|
|
editorID := env.lastUserID(t)
|
|
otherJWT := env.admin(t, controllers.PermissionAccessPosts)
|
|
otherID := env.lastUserID(t)
|
|
|
|
readTok, _ := env.mintToken(t, editorJWT, []string{"journal:read"})
|
|
journalTok, journalID := env.mintToken(t, editorJWT, []string{"journal"})
|
|
adminReadTok, _ := env.mintToken(t, editorJWT, []string{"admin:read"})
|
|
probeTok, _ := env.mintToken(t, editorJWT, []string{"probe"})
|
|
|
|
assertPHPError(t,
|
|
env.call(t, http.MethodPost, "/_journal/api/v1/posts", readTok, map[string]any{
|
|
"title": "Read scope write", "content": "no",
|
|
}),
|
|
http.StatusForbidden, "Insufficient permissions",
|
|
)
|
|
|
|
draft := models.Post{
|
|
Title: "Editor draft", Slug: "editor-token-draft", Content: "secret",
|
|
Published: false, UserID: &editorID,
|
|
}
|
|
if err := env.gdb.Create(&draft).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ownerDraft := env.call(t, http.MethodGet, "/_journal/api/v1/posts/editor-token-draft", readTok, nil)
|
|
if ownerDraft.Code != http.StatusOK {
|
|
t.Fatalf("journal:read draft show = %d, want 200: %s", ownerDraft.Code, ownerDraft.Body.String())
|
|
}
|
|
anonDraft := env.call(t, http.MethodGet, "/_journal/api/v1/posts/editor-token-draft", "", nil)
|
|
if anonDraft.Code != http.StatusNotFound {
|
|
t.Fatalf("anonymous draft show = %d, want 404: %s", anonDraft.Code, anonDraft.Body.String())
|
|
}
|
|
|
|
assertPHPError(t,
|
|
env.call(t, http.MethodPost, "/_journal/api/v1/posts", adminReadTok, map[string]any{
|
|
"title": "Unrelated", "content": "no",
|
|
}),
|
|
http.StatusForbidden, "Insufficient permissions",
|
|
)
|
|
assertPHPError(t,
|
|
env.call(t, http.MethodPost, "/_journal/api/v1/posts", probeTok, map[string]any{
|
|
"title": "Probe write", "content": "no",
|
|
}),
|
|
http.StatusForbidden, "Insufficient permissions",
|
|
)
|
|
unrelatedGet := env.call(t, http.MethodGet, "/_journal/api/v1/posts/editor-token-draft", probeTok, nil)
|
|
if unrelatedGet.Code != http.StatusNotFound {
|
|
t.Fatalf("probe draft show = %d, want anonymous 404: %s", unrelatedGet.Code, unrelatedGet.Body.String())
|
|
}
|
|
|
|
ownPath := fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", draft.ID)
|
|
up := env.callMultipart(t, http.MethodPost, ownPath, journalTok, nil, "file", "dot.png", png1x1)
|
|
if up.Code != http.StatusCreated {
|
|
t.Fatalf("featured upload own post = %d: %s", up.Code, up.Body.String())
|
|
}
|
|
var upBody struct {
|
|
Data struct {
|
|
ID uint `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(up.Body.Bytes(), &upBody); err != nil || upBody.Data.ID == 0 {
|
|
t.Fatalf("featured upload body: %s err=%v", up.Body.String(), err)
|
|
}
|
|
delOwn := env.call(t, http.MethodDelete,
|
|
fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images/%d", draft.ID, upBody.Data.ID),
|
|
journalTok, nil)
|
|
if delOwn.Code != http.StatusOK {
|
|
t.Fatalf("featured delete own = %d: %s", delOwn.Code, delOwn.Body.String())
|
|
}
|
|
|
|
otherPost := models.Post{
|
|
Title: "Other author", Slug: "other-author-post", Content: "x",
|
|
Published: false, UserID: &otherID,
|
|
}
|
|
if err := env.gdb.Create(&otherPost).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
otherPath := fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", otherPost.ID)
|
|
assertPHPError(t,
|
|
env.callMultipart(t, http.MethodPost, otherPath, journalTok, nil, "file", "dot.png", png1x1),
|
|
http.StatusForbidden, "You do not have permission to edit this post",
|
|
)
|
|
_ = otherJWT
|
|
|
|
revPath := adminAPI("/account/tokens/" + strconv.FormatUint(uint64(journalID), 10))
|
|
rev := env.call(t, http.MethodDelete, revPath, editorJWT, nil)
|
|
if rev.Code != http.StatusOK {
|
|
t.Fatalf("revoke: %d %s", rev.Code, rev.Body.String())
|
|
}
|
|
assertPHPError(t,
|
|
env.call(t, http.MethodPost, "/_journal/api/v1/posts", journalTok, map[string]any{
|
|
"title": "After revoke", "content": "no",
|
|
}),
|
|
http.StatusUnauthorized, "Authentication required",
|
|
)
|
|
|
|
devJWT := env.developerJWT(t)
|
|
created := env.call(t, http.MethodPost, "/_journal/api/v1/posts", devJWT, map[string]any{
|
|
"title": "Developer role grant", "content": "code-declared Roles",
|
|
})
|
|
if created.Code != http.StatusCreated {
|
|
t.Fatalf("developer JWT create = %d: %s", created.Code, created.Body.String())
|
|
}
|
|
}
|