From 58851dd1015bd4590e533146762335acc31e3891 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 11 Oct 2026 13:32:11 +0200 Subject: [PATCH] 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 --- journal_api_tokens_test.go | 237 +++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 journal_api_tokens_test.go diff --git a/journal_api_tokens_test.go b/journal_api_tokens_test.go new file mode 100644 index 0000000..7046f78 --- /dev/null +++ b/journal_api_tokens_test.go @@ -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 "" + } + return tok +}