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

@@ -122,4 +122,23 @@ describe('My account contributions', () => {
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['This action no longer applies.', 'danger']])
expect(requestsTo(calls, 'GET', CONTRIBUTIONS)).toHaveLength(2)
})
it("403 shows the server's message as a danger toast and keeps the list", async () => {
const { wrapper, calls } = await openAccount({
[`POST ${OPEN}`]: { status: 403, body: { error: { code: 'forbidden', message: 'You may not link this account.', details: {} } } },
})
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
await flushPromises()
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['You may not link this account.', 'danger']])
expect(requestsTo(calls, 'GET', CONTRIBUTIONS)).toHaveLength(1)
expect(wrapper.find('[data-account-contribution-url]').exists()).toBe(false)
})
it('a failed list load renders no card and leaves the rest of My account', async () => {
const { wrapper } = await openAccount({
[`GET ${CONTRIBUTIONS}`]: { status: 500, body: { error: { code: 'server_error', message: 'Server error', details: {} } } },
})
expect(wrapper.findAll('[data-account-contribution]')).toHaveLength(0)
expect(wrapper.find('[data-api-tokens]').exists()).toBe(true)
})
})

View File

@@ -137,4 +137,18 @@ describe('prompted record actions', () => {
expect(dialog()).toBeNull()
expect(done).toEqual([])
})
it('cancelling the prompt posts nothing and a later prompt starts empty', async () => {
const { wrapper, calls, done } = mountActions({})
await click(wrapper, 'link')
await type('acme')
dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!.click()
await flushPromises()
expect(dialog()).toBeNull()
expect(requestsTo(calls, 'POST', LINK)).toHaveLength(0)
expect(done).toEqual([])
await click(wrapper, 'link')
expect(promptInput()!.value).toBe('')
expect(confirmButton().disabled).toBe(true)
})
})

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

View File

@@ -0,0 +1,212 @@
package pier_test
import (
"context"
"net/http"
"slices"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// noContent answers 204 for the catalog's routes (nothing is called).
func noContent(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
// guarded is one route behind acme.auth.
func guarded(method, path string) testRoute {
return testRoute{method: method, path: path, mw: []string{"acme.auth"}, h: noContent}
}
type itemInput struct {
ID int `json:"id" path:"id"`
}
type listInput struct {
Page int `json:"page,omitempty" query:"page"`
}
func boolPtr(b bool) *bool { return &b }
// catalogMount mounts pier for plugins on a fresh app with the acme guard
// and answers the mount error.
func catalogMount(t *testing.T, plugins ...party.Plugin) (*instance, error) {
t.Helper()
app := newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{}}})
host, err := mountPier(t, app, plugins)
if err != nil {
return nil, err
}
return &instance{app: app, host: host}, nil
}
// TestCatalog (SC-2, D-10..D-12, D-14): tool and prompt names are prefixed
// with the plugin's short code; every boot error names what is wrong
// (colliding short codes and duplicate names name both owners, a long or
// dashed name, a route nobody registers, a route without a token guard and
// no Guard, a non-struct Input, a path tag without its segment, a body field
// on a GET); annotations follow the HTTP method with declared overrides.
func TestCatalog(t *testing.T) {
list := testRoute{method: http.MethodGet, path: "/acme/list", mw: []string{"acme.auth"}, h: noContent}
listTool := pact.MCPTool{Name: "list", Description: "Lists.", Method: "GET", Path: "/acme/list", Input: listInput{}}
t.Run("prefix is the plugin's short code", func(t *testing.T) {
for id, want := range map[string]string{"acme.blog": "blog_list", "acme.My-Blog": "my_blog_list", "Vendor.Shop2": "shop2_list"} {
in, err := catalogMount(t, routePlugin{id: id, routes: []testRoute{list}, tools: []pact.MCPTool{listTool}})
if err != nil {
t.Fatalf("%s: %v", id, err)
}
if got := in.pier().ToolNames(); !slices.Equal(got, []string{want}) {
t.Fatalf("%s exposes %v, want [%s]", id, got, want)
}
}
})
refusals := []struct {
name string
plugins []party.Plugin
want []string
}{
{"same short code in two plugins", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{listTool}},
routePlugin{id: "other.blog", guard: "other", tools: []pact.MCPTool{listTool}},
}, []string{"other.blog", "acme.blog", "blog_list"}},
{"duplicate name in one plugin", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{listTool, listTool}},
}, []string{"acme.blog", "blog_list", "already taken"}},
{"name over 128 characters", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: strings.Repeat("x", 124), Method: "GET", Path: "/acme/list", Input: listInput{}}}},
}, []string{"acme.blog", "must match"}},
{"name with a dash", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list-all", Method: "GET", Path: "/acme/list", Input: listInput{}}}},
}, []string{"blog_list-all", "must match"}},
{"unregistered route", []party.Plugin{
routePlugin{id: "acme.blog", tools: []pact.MCPTool{{Name: "gone", Method: "post", Path: "/acme/gone", Input: struct{}{}}}},
}, []string{"POST /acme/gone", "no plugin registers"}},
{"route without a token guard and no Guard", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{{method: http.MethodGet, path: "/acme/open", h: noContent}}, tools: []pact.MCPTool{{Name: "open", Method: "GET", Path: "/acme/open", Input: struct{}{}}}},
}, []string{"acme.blog", "not token-guarded"}},
{"unknown declared guard", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Guard: "nobody", Method: "GET", Path: "/acme/list", Input: listInput{}}}},
}, []string{"unknown guard \"nobody\""}},
{"input is not a struct", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list", Input: "page"}}},
}, []string{"Input must be a struct"}},
{"input is nil", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list"}}},
}, []string{"Input is nil"}},
{"path tag without its segment", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list", Input: itemInput{}}}},
}, []string{`path:"id"`, "no {id} segment"}},
{"segment without an input field", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{guarded(http.MethodGet, "/acme/items/{id}")}, tools: []pact.MCPTool{{Name: "show", Method: "GET", Path: "/acme/items/{id}", Input: struct{}{}}}},
}, []string{"segment {id}", "no input field"}},
{"body field on a GET", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list", Input: struct {
Title string `json:"title"`
}{}}}},
}, []string{`"title"`, "GET route takes only path and query fields"}},
{"prompt named like a tool", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{listTool}, prompts: []pact.MCPPrompt{{Name: "list", Guard: "acme", Render: func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) { return nil, nil }}}},
}, []string{"prompt \"list\"", "blog_list", "already taken"}},
{"prompt without a guard", []party.Plugin{
routePlugin{id: "acme.blog", prompts: []pact.MCPPrompt{{Name: "draft", Render: func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) { return nil, nil }}}},
}, []string{"prompt \"draft\"", "must name its Guard"}},
}
for _, c := range refusals {
t.Run(c.name, func(t *testing.T) {
_, err := catalogMount(t, c.plugins...)
if err == nil {
t.Fatal("mount succeeded, want a boot error")
}
for _, w := range c.want {
if !strings.Contains(err.Error(), w) {
t.Fatalf("err = %v, want it to name %q", err, w)
}
}
})
}
t.Run("a declared backend guard binds a route without a token guard", func(t *testing.T) {
in, err := catalogMount(t, routePlugin{id: "acme.blog", routes: []testRoute{{method: http.MethodGet, path: "/acme/posts", h: noContent}},
tools: []pact.MCPTool{{Name: "posts", Guard: "backend", Method: "GET", Path: "/acme/posts", Input: struct{}{}}}})
if err != nil {
t.Fatal(err)
}
if got := in.pier().ToolNames(); !slices.Equal(got, []string{"blog_posts"}) {
t.Fatalf("exposed %v", got)
}
})
t.Run("annotations follow the method", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
plugin := routePlugin{id: "acme.blog",
routes: []testRoute{guarded(http.MethodGet, "/acme/items/{id}"), guarded(http.MethodPut, "/acme/items/{id}"), guarded(http.MethodDelete, "/acme/items/{id}"), guarded(http.MethodPost, "/acme/items"), list},
tools: []pact.MCPTool{
{Name: "show", Method: "GET", Path: "/acme/items/{id}", Input: itemInput{}},
{Name: "replace", Method: "PUT", Path: "/acme/items/{id}", Input: itemInput{}},
{Name: "remove", Method: "DELETE", Path: "/acme/items/{id}", Input: itemInput{}},
{Name: "create", Method: "POST", Path: "/acme/items", Input: struct {
Title string `json:"title"`
}{}},
{Name: "export", Method: "GET", Path: "/acme/list", Input: listInput{}, ReadOnly: boolPtr(false), OpenWorld: boolPtr(true)},
}}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{}}}), plugin)
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})
list, err := cs.ListTools(ctx, nil)
if err != nil {
t.Fatal(err)
}
type hints struct{ read, idem, destr, open bool }
want := map[string]hints{
"blog_show": {read: true, idem: true},
"blog_replace": {idem: true},
"blog_remove": {destr: true},
"blog_create": {},
"blog_export": {idem: true, open: true},
}
if len(list.Tools) != len(want) {
t.Fatalf("listed %v", toolNamesOf(list))
}
for _, tl := range list.Tools {
a := tl.Annotations
if a == nil || a.DestructiveHint == nil || a.OpenWorldHint == nil {
t.Fatalf("%s annotations = %+v, want explicit destructive and open-world hints", tl.Name, a)
}
got := hints{read: a.ReadOnlyHint, idem: a.IdempotentHint, destr: *a.DestructiveHint, open: *a.OpenWorldHint}
if got != want[tl.Name] {
t.Fatalf("%s hints = %+v, want %+v", tl.Name, got, want[tl.Name])
}
}
var schema map[string]any
for _, tl := range list.Tools {
if tl.Name == "blog_show" {
schema, _ = tl.InputSchema.(map[string]any)
}
}
if props, _ := schema["properties"].(map[string]any); props["id"] == nil {
t.Fatalf("blog_show input schema = %+v, want an id property", schema)
}
})
t.Run("prompts are prefixed", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
plugin := routePlugin{id: "acme.blog", prompts: []pact.MCPPrompt{{Name: "draft", Guard: "acme", Render: func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) {
return []pact.MCPPromptMessage{{Role: "user", Text: "Draft."}}, nil
}}}}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{}}}), plugin)
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})
prompts, err := cs.ListPrompts(ctx, nil)
if err != nil || len(prompts.Prompts) != 1 || prompts.Prompts[0].Name != "blog_draft" {
t.Fatalf("prompts/list = %+v, %v", prompts, err)
}
if _, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "draft"}); err == nil {
t.Fatal("the unprefixed prompt name answered")
}
})
}

