Files
summercms/modules/cabana/account_contributions_smoke_test.go
Jakub Zych 9f14cf603c feat(19.7-01): account contributions, prompted record actions and backend subject events
- pact.HasAccountContributions compiled at Activate; GET /account/contributions
  and POST /account/contributions/{name}/actions/{action} behind interactiveOnly
  (AccountContributionView, AccountContributionActionView, AccountActionResultView)
- RecordAction.prompt (RecordActionPrompt) for a pact.AdminRecordAction with a
  Prompt; the record action route accepts payload only for such an action
  (trimmed JSON string, non-empty, capped at max_length) and hands it to Run as
  Value through CRUDService.RecordActionWithValue; a prompt without a label
  fails boot
- fireSubjectChanged: pact.MCPSubjectChanged for revoked tokens, Administrators
  saves that changed role, permissions or superuser, Roles saves that changed
  grants, deactivation and deletion
- phrases backend::lang.form.action_prompt_required/_too_long (en, pl)
- smoke tests TestAccountContributionsSmoke and TestRecordActionPromptSmoke;
  TestRecordActionPromptRun on Postgres; route inventories, CSRF walk and
  OpenAPI conformance cases extended for the two routes
- regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts; cabana
  README, docs/backend/{admin-controllers,users-and-permissions}.md
2026-10-22 16:31:29 +02:00

