Files
summercms/modules/cabana/account_contributions_test.go
Jakub Zych f14eae96dd test(19.7-05): framework unit tests for every validation row and the gate's admin stage
- pier: TestCatalog, TestDispatch, TestGuardRules, TestSessions (Redis
  across two instances and the memory store), TestFanOut; the store clock
  and stateless-server seams in export_test.go
- surf: TestStreamingRoute (throttle, CORS profile and access-log pattern on
  a stream route) and TestPierMount in both builds; the streaming smoke
  restores the mount hook it replaces
- wristband TestMCPScopes; cabana TestAccountContributions (Postgres part:
  transaction and unchanged My account payloads), TestRecordActionPrompt,
  TestScopeLimitForMethods; TestRecordActionPromptRun pins the empty Value
  of an action without a prompt
- vitest: 403 and failed-load cases for My account contributions, the
  cancelled prompt
- gate: every name required (tagged runs included), account.go unchanged
  since 51d088a, admin stage live
2026-10-23 14:04:16 +02:00

255 lines
11 KiB
Go

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 }