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:
Jakub Zych
2026-10-23 14:04:16 +02:00
parent 1111828e8a
commit f14eae96dd
18 changed files with 1696 additions and 10 deletions

View 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 }

View File

@@ -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)
}
}

View 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)
}
})
}

View 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)
}
}