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
This commit is contained in:
254
modules/cabana/account_contributions_test.go
Normal file
254
modules/cabana/account_contributions_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
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 }
|
||||
@@ -35,13 +35,22 @@ func (p linkPlugin) AdminActionExtensions() []pact.AdminActionExtension {
|
||||
*p.values = append(*p.values, in.Value)
|
||||
return pact.AdminRecordActionResult{Message: "Linked " + in.Value}, nil
|
||||
},
|
||||
}, {
|
||||
Name: "acme_plain", Label: "Plain action",
|
||||
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
*p.values = append(*p.values, "plain:"+in.Value)
|
||||
return pact.AdminRecordActionResult{Message: "Plain"}, nil
|
||||
},
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
// TestRecordActionPromptRun (Postgres): the preview carries the prompt, a
|
||||
// posted payload reaches Run trimmed as Value, and an empty value is 422 on
|
||||
// payload without running the action.
|
||||
// posted payload reaches Run trimmed as Value, an empty value is 422 on
|
||||
// payload without running the action, and an action without a prompt runs
|
||||
// with an empty Value.
|
||||
func TestRecordActionPromptRun(t *testing.T) {
|
||||
var values []string
|
||||
env := newScreensEnv(t, linkPlugin{mu: &sync.Mutex{}, values: &values})
|
||||
@@ -73,4 +82,10 @@ func TestRecordActionPromptRun(t *testing.T) {
|
||||
if result["message"] != "Linked acme" || len(values) != 1 || values[0] != "acme" {
|
||||
t.Fatalf("result = %v values = %v", result, values)
|
||||
}
|
||||
|
||||
// An action without a prompt runs with an empty Value.
|
||||
env.expect(t, http.StatusOK, http.MethodPost, userURL(target.ID, "actions", "acme_plain"), token, map[string]any{})
|
||||
if len(values) != 2 || values[1] != "plain:" {
|
||||
t.Fatalf("plain action values = %v", values)
|
||||
}
|
||||
}
|
||||
|
||||
124
modules/cabana/record_action_prompt_test.go
Normal file
124
modules/cabana/record_action_prompt_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"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"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
// TestRecordActionPrompt (D-34): a prompted record action's prompt is
|
||||
// localized in the show payload (label, placeholder and comment in the
|
||||
// request's language, the default cap of 255 when MaxLength is 0); its
|
||||
// value is trimmed and capped in runes; a non-string or empty payload is
|
||||
// 422 on payload; an action without a Prompt refuses any payload; and a
|
||||
// prompt-free value is the empty string. The Postgres run of the value
|
||||
// into Run, and the empty Value a plain action receives, are pinned by
|
||||
// TestRecordActionPromptRun.
|
||||
func TestRecordActionPrompt(t *testing.T) {
|
||||
cat := phrasebook.NewCatalog()
|
||||
if err := cat.Load("acme", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("site_login: Site login\nsite_login_hint: The login on the public site.\nlogin_placeholder: login\n")},
|
||||
"lang/pl/lang.yaml": {Data: []byte("site_login: Login na stronie\nsite_login_hint: Login w serwisie publicznym.\nlogin_placeholder: login\n")},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
|
||||
|
||||
var got []string
|
||||
extender := screensExtender{extensions: []pact.AdminActionExtension{{
|
||||
Controller: AdministratorsControllerID,
|
||||
RecordActions: []pact.AdminRecordAction{
|
||||
promptedAction("acme_link", &pact.AdminActionPrompt{Label: "acme::lang.site_login", Placeholder: "acme::lang.login_placeholder", Comment: "acme::lang.site_login_hint", MaxLength: 5}, &got),
|
||||
promptedAction("acme_note", &pact.AdminActionPrompt{Label: "acme::lang.site_login"}, &got),
|
||||
pingAction(),
|
||||
},
|
||||
}}}
|
||||
reg, err := compileWithScreens(t, []party.Plugin{extender})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cc, _ := reg.Get(AdministratorsControllerID)
|
||||
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
|
||||
|
||||
t.Run("prompt localized in the show payload", func(t *testing.T) {
|
||||
for locale, want := range map[string][2]string{
|
||||
"en": {"Site login", "The login on the public site."},
|
||||
"pl": {"Login na stronie", "Login w serwisie publicznym."},
|
||||
} {
|
||||
ctx := towel.WithLocale(bouncer.WithUser(context.Background(), super), locale)
|
||||
offered, err := (CRUDService{tr: tr}).offeredRecordActions(ctx, cc, &BackendUser{ID: 5, IsActivated: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, a := range offered {
|
||||
switch a.Name {
|
||||
case "acme_link":
|
||||
if a.Prompt == nil || a.Prompt.Label != want[0] || a.Prompt.Comment != want[1] || a.Prompt.Placeholder != "login" || a.Prompt.MaxLength != 5 {
|
||||
t.Fatalf("%s acme_link prompt = %+v", locale, a.Prompt)
|
||||
}
|
||||
case "acme_note":
|
||||
if a.Prompt == nil || a.Prompt.Label != want[0] || a.Prompt.MaxLength != 255 {
|
||||
t.Fatalf("%s acme_note prompt = %+v", locale, a.Prompt)
|
||||
}
|
||||
case "acme_ping":
|
||||
if a.Prompt != nil {
|
||||
t.Fatalf("acme_ping carries a prompt: %+v", a.Prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("value trimmed and capped in runes", func(t *testing.T) {
|
||||
prompt := &pact.AdminActionPrompt{MaxLength: 5}
|
||||
for raw, want := range map[string]string{`" ąęś "`: "ąęś", `"abcde"`: "abcde", "\"\\tabc\\n\"": "abc"} {
|
||||
if v, err := promptValue(context.Background(), nil, prompt, json.RawMessage(raw)); err != nil || v != want {
|
||||
t.Fatalf("%s = %q, %v", raw, v, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{`"abcdef"`, `"ąęśćżź"`, `""`, `" "`, `7`, `null`, `["a"]`, `{"v":"a"}`} {
|
||||
if v, err := promptValue(context.Background(), nil, prompt, json.RawMessage(raw)); err == nil {
|
||||
t.Fatalf("%s accepted as %q", raw, v)
|
||||
}
|
||||
}
|
||||
if v, err := promptValue(context.Background(), nil, nil, json.RawMessage(`"x"`)); err != nil || v != "" {
|
||||
t.Fatalf("no prompt = %q, %v", v, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("route refusals", func(t *testing.T) {
|
||||
svc := &service{app: backpack.New(nil), reg: reg}
|
||||
post := func(action, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
for k, v := range map[string]string{"vendor": "summercms", "plugin": "cabana", "controller": "users", "id": "5", "action": action} {
|
||||
req.SetPathValue(k, v)
|
||||
}
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), super))
|
||||
rec := httptest.NewRecorder()
|
||||
svc.recordAction(rec, req)
|
||||
return rec
|
||||
}
|
||||
for _, body := range []string{`{"payload":42}`, `{"payload":true}`, `{"payload":"abcdefgh"}`, `{"payload":""}`, `{}`} {
|
||||
if rec := post("acme_link", body); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"payload"`) {
|
||||
t.Fatalf("%s = %d %s", body, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if rec := post("acme_ping", `{"payload":"x"}`); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("payload on a plain action = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("a refused request ran the action: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
49
modules/cabana/scope_limit_for_test.go
Normal file
49
modules/cabana/scope_limit_for_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// TestScopeLimitForMethods (phase 19.7 D-22, the scope ceiling of an admin
|
||||
// personal token on the MCP endpoint): ScopeLimitFor filters a token's
|
||||
// stored scopes by the dispatched route's method exactly as the backend
|
||||
// guard does for a request. A GET-only scope limits GET and leaves no scope
|
||||
// for POST (known 1, method-allowed 0), "*" is full for every method, and
|
||||
// scopes the registry no longer knows count as none (unauthenticated).
|
||||
func TestScopeLimitForMethods(t *testing.T) {
|
||||
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{
|
||||
id: "acme.demo",
|
||||
perms: []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}},
|
||||
scopes: []pact.TokenScope{{
|
||||
Name: "demo-read", Label: "Demo", Description: "Reads the demo.",
|
||||
Permissions: []string{"acme.demo.access"}, Methods: []string{"GET", "HEAD"},
|
||||
}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish(&backendResolver{guard: &compositeGuard{reg: reg}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
limit, known, ok := ScopeLimitFor(app, []string{"demo-read"}, http.MethodGet)
|
||||
if limit == nil || limit.Full || known != 1 || ok != 1 || len(limit.Scopes) != 1 || !slices.Equal(limit.Scopes[0], []string{"acme.demo.access"}) {
|
||||
t.Fatalf("GET = %+v, %d, %d", limit, known, ok)
|
||||
}
|
||||
if limit, known, ok := ScopeLimitFor(app, []string{"demo-read"}, http.MethodPost); limit != nil || known != 1 || ok != 0 {
|
||||
t.Fatalf("POST = %+v, %d, %d, want no scope for the method", limit, known, ok)
|
||||
}
|
||||
if limit, known, ok := ScopeLimitFor(app, []string{"*"}, http.MethodDelete); limit == nil || !limit.Full || known != 1 || ok != 1 {
|
||||
t.Fatalf("* DELETE = %+v, %d, %d, want full", limit, known, ok)
|
||||
}
|
||||
if limit, known, ok := ScopeLimitFor(app, []string{"gone"}, http.MethodGet); limit != nil || known != 0 || ok != 0 {
|
||||
t.Fatalf("unknown scope = %+v, %d, %d", limit, known, ok)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user