View File

@@ -0,0 +1,173 @@
package pier_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type thingInput struct {
ID string `json:"id" path:"id"`
Tags []string `json:"tags,omitempty" query:"tag"`
Title string `json:"title"`
Note string `json:"note,omitempty"`
}
const invalidBody = `{"message":"The given data was invalid.","errors":{"title":["The title field is required."]}}`
// TestDispatch (D-11, D-13, D-14): a call's path and query fields fill the
// URL and the body is the agent's JSON minus them, without the fields the
// agent omitted (Pitfall 6); a multi-segment value for a single segment is
// refused before dispatch; the outer Authorization and Cookie never reach
// the route while the client address, X-Forwarded-For and Accept-Language
// do; a non-2xx answer is a tool error carrying the route's exact body as
// text and structured content; a 2xx array is wrapped as {"data": [...]};
// an answer above mcp.max_result_bytes is a fixed tool error; the per-call
// timeout cancels a hanging handler.
func TestDispatch(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var thingHits atomic.Int64
hangErr := make(chan error, 1)
echo := func(w http.ResponseWriter, r *http.Request) {
thingHits.Add(1)
raw, _ := io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": r.PathValue("id"),
"tags": r.URL.Query()["tag"],
"body": string(raw),
"auth": r.Header.Get("Authorization"),
"cookie": r.Header.Get("Cookie"),
"xff": r.Header.Get("X-Forwarded-For"),
"lang": r.Header.Get("Accept-Language"),
"remote": r.RemoteAddr,
})
}
plugin := routePlugin{id: "test.disp",
routes: []testRoute{
{method: http.MethodPost, path: "/disp/things/{id}", mw: []string{"acme.auth"}, h: echo},
{method: http.MethodPost, path: "/disp/invalid", mw: []string{"acme.auth"}, h: jsonRoute(http.StatusUnprocessableEntity, invalidBody)},
{method: http.MethodGet, path: "/disp/list", mw: []string{"acme.auth"}, h: jsonRoute(http.StatusOK, `[1,2,3]`)},
{method: http.MethodGet, path: "/disp/big", mw: []string{"acme.auth"}, h: jsonRoute(http.StatusOK, `"`+strings.Repeat("x", 2048)+`"`)},
{method: http.MethodGet, path: "/disp/hang", mw: []string{"acme.auth"}, h: func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
hangErr <- r.Context().Err()
w.WriteHeader(http.StatusServiceUnavailable)
}},
},
tools: []pact.MCPTool{
{Name: "thing", Method: "POST", Path: "/disp/things/{id}", Input: thingInput{}},
{Name: "invalid", Method: "POST", Path: "/disp/invalid", Input: struct{}{}},
{Name: "list", Method: "GET", Path: "/disp/list", Input: struct{}{}},
{Name: "big", Method: "GET", Path: "/disp/big", Input: struct{}{}},
{Name: "hang", Method: "GET", Path: "/disp/hang", Input: struct{}{}},
}}
app := newApp(t, appSpec{mcp: "enabled: false\nmax_result_bytes: 1024\ncall_timeout: 1s\n", guards: []pact.MCPGuard{&fakeGuard{}}})
in := startInstance(t, app, plugin)
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{headers: map[string]string{
"Cookie": "summer_session=outer; summer_admin=outer",
"X-Forwarded-For": "203.0.113.9",
"Accept-Language": "pl",
}})
call := func(name string, args map[string]any) *mcp.CallToolResult {
t.Helper()
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
if err != nil {
t.Fatalf("%s: %v", name, err)
}
return res
}
t.Run("argument split and request shape", func(t *testing.T) {
res := call("disp_thing", map[string]any{"id": "abc", "tags": []string{"a", "b"}, "title": "T"})
if res.IsError {
t.Fatalf("isError: %s", resultText(t, res))
}
got := structured(t, res)
if got["id"] != "abc" || got["body"] != `{"title":"T"}` {
t.Fatalf("path and body = %v, %v", got["id"], got["body"])
}
if tags, _ := got["tags"].([]any); len(tags) != 2 || tags[0] != "a" || tags[1] != "b" {
t.Fatalf("query tags = %v", got["tags"])
}
if got["auth"] != "" || got["cookie"] != "" {
t.Fatalf("outer credentials reached the route: auth %q cookie %q", got["auth"], got["cookie"])
}
if got["xff"] != "203.0.113.9" || got["lang"] != "pl" || !strings.HasPrefix(got["remote"].(string), "127.0.0.1:") {
t.Fatalf("forwarded request facts = xff %v lang %v remote %v", got["xff"], got["lang"], got["remote"])
}
if text := resultText(t, res); !strings.Contains(text, `"id":"abc"`) {
t.Fatalf("text content = %s", text)
}
})
t.Run("multi-segment value refused before dispatch", func(t *testing.T) {
before := thingHits.Load()
res := call("disp_thing", map[string]any{"id": "a/b", "title": "T"})
if !res.IsError || !strings.Contains(resultText(t, res), "one non-empty path segment") {
t.Fatalf("a/b = %+v", res)
}
res = call("disp_thing", map[string]any{"id": "abc"})
if !res.IsError || !strings.Contains(resultText(t, res), "invalid arguments") {
t.Fatalf("missing required title = %+v", res)
}
if thingHits.Load() != before {
t.Fatal("a refused call reached the route")
}
})
t.Run("route error is a tool error with its body", func(t *testing.T) {
res := call("disp_invalid", nil)
if !res.IsError || resultText(t, res) != invalidBody {
t.Fatalf("422 = %+v %q", res, resultText(t, res))
}
got := structured(t, res)
if got["message"] != "The given data was invalid." || got["errors"] == nil {
t.Fatalf("422 structuredContent = %+v", got)
}
})
t.Run("2xx array wrapped as data", func(t *testing.T) {
res := call("disp_list", nil)
data, _ := structured(t, res)["data"].([]any)
if res.IsError || len(data) != 3 || data[0] != 1.0 || resultText(t, res) != "[1,2,3]" {
t.Fatalf("array = %+v", res)
}
})
t.Run("result above max_result_bytes", func(t *testing.T) {
res := call("disp_big", nil)
if !res.IsError || resultText(t, res) != "pier: the route's answer exceeds mcp.max_result_bytes" || strings.Contains(resultText(t, res), "xxxx") {
t.Fatalf("big = %+v", res)
}
})
t.Run("call timeout cancels the handler", func(t *testing.T) {
start := time.Now()
res := call("disp_hang", nil)
if took := time.Since(start); took > 10*time.Second || took < 900*time.Millisecond {
t.Fatalf("hang returned after %v, want about the 1s call_timeout", took)
}
if !res.IsError {
t.Fatalf("hang = %+v, want a tool error", res)
}
select {
case err := <-hangErr:
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("handler context ended with %v, want the deadline", err)
}
case <-time.After(5 * time.Second):
t.Fatal("the hanging handler never saw its context end")
}
})
}

