Files
sm-journal-plugin/journal_api_tokens_test.go
Jakub Zych 58851dd101 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>
2026-10-11 13:32:11 +02:00

238 lines
8.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}