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>
238 lines
8.2 KiB
Go
238 lines
8.2 KiB
Go
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
|
||
}
|