View File

@@ -2,6 +2,7 @@ package pier
import (
"context"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
@@ -23,3 +24,25 @@ func (p *Pier) Resolve(ctx context.Context, raw string) (Identity, error) {
// Ceiling reports whether the read/write ceiling applies to the identity.
func (id Identity) Ceiling() bool { return id.ceiling }
// AdvanceStoreClock moves the memory session store's clock forward by d, so
// a test can expire idle sessions without waiting; a Redis store is moved
// with miniredis.FastForward instead.
func (p *Pier) AdvanceStoreClock(d time.Duration) bool {
ms, ok := p.store.(*memoryStore)
if !ok {
return false
}
ms.mu.Lock()
base := ms.now
ms.now = func() time.Time { return base().Add(d) }
ms.mu.Unlock()
return true
}
// StatelessServers counts the per-identity servers of the stateless path.
func (p *Pier) StatelessServers() int {
p.stateless.mu.Lock()
defer p.stateless.mu.Unlock()
return len(p.stateless.entries)
}

135
modules/pier/fanout_test.go Normal file
View File

@@ -0,0 +1,135 @@
package pier_test
import (
"context"
"net/http"
"slices"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// waitGone polls until a session's calls fail (its server closed it).
func waitGone(t *testing.T, ctx context.Context, cs *mcp.ClientSession) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
if _, err := cs.ListTools(ctx, nil); err != nil {
return
}
if time.Now().After(deadline) {
t.Fatal("the session survived the event")
}
time.Sleep(50 * time.Millisecond)
}
}
// TestFanOut (D-26, D-27): subject events fired on one instance reach the
// sessions of the other through the Redis store. A permissions change sends
// tools/list_changed and the next list drops the tool; a link change makes
// the backend tools appear and disappear (and drops the 5 s link cache at
// once); revocation and deletion end the subject's sessions (404 next) and
// evict its stateless servers.
func TestFanOut(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var deny atomic.Bool
front := &fakeGuard{allow: func(s pact.MCPSubject, _ pact.MCPRequirements, perms []string) bool {
return !(deny.Load() && slices.Contains(perms, "acme.echo"))
}}
back := &fakeGuard{name: "backend", tokenMW: "backend"}
links := newFakeLinks()
verifier := newFakeVerifier(map[string]pact.MCPGrant{"oas_at_admin": grant(10, "c4", 4, "read", "write", "backend")})
acme := routePlugin{id: "test.acme",
routes: []testRoute{guarded(http.MethodPost, "/acme/echo/{id}"), guarded(http.MethodGet, "/acme/items/{id}")},
tools: []pact.MCPTool{
{Name: "echo", Method: "POST", Path: "/acme/echo/{id}", Input: echoInput{}, Permissions: []string{"acme.echo"}},
{Name: "show", Method: "GET", Path: "/acme/items/{id}", Input: showInput{}},
}}
office := routePlugin{id: "test.office", guard: "backend", middleware: "backend",
routes: []testRoute{{method: http.MethodGet, path: "/office/posts", mw: []string{"backend"}, h: noContent}},
tools: []pact.MCPTool{{Name: "posts", Method: "GET", Path: "/office/posts", Input: struct{}{}}}}
a, b, _ := twoInstances(t, appSpec{guards: []pact.MCPGuard{front, back}, verifier: verifier, links: links}, acme, office)
t.Run("permissions change", func(t *testing.T) {
changed := make(chan struct{}, 4)
cs := sdkClient(t, ctx, b.url(), "oas_at_test", clientOpts{changed: changed})
if got := listNames(t, ctx, cs); !slices.Equal(got, []string{"acme_echo", "acme_show"}) {
t.Fatalf("before = %v", got)
}
deny.Store(true)
defer deny.Store(false)
fireSubjectChanged(t, a.app, "acme", 7, pact.MCPSubjectPermissions)
waitChanged(t, changed)
if got := listNames(t, ctx, cs); !slices.Equal(got, []string{"acme_show"}) {
t.Fatalf("after = %v", got)
}
})
t.Run("link change", func(t *testing.T) {
changed := make(chan struct{}, 4)
cs := sdkClient(t, ctx, b.url(), "oas_at_admin", clientOpts{changed: changed})
if got := listNames(t, ctx, cs); slices.Contains(got, "office_posts") {
t.Fatalf("backend tool listed without a link: %v", got)
}
links.set(10, 42)
fireSubjectChanged(t, a.app, "acme", 10, pact.MCPSubjectLink)
waitChanged(t, changed)
if got := listNames(t, ctx, cs); !slices.Contains(got, "office_posts") {
t.Fatalf("after linking = %v", got)
}
// Unlinked without an event: the instance keeps its cached answer for
// up to LinkCacheTTL; the link event drops it at once.
links.remove(10)
if id, err := b.pier().Resolve(ctx, "oas_at_admin"); err != nil || id.Backend == nil {
t.Fatalf("cached link within the TTL = %+v, %v", id.Backend, err)
}
fireSubjectChanged(t, a.app, "backend", 42, pact.MCPSubjectLink)
waitChanged(t, changed)
if got := listNames(t, ctx, cs); slices.Contains(got, "office_posts") {
t.Fatalf("after unlinking = %v", got)
}
if id, err := b.pier().Resolve(ctx, "oas_at_admin"); err != nil || id.Backend != nil {
t.Fatalf("link cache after the event = %+v, %v", id.Backend, err)
}
})
t.Run("revocation ends sessions and evicts stateless servers", func(t *testing.T) {
stateful := sdkClient(t, ctx, b.url(), "oas_at_test", clientOpts{})
stateless := sdkClient(t, ctx, b.url(), "oas_at_test", clientOpts{version: "newest"})
if stateless.ID() != "" {
t.Fatal("the newest client got a session")
}
listNames(t, ctx, stateless)
if n := b.pier().StatelessServers(); n == 0 {
t.Fatal("no stateless server for the binding")
}
sid := stateful.ID()
fireSubjectChanged(t, a.app, "acme", 7, pact.MCPSubjectRevoked)
waitGone(t, ctx, stateful)
if raw := rpc(t, ctx, b.url(), "oas_at_test", sid, callBody); raw.status != http.StatusNotFound {
t.Fatalf("call after revocation = %d %s", raw.status, raw.body)
}
deadline := time.Now().Add(10 * time.Second)
for b.pier().StatelessServers() != 0 {
if time.Now().After(deadline) {
t.Fatalf("stateless servers left after revocation: %d", b.pier().StatelessServers())
}
time.Sleep(50 * time.Millisecond)
}
})
t.Run("deletion ends sessions", func(t *testing.T) {
cs := sdkClient(t, ctx, b.url(), "oas_at_other", clientOpts{})
sid := cs.ID()
fireSubjectChanged(t, a.app, "acme", 8, pact.MCPSubjectDeleted)
waitGone(t, ctx, cs)
if raw := rpc(t, ctx, a.url(), "oas_at_other", sid, callBody); raw.status != http.StatusNotFound {
t.Fatalf("call after deletion = %d %s", raw.status, raw.body)
}
})
}

View File

@@ -0,0 +1,138 @@
package pier_test
import (
"context"
"errors"
"net/http"
"slices"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// permGuard parses acme.perm:<code> middleware into required permission
// codes and allows a subject only when its granted codes cover the route's
// codes and the tool's (the union, D-16).
func permGuard(granted map[uint][]string) *fakeGuard {
return &fakeGuard{
parse: func(mw []string) (pact.MCPRequirements, error) {
var req pact.MCPRequirements
for _, m := range mw {
if code, ok := strings.CutPrefix(m, "acme.perm:"); ok {
if code == "" {
return pact.MCPRequirements{}, errors.New("acme.perm names no code")
}
req.Permissions = append(req.Permissions, code)
}
}
return req, nil
},
allow: func(s pact.MCPSubject, req pact.MCPRequirements, perms []string) bool {
for _, code := range append(slices.Clone(req.Permissions), perms...) {
if !slices.Contains(granted[s.UserID], code) {
return false
}
}
return true
},
}
}
// methodDenyGuard is a method-aware provider that refuses one method, as an
// admin token whose scopes allow GET only does.
type methodDenyGuard struct {
methodGuard
deny string
}
func (g methodDenyGuard) MCPAllowsMethod(ctx context.Context, s pact.MCPSubject, req pact.MCPRequirements, perms []string, method string) (bool, error) {
ok, err := g.methodGuard.MCPAllowsMethod(ctx, s, req, perms, method)
return ok && method != g.deny, err
}
// TestGuardRules (D-15, D-16, D-22): the route's middleware requirements and
// the tool's Permissions are both handed to the provider (the union decides);
// a provider that rejects a route's middleware fails boot; a route the
// provider does not call token-guarded binds only with a declared Guard; a
// method-aware provider is asked with each tool's own method, so a GET-only
// scope hides write tools (the built-in backend provider does this with
// cabana.ScopeLimitFor, pinned by cabana's TestScopeLimitForMethods).
func TestGuardRules(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
rules := testRoute{method: http.MethodGet, path: "/rules/a", mw: []string{"acme.auth", "acme.perm:p.a"}, h: noContent}
t.Run("requirements union with tool permissions", func(t *testing.T) {
guard := permGuard(map[uint][]string{7: {"p.a", "p.b"}, 8: {"p.a"}})
plugin := routePlugin{id: "test.rules", factory: "acme.perm", routes: []testRoute{rules},
tools: []pact.MCPTool{
{Name: "both", Method: "GET", Path: "/rules/a", Input: struct{}{}, Permissions: []string{"p.b"}},
}}
plain := routePlugin{id: "test.plain", guard: "plain", routes: []testRoute{{method: http.MethodGet, path: "/rules/plain", mw: []string{"acme.auth", "acme.perm:p.a"}, h: noContent}},
tools: []pact.MCPTool{{Name: "route_only", Guard: "acme", Method: "GET", Path: "/rules/plain", Input: struct{}{}}}}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), plugin, plain)
if got := listNames(t, ctx, sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})); !slices.Equal(got, []string{"plain_route_only", "rules_both"}) {
t.Fatalf("user 7 (p.a, p.b) lists %v", got)
}
if got := listNames(t, ctx, sdkClient(t, ctx, in.url(), "oas_at_other", clientOpts{})); !slices.Equal(got, []string{"plain_route_only"}) {
t.Fatalf("user 8 (p.a) lists %v", got)
}
var saw bool
for _, c := range guard.recorded() {
if slices.Equal(c.req.Permissions, []string{"p.a"}) && slices.Equal(c.perms, []string{"p.b"}) {
saw = true
}
}
if !saw {
t.Fatalf("the provider never saw the route's p.a with the tool's p.b: %+v", guard.recorded())
}
})
t.Run("rejected middleware fails boot", func(t *testing.T) {
guard := permGuard(nil)
bad := routePlugin{id: "test.rules", factory: "acme.perm", routes: []testRoute{{method: http.MethodGet, path: "/rules/bad", mw: []string{"acme.auth", "acme.perm:"}, h: noContent}},
tools: []pact.MCPTool{{Name: "bad", Method: "GET", Path: "/rules/bad", Input: struct{}{}}}}
_, err := mountPier(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), []party.Plugin{bad})
if err == nil || !strings.Contains(err.Error(), "rejects the route's middleware") || !strings.Contains(err.Error(), "names no code") {
t.Fatalf("err = %v", err)
}
})
t.Run("not token-guarded needs a declared guard", func(t *testing.T) {
guard := &fakeGuard{notToken: true}
route := testRoute{method: http.MethodGet, path: "/rules/session", mw: []string{"acme.auth"}, h: noContent}
implicit := routePlugin{id: "test.rules", routes: []testRoute{route}, tools: []pact.MCPTool{{Name: "s", Method: "GET", Path: "/rules/session", Input: struct{}{}}}}
if _, err := mountPier(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), []party.Plugin{implicit}); err == nil || !strings.Contains(err.Error(), "not token-guarded") {
t.Fatalf("implicit binding: err = %v", err)
}
declared := implicit
declared.tools = []pact.MCPTool{{Name: "s", Guard: "acme", Method: "GET", Path: "/rules/session", Input: struct{}{}}}
if _, err := mountPier(t, newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{notToken: true}}}), []party.Plugin{declared}); err != nil {
t.Fatalf("declared guard: %v", err)
}
})
t.Run("method-aware provider sees each tool's method", func(t *testing.T) {
guard := methodDenyGuard{methodGuard: methodGuard{fakeGuard: &fakeGuard{}}, deny: http.MethodPost}
plugin := routePlugin{id: "test.rules",
routes: []testRoute{guarded(http.MethodGet, "/rules/items"), guarded(http.MethodPost, "/rules/items")},
tools: []pact.MCPTool{
{Name: "list", Method: "GET", Path: "/rules/items", Input: struct{}{}},
{Name: "create", Method: "POST", Path: "/rules/items", Input: struct{}{}},
}}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), plugin)
if got := listNames(t, ctx, sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})); !slices.Equal(got, []string{"rules_list"}) {
t.Fatalf("lists %v, want the GET tool only", got)
}
methods := map[string]bool{}
for _, c := range guard.recorded() {
methods[c.method] = true
}
if !methods[http.MethodGet] || !methods[http.MethodPost] || methods[""] {
t.Fatalf("provider asked with methods %v", methods)
}
})
}

