Files
sm-journal-plugin/journal_api_token_smoke_test.go
Jakub Zych f2026c5c9b feat(16.3-03): Journal token matrix, README and probe cover smoke
- 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>
2026-10-11 02:17:43 +02:00

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())
}
}