package cabana_test import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "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/compass" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/surf" ) // accountContributor contributes one My account section, "site_link": a // status line and three actions, open (always), manage (needs // acme.links.manage) and relink (offered while relink is set). Each run is // recorded with whether it saw a transaction. type accountContributor struct { id string name string relink *atomic.Bool mu *sync.Mutex runs *[]bool } func (p accountContributor) ID() string { return p.id } func (accountContributor) Requires() []string { return nil } func (accountContributor) Register(*backpack.App) error { return nil } func (accountContributor) Boot(*backpack.App) error { return nil } func (p accountContributor) AccountContributions() []pact.AccountContribution { run := func(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) { _, tx := cabana.TxFromContext(ctx) p.mu.Lock() *p.runs = append(*p.runs, tx) p.mu.Unlock() return pact.AccountActionResult{Message: "Done", URL: "https://site.example.test/link"}, nil } name := p.name if name == "" { name = "site_link" } return []pact.AccountContribution{{ Name: name, Label: "Site account", Status: func(context.Context, uint) (string, error) { return "Not linked", nil }, Actions: []pact.AccountAction{ {Name: "open", Label: "Open", Run: run}, {Name: "manage", Label: "Manage", Permissions: []string{"acme.links.manage"}, Run: run}, {Name: "relink", Label: "Relink", Applies: func(context.Context, uint) (bool, error) { return p.relink.Load(), nil }, Run: run}, }, }} } func newAccountContributor(id string) accountContributor { return accountContributor{id: id, relink: &atomic.Bool{}, mu: &sync.Mutex{}, runs: &[]bool{}} } // assembleWithoutDB assembles the admin like newScreensEnv but without a // database: the backend middleware is reached through the in-process // dispatch marker, so the account routes run without an admin row. func assembleWithoutDB(t *testing.T, plugins ...party.Plugin) (http.Handler, error) { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-account\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) if err != nil { t.Fatal(err) } for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} { if err := cfg.Set(key, value); err != nil { t.Fatal(err) } } app := backpack.New(cfg) all := append([]party.Plugin{demoPlugin{fsys: demoFS()}}, plugins...) if err := phrasebook.Activate(app, all); err != nil { t.Fatal(err) } return surf.Assemble(app, all) } // asAdmin serves one admin API request in-process as principal (with an // optional credential, as a personal token would carry). func asAdmin(h http.Handler, method, rel string, p *bouncer.Principal, credential any) *httptest.ResponseRecorder { req := httptest.NewRequest(method, adminAPI(rel), strings.NewReader("{}")) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Requested-With", "XMLHttpRequest") req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: p, Credential: credential})) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // listedActions lists the action names of the site_link section. func listedActions(t *testing.T, rec *httptest.ResponseRecorder) []string { t.Helper() var out struct { Data []struct { Name string `json:"name"` Status string `json:"status"` Actions []struct { Name string `json:"name"` } `json:"actions"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || len(out.Data) != 1 || out.Data[0].Name != "site_link" || out.Data[0].Status != "Not linked" { t.Fatalf("contributions = %d %s (%v)", rec.Code, rec.Body.String(), err) } var names []string for _, a := range out.Data[0].Actions { names = append(names, a.Name) } return names } // TestAccountContributions (D-04): boot refuses a section two plugins // contribute, a non-identifier name, an action without Run and an empty // label; the list shows the actions the admin's permissions and Applies // allow; an action whose Applies turned false since the list is 409; a // personal token is refused (interactive only); and (Postgres) the action // runs inside a transaction while /auth/me and My account's own payloads // are byte-identical with and without a contributing plugin. func TestAccountContributions(t *testing.T) { t.Run("boot refusals", func(t *testing.T) { bad := func(id string, section pact.AccountContribution) party.Plugin { return badContributor{id: id, sections: []pact.AccountContribution{section}} } run := func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) { return pact.AccountActionResult{}, nil } cases := map[string]struct { plugins []party.Plugin want []string }{ "duplicate across plugins": {[]party.Plugin{newAccountContributor("acme.links"), newAccountContributor("acme.other")}, []string{"acme.other", "acme.links", "site_link"}}, "non-identifier name": {[]party.Plugin{bad("acme.bad", pact.AccountContribution{Name: "site-link", Label: "x"})}, []string{"acme.bad"}}, "nil Run": {[]party.Plugin{bad("acme.bad", pact.AccountContribution{Name: "s", Label: "x", Actions: []pact.AccountAction{{Name: "go", Label: "Go"}}})}, []string{"acme.bad"}}, "empty label": {[]party.Plugin{bad("acme.bad", pact.AccountContribution{Name: "s", Label: " ", Actions: []pact.AccountAction{{Name: "go", Label: "Go", Run: run}}})}, []string{"acme.bad"}}, } for name, c := range cases { _, err := assembleWithoutDB(t, c.plugins...) if err == nil { t.Fatalf("%s: assembled", name) } for _, w := range c.want { if !strings.Contains(err.Error(), w) { t.Fatalf("%s: err = %v, want %q", name, err, w) } } } }) contributor := newAccountContributor("acme.links") h, err := assembleWithoutDB(t, contributor) if err != nil { t.Fatal(err) } admin := &bouncer.Principal{ID: 42, Backend: true} manager := &bouncer.Principal{ID: 43, Backend: true, PermissionGrants: map[string]bool{"acme.links.manage": true}} t.Run("list filtered by permissions and Applies", func(t *testing.T) { if got := listedActions(t, asAdmin(h, http.MethodGet, "/account/contributions", admin, nil)); strings.Join(got, ",") != "open" { t.Fatalf("admin sees %v", got) } contributor.relink.Store(true) defer contributor.relink.Store(false) if got := listedActions(t, asAdmin(h, http.MethodGet, "/account/contributions", manager, nil)); strings.Join(got, ",") != "open,manage,relink" { t.Fatalf("manager sees %v", got) } }) t.Run("run, stale Applies and missing permission", func(t *testing.T) { rec := asAdmin(h, http.MethodPost, "/account/contributions/site_link/actions/open", admin, nil) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"url":"https://site.example.test/link"`) { t.Fatalf("open = %d %s", rec.Code, rec.Body.String()) } contributor.relink.Store(true) listedActions(t, asAdmin(h, http.MethodGet, "/account/contributions", admin, nil)) contributor.relink.Store(false) if rec := asAdmin(h, http.MethodPost, "/account/contributions/site_link/actions/relink", admin, nil); rec.Code != http.StatusConflict { t.Fatalf("relink after Applies turned false = %d %s", rec.Code, rec.Body.String()) } if rec := asAdmin(h, http.MethodPost, "/account/contributions/site_link/actions/manage", admin, nil); rec.Code != http.StatusForbidden { t.Fatalf("manage without the permission = %d %s", rec.Code, rec.Body.String()) } }) t.Run("personal tokens are refused", func(t *testing.T) { before := len(*contributor.runs) for _, rel := range []string{"/account/contributions", "/account/contributions/site_link/actions/open"} { method := http.MethodGet if strings.Contains(rel, "/actions/") { method = http.MethodPost } rec := asAdmin(h, method, rel, admin, &cabana.BackendAPIToken{ID: 3, BackendUserID: 42}) if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "interactive_only") { t.Fatalf("%s with a token credential = %d %s", rel, rec.Code, rec.Body.String()) } } if len(*contributor.runs) != before { t.Fatal("a token ran the action") } }) // The Postgres part runs on a Docker host (the gate's docker stage); // under -short it is not created, so no subtest is reported as skipped. if testing.Short() { return } t.Run("transaction and unchanged payloads", func(t *testing.T) { with := newAccountContributor("acme.links") env := newScreensEnv(t, with) plain := newScreensEnv(t) _, token := env.admin(t, env.unique("account"), false, `{}`) env.expect(t, http.StatusOK, http.MethodPost, "/account/contributions/site_link/actions/open", token, map[string]any{}) if runs := *with.runs; len(runs) != 1 || !runs[0] { t.Fatalf("runs (with a transaction) = %v", runs) } for _, rel := range []string{"/auth/me", "/account/preferences", "/account/tokens", "/account/tokens/scopes"} { a, b := plain.call(t, http.MethodGet, rel, token, nil), env.call(t, http.MethodGet, rel, token, nil) if a.Code != http.StatusOK || a.Code != b.Code || !bytes.Equal(a.Body.Bytes(), b.Body.Bytes()) { t.Fatalf("%s differs with a contributor:\nwithout %d %s\nwith %d %s", rel, a.Code, a.Body.String(), b.Code, b.Body.String()) } } if rec := plain.call(t, http.MethodGet, "/account/contributions", token, nil); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"data":[]`) { t.Fatalf("no contributor = %d %s", rec.Code, rec.Body.String()) } }) } // badContributor contributes the sections it is given. type badContributor struct { id string sections []pact.AccountContribution } func (p badContributor) ID() string { return p.id } func (badContributor) Requires() []string { return nil } func (badContributor) Register(*backpack.App) error { return nil } func (badContributor) Boot(*backpack.App) error { return nil } func (p badContributor) AccountContributions() []pact.AccountContribution { return p.sections }