From f14eae96dd39bf4deaaff2d5a12537ff45be19ad Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 23 Oct 2026 14:04:16 +0200 Subject: [PATCH] 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 --- .../account/AccountContributions.test.ts | 19 ++ admin/tests/form/RecordActionsPrompt.test.ts | 14 + modules/cabana/account_contributions_test.go | 254 ++++++++++++++++++ .../record_action_prompt_postgres_test.go | 19 +- modules/cabana/record_action_prompt_test.go | 124 +++++++++ modules/cabana/scope_limit_for_test.go | 49 ++++ modules/pier/catalog_test.go | 212 +++++++++++++++ modules/pier/dispatch_test.go | 173 ++++++++++++ modules/pier/export_test.go | 23 ++ modules/pier/fanout_test.go | 135 ++++++++++ modules/pier/guard_rules_test.go | 138 ++++++++++ modules/pier/helpers_test.go | 12 + modules/pier/sessions_test.go | 112 ++++++++ modules/surf/pier_mount_test.go | 100 +++++++ modules/surf/streaming_smoke_test.go | 3 +- modules/surf/streaming_test.go | 164 +++++++++++ modules/wristband/mcp_scopes_test.go | 80 ++++++ scripts/check-phase19.7.sh | 75 +++++- 18 files changed, 1696 insertions(+), 10 deletions(-) create mode 100644 modules/cabana/account_contributions_test.go create mode 100644 modules/cabana/record_action_prompt_test.go create mode 100644 modules/cabana/scope_limit_for_test.go create mode 100644 modules/pier/catalog_test.go create mode 100644 modules/pier/dispatch_test.go create mode 100644 modules/pier/fanout_test.go create mode 100644 modules/pier/guard_rules_test.go create mode 100644 modules/pier/sessions_test.go create mode 100644 modules/surf/pier_mount_test.go create mode 100644 modules/surf/streaming_test.go create mode 100644 modules/wristband/mcp_scopes_test.go diff --git a/admin/tests/account/AccountContributions.test.ts b/admin/tests/account/AccountContributions.test.ts index 5633ac67..2548ce48 100644 --- a/admin/tests/account/AccountContributions.test.ts +++ b/admin/tests/account/AccountContributions.test.ts @@ -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) + }) }) diff --git a/admin/tests/form/RecordActionsPrompt.test.ts b/admin/tests/form/RecordActionsPrompt.test.ts index 520cee2e..12cdc7a1 100644 --- a/admin/tests/form/RecordActionsPrompt.test.ts +++ b/admin/tests/form/RecordActionsPrompt.test.ts @@ -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('[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) + }) }) diff --git a/modules/cabana/account_contributions_test.go b/modules/cabana/account_contributions_test.go new file mode 100644 index 00000000..0542c801 --- /dev/null +++ b/modules/cabana/account_contributions_test.go @@ -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 } diff --git a/modules/cabana/record_action_prompt_postgres_test.go b/modules/cabana/record_action_prompt_postgres_test.go index 6e9c259a..4cb69c5f 100644 --- a/modules/cabana/record_action_prompt_postgres_test.go +++ b/modules/cabana/record_action_prompt_postgres_test.go @@ -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) + } } diff --git a/modules/cabana/record_action_prompt_test.go b/modules/cabana/record_action_prompt_test.go new file mode 100644 index 00000000..00242849 --- /dev/null +++ b/modules/cabana/record_action_prompt_test.go @@ -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) + } + }) +} diff --git a/modules/cabana/scope_limit_for_test.go b/modules/cabana/scope_limit_for_test.go new file mode 100644 index 00000000..86a149a3 --- /dev/null +++ b/modules/cabana/scope_limit_for_test.go @@ -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) + } +} diff --git a/modules/pier/catalog_test.go b/modules/pier/catalog_test.go new file mode 100644 index 00000000..7868ee82 --- /dev/null +++ b/modules/pier/catalog_test.go @@ -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") + } + }) +} diff --git a/modules/pier/dispatch_test.go b/modules/pier/dispatch_test.go new file mode 100644 index 00000000..c47ce476 --- /dev/null +++ b/modules/pier/dispatch_test.go @@ -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") + } + }) +} diff --git a/modules/pier/export_test.go b/modules/pier/export_test.go index 45c6db59..a5fab929 100644 --- a/modules/pier/export_test.go +++ b/modules/pier/export_test.go @@ -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) +} diff --git a/modules/pier/fanout_test.go b/modules/pier/fanout_test.go new file mode 100644 index 00000000..678cc549 --- /dev/null +++ b/modules/pier/fanout_test.go @@ -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) + } + }) +} diff --git a/modules/pier/guard_rules_test.go b/modules/pier/guard_rules_test.go new file mode 100644 index 00000000..7a0e5c76 --- /dev/null +++ b/modules/pier/guard_rules_test.go @@ -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: 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) + } + }) +} diff --git a/modules/pier/helpers_test.go b/modules/pier/helpers_test.go index 5734eeca..7cb5be44 100644 --- a/modules/pier/helpers_test.go +++ b/modules/pier/helpers_test.go @@ -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 : 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 } diff --git a/modules/pier/sessions_test.go b/modules/pier/sessions_test.go new file mode 100644 index 00000000..7bf6dff5 --- /dev/null +++ b/modules/pier/sessions_test.go @@ -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") + }) + } +} diff --git a/modules/surf/pier_mount_test.go b/modules/surf/pier_mount_test.go new file mode 100644 index 00000000..eb26c534 --- /dev/null +++ b/modules/surf/pier_mount_test.go @@ -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) + } + }) +} diff --git a/modules/surf/streaming_smoke_test.go b/modules/surf/streaming_smoke_test.go index cd084f0c..e86a6649 100644 --- a/modules/surf/streaming_smoke_test.go +++ b/modules/surf/streaming_smoke_test.go @@ -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, diff --git a/modules/surf/streaming_test.go b/modules/surf/streaming_test.go new file mode 100644 index 00000000..7c591f8e --- /dev/null +++ b/modules/surf/streaming_test.go @@ -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()) + } + }) +} diff --git a/modules/wristband/mcp_scopes_test.go b/modules/wristband/mcp_scopes_test.go new file mode 100644 index 00000000..212d3719 --- /dev/null +++ b/modules/wristband/mcp_scopes_test.go @@ -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) + } + } + } + }) +} diff --git a/scripts/check-phase19.7.sh b/scripts/check-phase19.7.sh index 37b72e6a..0dc956d6 100755 --- a/scripts/check-phase19.7.sh +++ b/scripts/check-phase19.7.sh @@ -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() {