View File

@@ -647,6 +647,18 @@ type routePlugin struct {
routes []testRoute
tools []pact.MCPTool
prompts []pact.MCPPrompt
// factory, when set, registers a pass-through middleware factory under
// that name (for <factory>:<param> requirement middleware).
factory string
}
func (p routePlugin) MiddlewareFactories() map[string]func(string) pact.Middleware {
if p.factory == "" {
return nil
}
return map[string]func(string) pact.Middleware{p.factory: func(string) pact.Middleware {
return func(next http.Handler) http.Handler { return next }
}}
}
func (p routePlugin) ID() string { return p.id }

View File

@@ -0,0 +1,112 @@
package pier_test
import (
"context"
"net/http"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
)
// deleteSession sends DELETE for a session with a bearer.
func deleteSession(t *testing.T, ctx context.Context, url, token, sid string) int {
t.Helper()
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Mcp-Session-Id", sid)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
return resp.StatusCode
}
// sessionCase is one store under test: the instance that opens sessions,
// the one that serves them next and the store's clock.
type sessionCase struct {
name string
a, b *instance
advance func(time.Duration)
}
// TestSessions (D-25): on the Redis store shared by two instances and on
// the memory store of one instance, a session is bound to the identity that
// opened it (another binding is 403 on POST and DELETE), is served by
// whichever instance receives it (rehydrated from the stored protocol state,
// never from a stored identity: the call runs as the request's bearer),
// DELETE ends it everywhere (404 next), an idle session expires after
// mcp.sessions.idle_ttl (404, and the client initialises a new one) and
// mcp.sessions.max_per_binding caps a binding's open sessions (429).
func TestSessions(t *testing.T) {
cfgTail := " idle_ttl: 30m\n max_per_binding: 2\n"
plugin := acmePlugin{tools: []pact.MCPTool{echoTool()}}
var cases []sessionCase
{
a, b, mr := twoInstances(t, appSpec{mcp: "enabled: false\nsessions:\n store: redis\n" + cfgTail, guards: []pact.MCPGuard{&acmeGuard{}}}, plugin)
cases = append(cases, sessionCase{"redis across two instances", a, b, mr.FastForward})
}
{
one := startInstance(t, newApp(t, appSpec{mcp: "enabled: false\nsessions:\n store: memory\n" + cfgTail, guards: []pact.MCPGuard{&acmeGuard{}}}), plugin)
cases = append(cases, sessionCase{"memory on one instance", one, one, func(d time.Duration) {
if !one.pier().AdvanceStoreClock(d) {
t.Fatal("the memory instance has no memory store")
}
}})
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sid := openSession(t, ctx, c.a, "oas_at_test")
call := rpc(t, ctx, c.b.url(), "oas_at_test", sid, callBody)
if call.status != http.StatusOK || !strings.Contains(call.body, `\"principal\":7`) || strings.Contains(call.body, `"isError":true`) {
t.Fatalf("call on the second instance = %d %s", call.status, call.body)
}
if other := rpc(t, ctx, c.b.url(), "oas_at_other", sid, callBody); other.status != http.StatusForbidden || !strings.Contains(other.body, "session_binding") {
t.Fatalf("another binding = %d %s", other.status, other.body)
}
if code := deleteSession(t, ctx, c.b.url(), "oas_at_other", sid); code != http.StatusForbidden {
t.Fatalf("DELETE by another binding = %d", code)
}
if code := deleteSession(t, ctx, c.b.url(), "oas_at_test", sid); code != http.StatusNoContent {
t.Fatalf("DELETE = %d", code)
}
for _, in := range []*instance{c.a, c.b} {
if gone := rpc(t, ctx, in.url(), "oas_at_test", sid, callBody); gone.status != http.StatusNotFound {
t.Fatalf("call after DELETE = %d %s", gone.status, gone.body)
}
}
if code := deleteSession(t, ctx, c.a.url(), "oas_at_test", sid); code != http.StatusNotFound {
t.Fatalf("second DELETE = %d", code)
}
idle := openSession(t, ctx, c.a, "oas_at_test")
c.advance(31 * time.Minute)
if expired := rpc(t, ctx, c.b.url(), "oas_at_test", idle, callBody); expired.status != http.StatusNotFound {
t.Fatalf("call after the idle TTL = %d %s", expired.status, expired.body)
}
fresh := openSession(t, ctx, c.b, "oas_at_test")
if again := rpc(t, ctx, c.a.url(), "oas_at_test", fresh, callBody); again.status != http.StatusOK {
t.Fatalf("call on the re-initialised session = %d %s", again.status, again.body)
}
openSession(t, ctx, c.a, "oas_at_test")
third := rpc(t, ctx, c.b.url(), "oas_at_test", "", initializeBody)
if third.status != http.StatusTooManyRequests || !strings.Contains(third.body, "too_many_sessions") {
t.Fatalf("session over max_per_binding = %d %s", third.status, third.body)
}
// Another binding has its own allowance.
openSession(t, ctx, c.b, "oas_at_other")
})
}
}

