test(16.3-04): Journal personal API token write/read matrix

Cover every write route and optional-auth Index/Show across journal,
star, read-only, admin:read, unrelated, revoked and malformed tokens,
plus publish permission under a journal token.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-11 13:32:11 +02:00
parent 7bbafcbf28
commit 58851dd101

237
journal_api_tokens_test.go Normal file
View File

@@ -0,0 +1,237 @@
package journal_test
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"testing"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
)
// TestJournalTokens pins SC-4 / D-15: every write route × token class with
// byte-identical PHP bodies, optional-auth reads, JWT unchanged, and publish
// permission still enforced under a journal token.
func TestJournalTokens(t *testing.T) {
env := bootJournal(t, []string{"golem15.translate", "golem15.journal", "acme.probe"})
jwt := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish, "acme.probe.access")
limitedJWT := env.admin(t, controllers.PermissionAccessPosts)
journalTok, journalID := env.mintToken(t, jwt, []string{"journal"})
starTok, _ := env.mintToken(t, jwt, []string{"*"})
readTok, _ := env.mintToken(t, jwt, []string{"journal:read"})
adminReadTok, _ := env.mintToken(t, jwt, []string{"admin:read"})
probeTok, _ := env.mintToken(t, jwt, []string{"probe"})
malformed := "g15_notavalidtokenvaluexxxxxxxxxxxx"
createBody := map[string]any{"title": "Matrix post", "content": "hello"}
writeCases := []struct {
name string
token string
status int
err string
}{
{"journal", journalTok, http.StatusCreated, ""},
{"star", starTok, http.StatusCreated, ""},
{"journal:read", readTok, http.StatusForbidden, "Insufficient permissions"},
{"admin:read", adminReadTok, http.StatusForbidden, "Insufficient permissions"},
{"probe", probeTok, http.StatusForbidden, "Insufficient permissions"},
{"malformed", malformed, http.StatusUnauthorized, "Authentication required"},
}
for _, tc := range writeCases {
t.Run("store/"+tc.name, func(t *testing.T) {
rec := env.call(t, http.MethodPost, "/_journal/api/v1/posts", tc.token, createBody)
if rec.Code != tc.status {
t.Fatalf("status = %d, want %d body=%s", rec.Code, tc.status, rec.Body.String())
}
if tc.err != "" {
assertPHPError(t, rec, tc.status, tc.err)
}
})
}
// Seed a post for update/destroy/featured/media under journal token.
seed := env.call(t, http.MethodPost, "/_journal/api/v1/posts", journalTok, map[string]any{
"title": "Seed", "content": "body",
})
if seed.Code != http.StatusCreated {
t.Fatalf("seed: %d %s", seed.Code, seed.Body.String())
}
var seedBody struct {
Data struct {
ID uint `json:"id"`
Slug string `json:"slug"`
} `json:"data"`
}
if err := json.Unmarshal(seed.Body.Bytes(), &seedBody); err != nil {
t.Fatal(err)
}
idPath := fmt.Sprintf("/_journal/api/v1/posts/%d", seedBody.Data.ID)
slugPath := "/_journal/api/v1/posts/" + seedBody.Data.Slug
for _, tc := range []struct {
name string
token string
status int
err string
}{
{"journal", journalTok, http.StatusOK, ""},
{"journal:read", readTok, http.StatusForbidden, "Insufficient permissions"},
{"probe", probeTok, http.StatusForbidden, "Insufficient permissions"},
{"malformed", malformed, http.StatusUnauthorized, "Authentication required"},
} {
t.Run("update/"+tc.name, func(t *testing.T) {
rec := env.call(t, http.MethodPut, idPath, tc.token, map[string]any{
"title": "Updated", "content": "body",
})
if rec.Code != tc.status {
t.Fatalf("status = %d, want %d body=%s", rec.Code, tc.status, rec.Body.String())
}
if tc.err != "" {
assertPHPError(t, rec, tc.status, tc.err)
}
})
}
// Publish permission still enforced under a journal token without access_publish.
limitedTok, _ := env.mintToken(t, limitedJWT, []string{"journal"})
pubDenied := env.call(t, http.MethodPost, "/_journal/api/v1/posts", limitedTok, map[string]any{
"title": "Pub", "content": "x", "published": true,
})
assertPHPError(t, pubDenied, http.StatusForbidden, "You do not have permission to publish posts")
featPath := fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", seedBody.Data.ID)
for _, tc := range []struct {
name string
token string
status int
err string
}{
{"journal", journalTok, http.StatusCreated, ""},
{"journal:read", readTok, http.StatusForbidden, "Insufficient permissions"},
{"malformed", malformed, http.StatusUnauthorized, "Authentication required"},
} {
t.Run("featured/"+tc.name, func(t *testing.T) {
rec := env.callMultipart(t, http.MethodPost, featPath, tc.token, nil, "file", "dot.png", png1x1)
if rec.Code != tc.status {
t.Fatalf("status = %d, want %d body=%s", rec.Code, tc.status, rec.Body.String())
}
if tc.err != "" {
assertPHPError(t, rec, tc.status, tc.err)
}
if tc.status == http.StatusCreated {
var up struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &up)
if up.Data.ID != 0 {
del := env.call(t, http.MethodDelete,
fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images/%d", seedBody.Data.ID, up.Data.ID),
journalTok, nil)
if del.Code != http.StatusOK {
t.Fatalf("featured delete: %d %s", del.Code, del.Body.String())
}
}
}
})
}
mediaPath := "/_journal/api/v1/media/upload"
for _, tc := range []struct {
name string
token string
status int
err string
}{
{"journal", journalTok, http.StatusCreated, ""},
{"admin:read", adminReadTok, http.StatusForbidden, "Insufficient permissions"},
{"revoked", "", http.StatusUnauthorized, "Authentication required"}, // filled below
} {
token := tc.token
if tc.name == "revoked" {
rev := env.call(t, http.MethodDelete, adminAPI("/account/tokens/"+strconv.FormatUint(uint64(journalID), 10)), jwt, nil)
if rev.Code != http.StatusOK {
t.Fatalf("revoke journal token: %d %s", rev.Code, rev.Body.String())
}
token = journalTok
}
t.Run("media/"+tc.name, func(t *testing.T) {
if tc.name != "revoked" && tc.name != "admin:read" && tc.name != "journal" {
return
}
rec := env.callMultipart(t, http.MethodPost, mediaPath, token, nil, "file", "dot.png", png1x1)
// Media may be 201 or 200 depending on implementation; accept Created/OK for journal.
if tc.name == "journal" {
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
t.Fatalf("journal media: %d %s", rec.Code, rec.Body.String())
}
return
}
if rec.Code != tc.status {
t.Fatalf("status = %d, want %d body=%s", rec.Code, tc.status, rec.Body.String())
}
assertPHPError(t, rec, tc.status, tc.err)
})
}
// Re-mint journal token after revoke for destroy + optional-auth reads.
journalTok, _ = env.mintToken(t, jwt, []string{"journal"})
readTok, _ = env.mintToken(t, jwt, []string{"journal:read"})
destroy := env.call(t, http.MethodDelete, idPath, journalTok, nil)
if destroy.Code != http.StatusOK {
t.Fatalf("destroy: %d %s", destroy.Code, destroy.Body.String())
}
assertPHPError(t,
env.call(t, http.MethodDelete, idPath, readTok, nil),
http.StatusForbidden, "Insufficient permissions",
)
// Optional-auth Index/Show with each token class; JWT path unchanged.
pubRec := env.call(t, http.MethodPost, "/_journal/api/v1/posts", jwt, map[string]any{
"title": "Public", "content": "p", "published": true,
"published_at": "2020-01-01 00:00:00",
})
if pubRec.Code != http.StatusCreated {
t.Fatalf("publish seed: %d %s", pubRec.Code, pubRec.Body.String())
}
var pubBody struct {
Data struct {
Slug string `json:"slug"`
} `json:"data"`
}
if err := json.Unmarshal(pubRec.Body.Bytes(), &pubBody); err != nil || pubBody.Data.Slug == "" {
t.Fatalf("publish slug: %s err=%v", pubRec.Body.String(), err)
}
showPath := "/_journal/api/v1/posts/" + pubBody.Data.Slug
for _, tok := range []string{"", journalTok, readTok, starTok, probeTok, jwt} {
rec := env.call(t, http.MethodGet, "/_journal/api/v1/posts", tok, nil)
if rec.Code != http.StatusOK {
t.Fatalf("index token=%q: %d %s", trunc(tok), rec.Code, rec.Body.String())
}
show := env.call(t, http.MethodGet, showPath, tok, nil)
if show.Code != http.StatusOK {
t.Fatalf("show token=%q: %d %s", trunc(tok), show.Code, show.Body.String())
}
}
jwtCreate := env.call(t, http.MethodPost, "/_journal/api/v1/posts", jwt, map[string]any{
"title": "JWT still works", "content": "yes",
})
if jwtCreate.Code != http.StatusCreated {
t.Fatalf("jwt create: %d %s", jwtCreate.Code, jwtCreate.Body.String())
}
_ = slugPath
}
func trunc(tok string) string {
if len(tok) > 12 {
return tok[:12] + "…"
}
if tok == "" {
return "<anon>"
}
return tok
}