203 lines
9.5 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// contributor is a plugin that contributes one My account section (phase
// 19.7 D-04 part 1, planner decision P-02).
type contributor struct {
id string
sections []pact.AccountContribution
}
func (p contributor) ID() string { return p.id }
func (contributor) Requires() []string { return nil }
func (contributor) Register(*backpack.App) error { return nil }
func (contributor) Boot(*backpack.App) error { return nil }
func (p contributor) AccountContributions() []pact.AccountContribution { return p.sections }
// TestAccountContributionsSmoke: a contributed section is listed with its
// status and the actions that apply, an action runs for the signed-in
// administrator and answers its URL, personal tokens are refused, and a
// duplicate section name across plugins fails boot.
func TestAccountContributionsSmoke(t *testing.T) {
var ran []uint
section := pact.AccountContribution{
Name: "site_link", Label: "acme::lang.site_link", Description: "acme::lang.site_link_hint",
Status: func(_ context.Context, adminID uint) (string, error) { return "linked as admin", nil },
Actions: []pact.AccountAction{
{
Name: "open", Label: "acme::lang.open", Confirm: "acme::lang.open_confirm", Danger: true,
Run: func(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
ran = append(ran, in.AdminID)
return pact.AccountActionResult{Message: "acme::lang.opened", URL: "https://blog.example.com/link"}, nil
},
},
{
Name: "hidden", Label: "acme::lang.hidden",
Applies: func(context.Context, uint) (bool, error) { return false, nil },
Run: func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) {
return pact.AccountActionResult{}, errors.New("must not run")
},
},
{
Name: "locked", Label: "acme::lang.locked", Permissions: []string{"acme.links.manage"},
Run: func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) {
return pact.AccountActionResult{Message: "acme::lang.locked_done"}, nil
},
},
},
}
reg := &Registry{byID: map[string]*CompiledController{}}
if err := compileAccountContributions(reg, []party.Plugin{contributor{id: "acme.links", sections: []pact.AccountContribution{section}}}); err != nil {
t.Fatal(err)
}
svc := &service{app: backpack.New(nil), reg: reg}
admin := &bouncer.Principal{ID: 42, Backend: true}
request := func(method, path string, principal *bouncer.Principal) *http.Request {
req := httptest.NewRequest(method, path, strings.NewReader("{}"))
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.SetPathValue("name", "site_link")
if strings.Contains(path, "/actions/") {
req.SetPathValue("action", path[strings.LastIndex(path, "/")+1:])
}
if principal != nil {
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
}
return req
}
list := func(principal *bouncer.Principal) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
interactiveOnly(svc.accountContributionsList)(rec, request(http.MethodGet, "/account/contributions", principal))
return rec
}
run := func(action string, principal *bouncer.Principal) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
interactiveOnly(requireAjax(svc.accountContributionAction))(rec, request(http.MethodPost, "/account/contributions/site_link/actions/"+action, principal))
return rec
}
// The list: one section, its status, the open action; hidden (Applies
// false) and locked (missing permission) are left out.
rec := list(admin)
if rec.Code != http.StatusOK {
t.Fatalf("list = %d %s", rec.Code, rec.Body.String())
}
var listed struct {
Data []AccountContributionView `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
t.Fatal(err)
}
if len(listed.Data) != 1 || listed.Data[0].Name != "site_link" || listed.Data[0].Label != "acme::lang.site_link" || listed.Data[0].Status != "linked as admin" {
t.Fatalf("listed = %+v", listed.Data)
}
if actions := listed.Data[0].Actions; len(actions) != 1 || actions[0].Name != "open" || !actions[0].Danger || actions[0].Confirm != "acme::lang.open_confirm" {
t.Fatalf("actions = %+v", listed.Data[0].Actions)
}
// A superuser holds every permission: locked appears for them.
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
if err := json.Unmarshal(list(super).Body.Bytes(), &listed); err != nil || len(listed.Data[0].Actions) != 2 {
t.Fatalf("superuser actions = %+v err=%v", listed.Data, err)
}
// Running the action answers its message and URL for the signed-in admin.
rec = run("open", admin)
if rec.Code != http.StatusOK {
t.Fatalf("run = %d %s", rec.Code, rec.Body.String())
}
var result struct {
Data AccountActionResultView `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Data.URL != "https://blog.example.com/link" || result.Data.Message != "acme::lang.opened" || len(ran) != 1 || ran[0] != 42 {
t.Fatalf("result = %+v ran=%v", result.Data, ran)
}
if rec := run("hidden", admin); rec.Code != http.StatusConflict {
t.Fatalf("action that does not apply = %d %s", rec.Code, rec.Body.String())
}
if rec := run("locked", admin); rec.Code != http.StatusForbidden {
t.Fatalf("action without its permission = %d %s", rec.Code, rec.Body.String())
}
if rec := run("missing", admin); rec.Code != http.StatusNotFound {
t.Fatalf("unknown action = %d %s", rec.Code, rec.Body.String())
}
if rec := run("open", nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("no principal = %d %s", rec.Code, rec.Body.String())
}
if rec := run("open", &bouncer.Principal{ID: 7}); rec.Code != http.StatusUnauthorized {
t.Fatalf("frontend principal = %d %s", rec.Code, rec.Body.String())
}
// Personal API tokens are refused: a token credential and a raw g15_ bearer.
tokenReq := request(http.MethodGet, "/account/contributions", admin)
tokenReq = tokenReq.WithContext(bouncer.WithCredential(tokenReq.Context(), &BackendAPIToken{ID: 3}))
rec = httptest.NewRecorder()
interactiveOnly(svc.accountContributionsList)(rec, tokenReq)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "interactive_only") {
t.Fatalf("token credential = %d %s", rec.Code, rec.Body.String())
}
bearerReq := request(http.MethodPost, "/account/contributions/site_link/actions/open", admin)
bearerReq.Header.Set("Authorization", "Bearer "+APITokenPrefix+strings.Repeat("0", 40))
rec = httptest.NewRecorder()
interactiveOnly(requireAjax(svc.accountContributionAction))(rec, bearerReq)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "interactive_only") || len(ran) != 1 {
t.Fatalf("raw bearer = %d %s ran=%v", rec.Code, rec.Body.String(), ran)
}
// The POST needs the AJAX header (CSRF).
plain := request(http.MethodPost, "/account/contributions/site_link/actions/open", admin)
plain.Header.Del("X-Requested-With")
rec = httptest.NewRecorder()
interactiveOnly(requireAjax(svc.accountContributionAction))(rec, plain)
if rec.Code != http.StatusForbidden || len(ran) != 1 {
t.Fatalf("POST without the AJAX header = %d ran=%v", rec.Code, ran)
}
// Boot refuses a section name another plugin already contributed, and
// the other shapes the contract names.
dup := []party.Plugin{
contributor{id: "acme.links", sections: []pact.AccountContribution{section}},
contributor{id: "acme.other", sections: []pact.AccountContribution{{Name: "site_link", Label: "x", Actions: nil}}},
}
err := compileAccountContributions(&Registry{}, dup)
if err == nil || !strings.Contains(err.Error(), "acme.other") || !strings.Contains(err.Error(), "acme.links") {
t.Fatalf("duplicate section: %v", err)
}
for name, bad := range map[string]pact.AccountContribution{
"non-identifier name": {Name: "site-link", Label: "x"},
"empty label": {Name: "site_link", Label: " "},
"bad action name": {Name: "site_link", Label: "x", Actions: []pact.AccountAction{{Name: "no!", Label: "x", Run: section.Actions[0].Run}}},
"nil run": {Name: "site_link", Label: "x", Actions: []pact.AccountAction{{Name: "open", Label: "x"}}},
"empty action label": {Name: "site_link", Label: "x", Actions: []pact.AccountAction{{Name: "open", Run: section.Actions[0].Run}}},
"duplicate action": {Name: "site_link", Label: "x", Actions: []pact.AccountAction{{Name: "open", Label: "x", Run: section.Actions[0].Run}, {Name: "open", Label: "y", Run: section.Actions[0].Run}}},
} {
err := compileAccountContributions(&Registry{}, []party.Plugin{contributor{id: "acme.bad", sections: []pact.AccountContribution{bad}}})
if err == nil || !strings.HasPrefix(err.Error(), "cabana: plugin acme.bad contributes account section") {
t.Fatalf("%s: err = %v", name, err)
}
}
// No contributor: an empty list, never null, and nothing else changes.
empty := &service{app: backpack.New(nil), reg: &Registry{}}
rec = httptest.NewRecorder()
empty.accountContributionsList(rec, request(http.MethodGet, "/account/contributions", admin))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"data":[]`) {
t.Fatalf("empty list = %d %s", rec.Code, rec.Body.String())
}
}