View File

@@ -0,0 +1,100 @@
package surf
import (
"net/http"
"reflect"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// TestPierMount (SC-1, D-30, D-33) runs in both builds. Without the
// summercms_mcp tag: mcp off leaves the route table exactly as without the
// key, and mcp.enabled true fails boot naming the tag. With the tag (the
// gate's tagged run): mcp.enabled true mounts POST, GET and DELETE at the
// endpoint as stream routes and the two protected resource metadata
// documents, and a plugin route already at the endpoint path fails boot
// naming its plugin.
func TestPierMount(t *testing.T) {
plugins := []party.Plugin{dispatchPlugin{}}
files := func(mcp string) map[string]string {
f := map[string]string{"http.yaml": transportBodyLimits, "app.yaml": "name: mount-test\nurl: http://acme.test\n"}
if mcp != "" {
f["mcp.yaml"] = mcp
}
return f
}
t.Run("off leaves the route table unchanged", func(t *testing.T) {
base, err := BuildRouter(transportApp(t, "development", files("")), plugins)
if err != nil {
t.Fatal(err)
}
for _, mcp := range []string{"enabled: false\n", "path: /agents\nallow_personal_tokens: true\n"} {
router, err := BuildRouter(transportApp(t, "development", files(mcp)), plugins)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(router.Routes(), base.Routes()) {
t.Fatalf("%q changes the route table:\n%+v\n%+v", mcp, router.Routes(), base.Routes())
}
}
})
if mountPier == nil {
t.Run("enabled without the tag fails boot", func(t *testing.T) {
_, err := BuildRouter(transportApp(t, "development", files("enabled: true\n")), plugins)
if err == nil || !strings.Contains(err.Error(), MCPBuildTag) {
t.Fatalf("err = %v, want one naming %s", err, MCPBuildTag)
}
})
return
}
t.Run("enabled mounts the endpoint as stream routes", func(t *testing.T) {
router, err := BuildRouter(transportApp(t, "development", files("enabled: true\n")), plugins)
if err != nil {
t.Fatal(err)
}
var endpoint []string
for _, rt := range router.routes {
if rt.path == "/mcp" {
if !rt.stream || rt.pluginID != "summercms.pier" || !slices.Contains(rt.middleware, "throttle:120,1") {
t.Fatalf("%s /mcp = stream %v plugin %q middleware %v", rt.method, rt.stream, rt.pluginID, rt.middleware)
}
endpoint = append(endpoint, rt.method)
}
}
slices.Sort(endpoint)
if !slices.Equal(endpoint, []string{http.MethodDelete, http.MethodGet, http.MethodPost}) {
t.Fatalf("endpoint methods = %v", endpoint)
}
for _, path := range []string{"/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"} {
found := false
for _, ri := range router.Routes() {
if ri.Method == http.MethodGet && ri.Pattern == path && ri.PluginID == "summercms.pier" {
found = true
}
}
if !found {
t.Fatalf("GET %s is not mounted", path)
}
}
if _, ok := router.Handler(http.MethodPost, "/mcp"); !ok {
t.Fatal("no compiled handler for POST /mcp")
}
})
t.Run("a plugin route at the endpoint fails boot", func(t *testing.T) {
squatter := routesPluginFunc{id: "acme.squatter", routes: func(r pact.Router) {
r.Get("/mcp", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
}}
_, err := BuildRouter(transportApp(t, "development", files("enabled: true\n")), append(plugins, squatter))
if err == nil || !strings.Contains(err.Error(), "acme.squatter") {
t.Fatalf("err = %v, want one naming acme.squatter", err)
}
})
}

View File

@@ -88,7 +88,8 @@ func TestStreamingRouteSmoke(t *testing.T) {
}
return nil
}
defer func() { mountPier = nil }()
prev := mountPier
defer func() { mountPier = prev }()
app := transportApp(t, "development", map[string]string{
"http.yaml": transportBodyLimits,

View File

@@ -0,0 +1,164 @@
package surf
import (
"bytes"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/party"
)
// TestStreamingRoute (SC-1, RESEARCH Pitfall 1): a MountStream route flushes
// to the client while its handler runs and a buffered route does not (its
// Flush stays a no-op until the handler returns); a panic before the first
// byte is the opaque 500; and a stream route keeps everything else a route
// gets: its inline throttle, its CORS profile's headers and the route
// pattern in the access log.
func TestStreamingRoute(t *testing.T) {
plugin := streamPlugin{release: make(chan struct{})}
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "event: ok\n\n")
})
prev := mountPier
mountPier = func(r *Router, _ *backpack.App, _ []party.Plugin) error {
r.BindPlugin("summercms.pier")
for _, m := range []struct {
path string
h http.Handler
mw []string
}{
{"/mcp/events", http.HandlerFunc(plugin.events), nil},
{"/mcp/panic-early", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("before write") }), nil},
{"/mcp/limited", ok, []string{"throttle:2,1"}},
{"/mcp/cors", ok, []string{"cors:spa"}},
} {
if err := r.MountStream(http.MethodGet, m.path, m.h, m.mw...); err != nil {
return err
}
}
return nil
}
defer func() { mountPier = prev }()
app := transportApp(t, "development", map[string]string{
"http.yaml": transportBodyLimits + `cors_profiles:
spa:
allowed_origins: ["https://www.example.com"]
allowed_methods: ["GET"]
allowed_headers: ["Content-Type"]
`,
"mcp.yaml": "enabled: true\n",
})
var logs bytes.Buffer
if err := app.Publish(slog.New(slog.NewTextHandler(&logs, nil))); err != nil {
t.Fatal(err)
}
h, err := Assemble(app, []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(h)
defer srv.Close()
client := srv.Client()
get := func(path string, header map[string]string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodGet, srv.URL+path, nil)
if err != nil {
t.Fatal(err)
}
for k, v := range header {
req.Header.Set(k, v)
}
res, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
return res
}
t.Run("stream flushes, buffered Flush is a no-op", func(t *testing.T) {
stream := get("/mcp/events", nil)
defer stream.Body.Close()
select {
case line := <-firstLine(t, stream):
if line != "event: a" {
t.Fatalf("first line = %q", line)
}
case <-time.After(5 * time.Second):
t.Fatal("the stream route did not flush while its handler ran")
}
buffered := make(chan string, 1)
go func() {
res, err := client.Get(srv.URL + "/acme/buffered")
if err != nil {
buffered <- "error: " + err.Error()
return
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
buffered <- string(body)
}()
select {
case got := <-buffered:
t.Fatalf("the buffered route answered before its handler returned: %q", got)
case <-time.After(300 * time.Millisecond):
}
close(plugin.release)
select {
case got := <-buffered:
if got != "event: a\n\nevent: b\n\n" {
t.Fatalf("buffered body = %q", got)
}
case <-time.After(5 * time.Second):
t.Fatal("the buffered route never answered")
}
})
t.Run("panic before the first byte", func(t *testing.T) {
res := get("/mcp/panic-early", nil)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusInternalServerError || strings.Contains(string(body), "before write") {
t.Fatalf("panic = %d %q", res.StatusCode, body)
}
})
t.Run("inline throttle", func(t *testing.T) {
var codes []int
for i := 0; i < 3; i++ {
res := get("/mcp/limited", nil)
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
codes = append(codes, res.StatusCode)
}
if codes[0] != http.StatusOK || codes[1] != http.StatusOK || codes[2] != http.StatusTooManyRequests {
t.Fatalf("statuses = %v, want 200 200 429", codes)
}
})
t.Run("CORS profile headers", func(t *testing.T) {
res := get("/mcp/cors", map[string]string{"Origin": "https://www.example.com"})
defer res.Body.Close()
if res.StatusCode != http.StatusOK || res.Header.Get("Access-Control-Allow-Origin") != "https://www.example.com" {
t.Fatalf("cors = %d, allow-origin %q", res.StatusCode, res.Header.Get("Access-Control-Allow-Origin"))
}
other := get("/mcp/cors", map[string]string{"Origin": "https://evil.example"})
defer other.Body.Close()
if other.Header.Get("Access-Control-Allow-Origin") != "" {
t.Fatalf("a foreign origin got allow-origin %q", other.Header.Get("Access-Control-Allow-Origin"))
}
})
t.Run("access log pattern", func(t *testing.T) {
if !strings.Contains(logs.String(), `pattern="GET /mcp/limited"`) || !strings.Contains(logs.String(), `pattern="GET /mcp/events"`) {
t.Fatalf("access log lacks the stream route patterns:\n%s", logs.String())
}
})
}

