- 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
255 lines
11 KiB
Go
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 }
|