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:
237
journal_api_tokens_test.go
Normal file
237
journal_api_tokens_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user