View File

@@ -0,0 +1,80 @@
package wristband
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
// TestMCPScopes (D-21, the framework side of the backend scope): the MCP
// flavour's authorize allow-list is exactly Options.ScopesSupported. The
// default server refuses backend; a server that lists it accepts "read
// backend" and advertises it in its metadata; on either, openid and a scope
// nobody lists stay refused.
func TestMCPScopes(t *testing.T) {
_, challenge := s256Pair(t)
authorize := func(t *testing.T, s *Server, scope string) map[string]string {
t.Helper()
rec := httptest.NewRecorder()
s.Authorize(rec, authorizeRequest(map[string]string{
"client_id": "cli-scopes", "redirect_uri": authorizeTestRedirect, "response_type": "code",
"code_challenge": challenge, "code_challenge_method": "S256", "scope": scope, "state": "s",
}))
if rec.Code != http.StatusFound {
t.Fatalf("%q: status = %d, want 302", scope, rec.Code)
}
return queryOf(t, rec.Header().Get("Location"))
}
plain, plainBackend := newAuthorizeTestServer()
insertAuthorizeTestClient(plainBackend, "cli-scopes", []string{authorizeTestRedirect}, nil)
listing := DefaultOptions()
listing.Issuer = "https://issuer.example.test"
listing.Resource = "https://resource.example.test/mcp"
listing.ScopesSupported = append(listing.ScopesSupported, "backend")
withBackend := NewServer(listing)
backend := newMemoryBackend()
withBackend.SetBackend(backend)
insertAuthorizeTestClient(backend, "cli-scopes", []string{authorizeTestRedirect}, nil)
t.Run("default server refuses backend", func(t *testing.T) {
if q := authorize(t, plain, "read backend"); q["error"] != "invalid_scope" {
t.Fatalf("default server = %v", q)
}
if q := authorize(t, plain, "read write"); q["error"] != "" {
t.Fatalf("default server refused read write: %v", q)
}
})
t.Run("listing server accepts and advertises backend", func(t *testing.T) {
if q := authorize(t, withBackend, "read backend"); q["error"] != "" {
t.Fatalf("read backend = %v", q)
}
rec := httptest.NewRecorder()
withBackend.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil))
var doc struct {
Scopes []string `json:"scopes_supported"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil || !slices.Contains(doc.Scopes, "backend") {
t.Fatalf("scopes_supported = %v (%v)", doc.Scopes, err)
}
rec = httptest.NewRecorder()
plain.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil))
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil || slices.Contains(doc.Scopes, "backend") {
t.Fatalf("default scopes_supported = %v (%v)", doc.Scopes, err)
}
})
t.Run("openid and unlisted scopes stay refused", func(t *testing.T) {
for _, s := range []*Server{plain, withBackend} {
for _, scope := range []string{"openid backend", "read openid", "read admin"} {
if q := authorize(t, s, scope); q["error"] != "invalid_scope" {
t.Fatalf("%q = %v", scope, q)
}
}
}
})
}

View File

@@ -88,21 +88,44 @@ GO_TESTS=(
./modules/pier:TestPierTracerSmoke ./modules/pier:TestPierSessionsSmoke
./modules/pier:TestPierFanOutSmoke ./modules/pier:TestPierStatelessSmoke
./modules/pier:TestExampleBlogPlugin
# Plan 05: the validation map (SC-3, D-17, D-20, D-23).
./modules/pier:TestToolsList
# The cabana subject-event fix (d96c726).
./modules/cabana:TestFireSubjectChangedReachesPointerListener
# Plan 05: the validation map (SC-1..SC-3, D-04, D-10..D-17, D-20..D-27,
# D-30, D-34).
./modules/pier:TestToolsList ./modules/pier:TestCatalog ./modules/pier:TestDispatch
./modules/pier:TestGuardRules ./modules/pier:TestSessions ./modules/pier:TestFanOut
./modules/bouncer:TestDispatchMarker ./modules/cabana:TestDispatchMarker
./modules/surf:TestStreamingRoute ./modules/surf:TestPierMount
./modules/wristband:TestMCPScopes
./modules/cabana:TestAccountContributions ./modules/cabana:TestRecordActionPrompt
./modules/cabana:TestScopeLimitForMethods
)
# go stage: framework tests that run in a binary built with the MCP tag.
# go stage: framework tests that also run in a binary built with the MCP
# tag (surf links pier there; TestPierMount takes its tagged branch).
GO_TAGGED_TESTS=(
./modules/pier:TestToolsList
./modules/pier:TestToolsList ./modules/pier:TestCatalog ./modules/pier:TestDispatch
./modules/pier:TestGuardRules ./modules/pier:TestSessions ./modules/pier:TestFanOut
./modules/pier:TestPierTracerSmoke ./modules/pier:TestPierStatelessSmoke
./modules/surf:TestPierMount ./modules/surf:TestStreamingRoute ./modules/surf:TestDispatchSmoke
)
# docker stage: Postgres tests in the framework.
DOCKER_TESTS=(
./modules/cabana:TestDispatchMarker ./modules/cabana:TestRecordActionPromptRun
./modules/cabana:TestDispatchMarker ./modules/cabana:TestAccountContributions
./modules/cabana:TestRecordActionPromptRun ./modules/cabana:TestFireSubjectChangedWaitsForCommit
)
# admin stage: vitest files that must pass.
ADMIN_FILES=(
tests/account/AccountContributions.test.ts
tests/form/RecordActionsPrompt.test.ts
)
# go stage: My account's own handlers stay as they were before the phase
# (D-04: existing payloads unchanged).
ACCOUNT_BASE="51d088a"
# docs stage: names of consuming applications (split so this file does not
# match itself), the decision note and the dependency names it must carry.
APP_NAMES=("p[lł]yta""rium" "fono""teka" "wave""path" "horo""skopia" "bm\\.jakub""zych" "grzyby""funkcjonalne" "stu""dia")
@@ -453,6 +476,11 @@ run_go() {
return 1
fi
echo "untagged: examples/hello links no MCP SDK"
if ! (cd "$ROOT" && git diff --quiet "$ACCOUNT_BASE" -- modules/cabana/account.go); then
echo "refuse: modules/cabana/account.go changed since $ACCOUNT_BASE (D-04: My account's payloads stay as they were)" >&2
return 1
fi
echo "account: modules/cabana/account.go unchanged since $ACCOUNT_BASE"
go_pkg_named "$ROOT" "-short" "${GO_TESTS[@]}"
go_pkg_named "$ROOT" "-short -tags $MCP_TAG" "${GO_TAGGED_TESTS[@]}"
echo "phase19.7 go passed"
@@ -532,9 +560,42 @@ EOF
echo "NOT RUN docker (Docker-host only; see the notice above)"
}
# npm_checked NAME DETECT_FILES... runs npm --prefix admin run NAME and
# checks its output with detect_vitest, requiring DETECT_FILES.
npm_checked() {
local script="$1"
shift
local log rc=0 dc=0
log="$(mktemp)"
npm --prefix "$ROOT/admin" run "$script" >"$log" 2>&1 || rc=$?
REQUIRE_FILES="$*" detect_vitest "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
grep -E '(FAIL|×|Error|expected)' "$log" | head -n 40 >&2 || true
tail -n 20 "$log" >&2
rm -f "$log"
echo "refuse: npm run $script in admin (test=$rc detect=$dc)" >&2
return 1
fi
echo "vitest ($script): $(grep -E '^\s*Tests\s' "$log" | tr -s ' ')"
rm -f "$log"
}
run_admin() {
echo "TODO stage admin" >&2
return 1
local log rc=0
log="$(mktemp)"
npm --prefix "$ROOT/admin" run typecheck >"$log" 2>&1 || rc=$?
if [[ "$rc" -ne 0 ]]; then
tail -n 40 "$log" >&2
rm -f "$log"
echo "refuse: npm run typecheck in admin" >&2
return 1
fi
rm -f "$log"
echo "typecheck: admin"
npm_checked test "${ADMIN_FILES[@]}"
(cd "$ROOT" && bash scripts/check-admin-openapi.sh --check)
(cd "$ROOT" && bash scripts/check-admin-dist.sh)
echo "phase19.7 admin passed"
}
run_docs() {