package journal_test import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" ) // probeTokenPlugin registers a non-Journal scope so expansion tests can mint a // token that authenticates but fails the golem15.journal.* cover check. type probeTokenPlugin struct{} func init() { party.Register(&probeTokenPlugin{}) } func (p *probeTokenPlugin) ID() string { return "acme.probe" } func (p *probeTokenPlugin) Requires() []string { return nil } func (p *probeTokenPlugin) Register(app *backpack.App) error { return nil } func (p *probeTokenPlugin) Boot(app *backpack.App) error { return nil } func (p *probeTokenPlugin) Permissions() []pact.Permission { return []pact.Permission{{ Code: "acme.probe.access", Label: "Probe access", Roles: []string{"developer"}, }} } func (p *probeTokenPlugin) TokenScopes() []pact.TokenScope { return []pact.TokenScope{{ Name: "probe", Label: "Probe", Description: "Test scope that does not cover Journal", Permissions: []string{"acme.probe.*"}, Methods: []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}, }} } var ( _ pact.HasTokenScopes = (*probeTokenPlugin)(nil) _ pact.HasPermissions = (*probeTokenPlugin)(nil) ) func (e *adminEnv) mintToken(t *testing.T, jwt string, scopes []string) (plain string, id uint) { t.Helper() create := e.call(t, http.MethodPost, adminAPI("/account/tokens"), jwt, map[string]any{ "name": "smoke-" + strings.Join(scopes, "-"), "expires": "90d", "scopes": scopes, }) if create.Code != http.StatusCreated { t.Fatalf("create token scopes=%v: %d %s", scopes, create.Code, create.Body.String()) } var created struct { Data cabana.AdminAPITokenCreated `json:"data"` } if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil { t.Fatal(err) } if !strings.HasPrefix(created.Data.Token, cabana.APITokenPrefix) { t.Fatalf("token shape %q", created.Data.Token) } return created.Data.Token, created.Data.ID } func (e *adminEnv) developerJWT(t *testing.T) string { t.Helper() stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1)) login := "dev-" + stamp password := "dev-secret-" + stamp hash, err := bouncer.HashPassword(10, password) if err != nil { t.Fatal(err) } var roleID uint if err := e.gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil || roleID == 0 { t.Fatalf("developer role: id=%d err=%v", roleID, err) } if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`, login, login+"@example.test", hash, roleID).Error; err != nil { t.Fatal(err) } rec := e.call(t, http.MethodPost, adminAPI("/auth/login"), "", map[string]string{"login": login, "password": password}) if rec.Code != http.StatusOK { t.Fatalf("developer login: %d %s", rec.Code, rec.Body.String()) } var body struct { Data struct { AccessToken string `json:"access_token"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.AccessToken == "" { t.Fatalf("developer login body: %s err=%v", rec.Body.String(), err) } return body.Data.AccessToken } func assertPHPError(t *testing.T, rec *httptest.ResponseRecorder, status int, want string) { t.Helper() if rec.Code != status { t.Fatalf("status = %d, want %d: %s", rec.Code, status, rec.Body.String()) } got, _ := phpErrorString(t, rec.Body.Bytes()) if got != want { t.Fatalf("error = %q, want %q; body=%s", got, want, rec.Body.String()) } } // TestJournalTokenTracer mints a journal-scoped personal API token through the // admin account API and creates a Journal post as that token's owner (16.3-03). func TestJournalTokenTracer(t *testing.T) { env := newPostsEnv(t) jwt := env.admin(t, controllers.PermissionAccessPosts) adminID := env.lastUserID(t) create := env.call(t, http.MethodPost, adminAPI("/account/tokens"), jwt, map[string]any{ "name": "journal-agent", "expires": "90d", "scopes": []string{"journal"}, }) if create.Code != http.StatusCreated { t.Fatalf("create token: %d %s", create.Code, create.Body.String()) } var created struct { Data cabana.AdminAPITokenCreated `json:"data"` } if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil { t.Fatal(err) } plain := created.Data.Token if !strings.HasPrefix(plain, cabana.APITokenPrefix) || len(plain) != len(cabana.APITokenPrefix)+40 { t.Fatalf("token shape %q", plain) } postRec := env.call(t, http.MethodPost, "/_journal/api/v1/posts", plain, map[string]any{ "title": "Token-authored post", "content": "Written with a journal scope token", }) if postRec.Code != http.StatusCreated { t.Fatalf("POST /posts with journal token: %d %s", postRec.Code, postRec.Body.String()) } var postBody struct { Data struct { ID uint `json:"id"` UserID *uint `json:"user_id"` Slug string `json:"slug"` } `json:"data"` } if err := json.Unmarshal(postRec.Body.Bytes(), &postBody); err != nil { t.Fatalf("post body: %s err=%v", postRec.Body.String(), err) } if postBody.Data.ID == 0 { t.Fatalf("missing post id: %s", postRec.Body.String()) } var row models.Post if err := env.gdb.First(&row, postBody.Data.ID).Error; err != nil { t.Fatal(err) } if row.UserID == nil || *row.UserID != adminID { t.Fatalf("stored user_id = %v, want %d", row.UserID, adminID) } } // TestJournalTokenExpansion pins D-15 read/unrelated/revoked scopes, optional // draft unlock, featured-image canEdit parity, and code-declared developer roles. func TestJournalTokenExpansion(t *testing.T) { env := bootJournal(t, []string{"golem15.translate", "golem15.journal", "acme.probe"}) editorJWT := env.admin(t, controllers.PermissionAccessPosts, "acme.probe.access") editorID := env.lastUserID(t) otherJWT := env.admin(t, controllers.PermissionAccessPosts) otherID := env.lastUserID(t) readTok, _ := env.mintToken(t, editorJWT, []string{"journal:read"}) journalTok, journalID := env.mintToken(t, editorJWT, []string{"journal"}) adminReadTok, _ := env.mintToken(t, editorJWT, []string{"admin:read"}) probeTok, _ := env.mintToken(t, editorJWT, []string{"probe"}) assertPHPError(t, env.call(t, http.MethodPost, "/_journal/api/v1/posts", readTok, map[string]any{ "title": "Read scope write", "content": "no", }), http.StatusForbidden, "Insufficient permissions", ) draft := models.Post{ Title: "Editor draft", Slug: "editor-token-draft", Content: "secret", Published: false, UserID: &editorID, } if err := env.gdb.Create(&draft).Error; err != nil { t.Fatal(err) } ownerDraft := env.call(t, http.MethodGet, "/_journal/api/v1/posts/editor-token-draft", readTok, nil) if ownerDraft.Code != http.StatusOK { t.Fatalf("journal:read draft show = %d, want 200: %s", ownerDraft.Code, ownerDraft.Body.String()) } anonDraft := env.call(t, http.MethodGet, "/_journal/api/v1/posts/editor-token-draft", "", nil) if anonDraft.Code != http.StatusNotFound { t.Fatalf("anonymous draft show = %d, want 404: %s", anonDraft.Code, anonDraft.Body.String()) } assertPHPError(t, env.call(t, http.MethodPost, "/_journal/api/v1/posts", adminReadTok, map[string]any{ "title": "Unrelated", "content": "no", }), http.StatusForbidden, "Insufficient permissions", ) assertPHPError(t, env.call(t, http.MethodPost, "/_journal/api/v1/posts", probeTok, map[string]any{ "title": "Probe write", "content": "no", }), http.StatusForbidden, "Insufficient permissions", ) unrelatedGet := env.call(t, http.MethodGet, "/_journal/api/v1/posts/editor-token-draft", probeTok, nil) if unrelatedGet.Code != http.StatusNotFound { t.Fatalf("probe draft show = %d, want anonymous 404: %s", unrelatedGet.Code, unrelatedGet.Body.String()) } ownPath := fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", draft.ID) up := env.callMultipart(t, http.MethodPost, ownPath, journalTok, nil, "file", "dot.png", png1x1) if up.Code != http.StatusCreated { t.Fatalf("featured upload own post = %d: %s", up.Code, up.Body.String()) } var upBody struct { Data struct { ID uint `json:"id"` } `json:"data"` } if err := json.Unmarshal(up.Body.Bytes(), &upBody); err != nil || upBody.Data.ID == 0 { t.Fatalf("featured upload body: %s err=%v", up.Body.String(), err) } delOwn := env.call(t, http.MethodDelete, fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images/%d", draft.ID, upBody.Data.ID), journalTok, nil) if delOwn.Code != http.StatusOK { t.Fatalf("featured delete own = %d: %s", delOwn.Code, delOwn.Body.String()) } otherPost := models.Post{ Title: "Other author", Slug: "other-author-post", Content: "x", Published: false, UserID: &otherID, } if err := env.gdb.Create(&otherPost).Error; err != nil { t.Fatal(err) } otherPath := fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", otherPost.ID) assertPHPError(t, env.callMultipart(t, http.MethodPost, otherPath, journalTok, nil, "file", "dot.png", png1x1), http.StatusForbidden, "You do not have permission to edit this post", ) _ = otherJWT revPath := adminAPI("/account/tokens/" + strconv.FormatUint(uint64(journalID), 10)) rev := env.call(t, http.MethodDelete, revPath, editorJWT, nil) if rev.Code != http.StatusOK { t.Fatalf("revoke: %d %s", rev.Code, rev.Body.String()) } assertPHPError(t, env.call(t, http.MethodPost, "/_journal/api/v1/posts", journalTok, map[string]any{ "title": "After revoke", "content": "no", }), http.StatusUnauthorized, "Authentication required", ) devJWT := env.developerJWT(t) created := env.call(t, http.MethodPost, "/_journal/api/v1/posts", devJWT, map[string]any{ "title": "Developer role grant", "content": "code-declared Roles", }) if created.Code != http.StatusCreated { t.Fatalf("developer JWT create = %d: %s", created.Code, created.Body.String()) } }