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 }