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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
254
modules/cabana/account_contributions_test.go
Normal file
254
modules/cabana/account_contributions_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
// accountContributor contributes one My account section, "site_link": a
|
||||
// status line and three actions, open (always), manage (needs
|
||||
// acme.links.manage) and relink (offered while relink is set). Each run is
|
||||
// recorded with whether it saw a transaction.
|
||||
type accountContributor struct {
|
||||
id string
|
||||
name string
|
||||
relink *atomic.Bool
|
||||
mu *sync.Mutex
|
||||
runs *[]bool
|
||||
}
|
||||
|
||||
func (p accountContributor) ID() string { return p.id }
|
||||
func (accountContributor) Requires() []string { return nil }
|
||||
func (accountContributor) Register(*backpack.App) error { return nil }
|
||||
func (accountContributor) Boot(*backpack.App) error { return nil }
|
||||
func (p accountContributor) AccountContributions() []pact.AccountContribution {
|
||||
run := func(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
|
||||
_, tx := cabana.TxFromContext(ctx)
|
||||
p.mu.Lock()
|
||||
*p.runs = append(*p.runs, tx)
|
||||
p.mu.Unlock()
|
||||
return pact.AccountActionResult{Message: "Done", URL: "https://site.example.test/link"}, nil
|
||||
}
|
||||
name := p.name
|
||||
if name == "" {
|
||||
name = "site_link"
|
||||
}
|
||||
return []pact.AccountContribution{{
|
||||
Name: name, Label: "Site account",
|
||||
Status: func(context.Context, uint) (string, error) { return "Not linked", nil },
|
||||
Actions: []pact.AccountAction{
|
||||
{Name: "open", Label: "Open", Run: run},
|
||||
{Name: "manage", Label: "Manage", Permissions: []string{"acme.links.manage"}, Run: run},
|
||||
{Name: "relink", Label: "Relink", Applies: func(context.Context, uint) (bool, error) { return p.relink.Load(), nil }, Run: run},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func newAccountContributor(id string) accountContributor {
|
||||
return accountContributor{id: id, relink: &atomic.Bool{}, mu: &sync.Mutex{}, runs: &[]bool{}}
|
||||
}
|
||||
|
||||
// assembleWithoutDB assembles the admin like newScreensEnv but without a
|
||||
// database: the backend middleware is reached through the in-process
|
||||
// dispatch marker, so the account routes run without an admin row.
|
||||
func assembleWithoutDB(t *testing.T, plugins ...party.Plugin) (http.Handler, error) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-account\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
|
||||
if err := cfg.Set(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
all := append([]party.Plugin{demoPlugin{fsys: demoFS()}}, plugins...)
|
||||
if err := phrasebook.Activate(app, all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return surf.Assemble(app, all)
|
||||
}
|
||||
|
||||
// asAdmin serves one admin API request in-process as principal (with an
|
||||
// optional credential, as a personal token would carry).
|
||||
func asAdmin(h http.Handler, method, rel string, p *bouncer.Principal, credential any) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, adminAPI(rel), strings.NewReader("{}"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: p, Credential: credential}))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// listedActions lists the action names of the site_link section.
|
||||
func listedActions(t *testing.T, rec *httptest.ResponseRecorder) []string {
|
||||
t.Helper()
|
||||
var out struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Actions []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"actions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || len(out.Data) != 1 || out.Data[0].Name != "site_link" || out.Data[0].Status != "Not linked" {
|
||||
t.Fatalf("contributions = %d %s (%v)", rec.Code, rec.Body.String(), err)
|
||||
}
|
||||
var names []string
|
||||
for _, a := range out.Data[0].Actions {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// TestAccountContributions (D-04): boot refuses a section two plugins
|
||||
// contribute, a non-identifier name, an action without Run and an empty
|
||||
// label; the list shows the actions the admin's permissions and Applies
|
||||
// allow; an action whose Applies turned false since the list is 409; a
|
||||
// personal token is refused (interactive only); and (Postgres) the action
|
||||
// runs inside a transaction while /auth/me and My account's own payloads
|
||||
// are byte-identical with and without a contributing plugin.
|
||||
func TestAccountContributions(t *testing.T) {
|
||||
t.Run("boot refusals", func(t *testing.T) {
|
||||
bad := func(id string, section pact.AccountContribution) party.Plugin {
|
||||
return badContributor{id: id, sections: []pact.AccountContribution{section}}
|
||||
}
|
||||
run := func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) {
|
||||
return pact.AccountActionResult{}, nil
|
||||
}
|
||||
cases := map[string]struct {
|
||||
plugins []party.Plugin
|
||||
want []string
|
||||
}{
|
||||
"duplicate across plugins": {[]party.Plugin{newAccountContributor("acme.links"), newAccountContributor("acme.other")}, []string{"acme.other", "acme.links", "site_link"}},
|
||||
"non-identifier name": {[]party.Plugin{bad("acme.bad", pact.AccountContribution{Name: "site-link", Label: "x"})}, []string{"acme.bad"}},
|
||||
"nil Run": {[]party.Plugin{bad("acme.bad", pact.AccountContribution{Name: "s", Label: "x", Actions: []pact.AccountAction{{Name: "go", Label: "Go"}}})}, []string{"acme.bad"}},
|
||||
"empty label": {[]party.Plugin{bad("acme.bad", pact.AccountContribution{Name: "s", Label: " ", Actions: []pact.AccountAction{{Name: "go", Label: "Go", Run: run}}})}, []string{"acme.bad"}},
|
||||
}
|
||||
for name, c := range cases {
|
||||
_, err := assembleWithoutDB(t, c.plugins...)
|
||||
if err == nil {
|
||||
t.Fatalf("%s: assembled", name)
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(err.Error(), w) {
|
||||
t.Fatalf("%s: err = %v, want %q", name, err, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
contributor := newAccountContributor("acme.links")
|
||||
h, err := assembleWithoutDB(t, contributor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := &bouncer.Principal{ID: 42, Backend: true}
|
||||
manager := &bouncer.Principal{ID: 43, Backend: true, PermissionGrants: map[string]bool{"acme.links.manage": true}}
|
||||
|
||||
t.Run("list filtered by permissions and Applies", func(t *testing.T) {
|
||||
if got := listedActions(t, asAdmin(h, http.MethodGet, "/account/contributions", admin, nil)); strings.Join(got, ",") != "open" {
|
||||
t.Fatalf("admin sees %v", got)
|
||||
}
|
||||
contributor.relink.Store(true)
|
||||
defer contributor.relink.Store(false)
|
||||
if got := listedActions(t, asAdmin(h, http.MethodGet, "/account/contributions", manager, nil)); strings.Join(got, ",") != "open,manage,relink" {
|
||||
t.Fatalf("manager sees %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("run, stale Applies and missing permission", func(t *testing.T) {
|
||||
rec := asAdmin(h, http.MethodPost, "/account/contributions/site_link/actions/open", admin, nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"url":"https://site.example.test/link"`) {
|
||||
t.Fatalf("open = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
contributor.relink.Store(true)
|
||||
listedActions(t, asAdmin(h, http.MethodGet, "/account/contributions", admin, nil))
|
||||
contributor.relink.Store(false)
|
||||
if rec := asAdmin(h, http.MethodPost, "/account/contributions/site_link/actions/relink", admin, nil); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("relink after Applies turned false = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := asAdmin(h, http.MethodPost, "/account/contributions/site_link/actions/manage", admin, nil); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("manage without the permission = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("personal tokens are refused", func(t *testing.T) {
|
||||
before := len(*contributor.runs)
|
||||
for _, rel := range []string{"/account/contributions", "/account/contributions/site_link/actions/open"} {
|
||||
method := http.MethodGet
|
||||
if strings.Contains(rel, "/actions/") {
|
||||
method = http.MethodPost
|
||||
}
|
||||
rec := asAdmin(h, method, rel, admin, &cabana.BackendAPIToken{ID: 3, BackendUserID: 42})
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "interactive_only") {
|
||||
t.Fatalf("%s with a token credential = %d %s", rel, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if len(*contributor.runs) != before {
|
||||
t.Fatal("a token ran the action")
|
||||
}
|
||||
})
|
||||
|
||||
// The Postgres part runs on a Docker host (the gate's docker stage);
|
||||
// under -short it is not created, so no subtest is reported as skipped.
|
||||
if testing.Short() {
|
||||
return
|
||||
}
|
||||
t.Run("transaction and unchanged payloads", func(t *testing.T) {
|
||||
with := newAccountContributor("acme.links")
|
||||
env := newScreensEnv(t, with)
|
||||
plain := newScreensEnv(t)
|
||||
_, token := env.admin(t, env.unique("account"), false, `{}`)
|
||||
env.expect(t, http.StatusOK, http.MethodPost, "/account/contributions/site_link/actions/open", token, map[string]any{})
|
||||
if runs := *with.runs; len(runs) != 1 || !runs[0] {
|
||||
t.Fatalf("runs (with a transaction) = %v", runs)
|
||||
}
|
||||
for _, rel := range []string{"/auth/me", "/account/preferences", "/account/tokens", "/account/tokens/scopes"} {
|
||||
a, b := plain.call(t, http.MethodGet, rel, token, nil), env.call(t, http.MethodGet, rel, token, nil)
|
||||
if a.Code != http.StatusOK || a.Code != b.Code || !bytes.Equal(a.Body.Bytes(), b.Body.Bytes()) {
|
||||
t.Fatalf("%s differs with a contributor:\nwithout %d %s\nwith %d %s", rel, a.Code, a.Body.String(), b.Code, b.Body.String())
|
||||
}
|
||||
}
|
||||
if rec := plain.call(t, http.MethodGet, "/account/contributions", token, nil); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"data":[]`) {
|
||||
t.Fatalf("no contributor = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// badContributor contributes the sections it is given.
|
||||
type badContributor struct {
|
||||
id string
|
||||
sections []pact.AccountContribution
|
||||
}
|
||||
|
||||
func (p badContributor) ID() string { return p.id }
|
||||
func (badContributor) Requires() []string { return nil }
|
||||
func (badContributor) Register(*backpack.App) error { return nil }
|
||||
func (badContributor) Boot(*backpack.App) error { return nil }
|
||||
func (p badContributor) AccountContributions() []pact.AccountContribution { return p.sections }
|
||||
@@ -35,13 +35,22 @@ func (p linkPlugin) AdminActionExtensions() []pact.AdminActionExtension {
|
||||
*p.values = append(*p.values, in.Value)
|
||||
return pact.AdminRecordActionResult{Message: "Linked " + in.Value}, nil
|
||||
},
|
||||
}, {
|
||||
Name: "acme_plain", Label: "Plain action",
|
||||
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
*p.values = append(*p.values, "plain:"+in.Value)
|
||||
return pact.AdminRecordActionResult{Message: "Plain"}, nil
|
||||
},
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
// TestRecordActionPromptRun (Postgres): the preview carries the prompt, a
|
||||
// posted payload reaches Run trimmed as Value, and an empty value is 422 on
|
||||
// payload without running the action.
|
||||
// posted payload reaches Run trimmed as Value, an empty value is 422 on
|
||||
// payload without running the action, and an action without a prompt runs
|
||||
// with an empty Value.
|
||||
func TestRecordActionPromptRun(t *testing.T) {
|
||||
var values []string
|
||||
env := newScreensEnv(t, linkPlugin{mu: &sync.Mutex{}, values: &values})
|
||||
@@ -73,4 +82,10 @@ func TestRecordActionPromptRun(t *testing.T) {
|
||||
if result["message"] != "Linked acme" || len(values) != 1 || values[0] != "acme" {
|
||||
t.Fatalf("result = %v values = %v", result, values)
|
||||
}
|
||||
|
||||
// An action without a prompt runs with an empty Value.
|
||||
env.expect(t, http.StatusOK, http.MethodPost, userURL(target.ID, "actions", "acme_plain"), token, map[string]any{})
|
||||
if len(values) != 2 || values[1] != "plain:" {
|
||||
t.Fatalf("plain action values = %v", values)
|
||||
}
|
||||
}
|
||||
|
||||
124
modules/cabana/record_action_prompt_test.go
Normal file
124
modules/cabana/record_action_prompt_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
// TestRecordActionPrompt (D-34): a prompted record action's prompt is
|
||||
// localized in the show payload (label, placeholder and comment in the
|
||||
// request's language, the default cap of 255 when MaxLength is 0); its
|
||||
// value is trimmed and capped in runes; a non-string or empty payload is
|
||||
// 422 on payload; an action without a Prompt refuses any payload; and a
|
||||
// prompt-free value is the empty string. The Postgres run of the value
|
||||
// into Run, and the empty Value a plain action receives, are pinned by
|
||||
// TestRecordActionPromptRun.
|
||||
func TestRecordActionPrompt(t *testing.T) {
|
||||
cat := phrasebook.NewCatalog()
|
||||
if err := cat.Load("acme", fstest.MapFS{
|
||||
"lang/en/lang.yaml": {Data: []byte("site_login: Site login\nsite_login_hint: The login on the public site.\nlogin_placeholder: login\n")},
|
||||
"lang/pl/lang.yaml": {Data: []byte("site_login: Login na stronie\nsite_login_hint: Login w serwisie publicznym.\nlogin_placeholder: login\n")},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
|
||||
|
||||
var got []string
|
||||
extender := screensExtender{extensions: []pact.AdminActionExtension{{
|
||||
Controller: AdministratorsControllerID,
|
||||
RecordActions: []pact.AdminRecordAction{
|
||||
promptedAction("acme_link", &pact.AdminActionPrompt{Label: "acme::lang.site_login", Placeholder: "acme::lang.login_placeholder", Comment: "acme::lang.site_login_hint", MaxLength: 5}, &got),
|
||||
promptedAction("acme_note", &pact.AdminActionPrompt{Label: "acme::lang.site_login"}, &got),
|
||||
pingAction(),
|
||||
},
|
||||
}}}
|
||||
reg, err := compileWithScreens(t, []party.Plugin{extender})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cc, _ := reg.Get(AdministratorsControllerID)
|
||||
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
|
||||
|
||||
t.Run("prompt localized in the show payload", func(t *testing.T) {
|
||||
for locale, want := range map[string][2]string{
|
||||
"en": {"Site login", "The login on the public site."},
|
||||
"pl": {"Login na stronie", "Login w serwisie publicznym."},
|
||||
} {
|
||||
ctx := towel.WithLocale(bouncer.WithUser(context.Background(), super), locale)
|
||||
offered, err := (CRUDService{tr: tr}).offeredRecordActions(ctx, cc, &BackendUser{ID: 5, IsActivated: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, a := range offered {
|
||||
switch a.Name {
|
||||
case "acme_link":
|
||||
if a.Prompt == nil || a.Prompt.Label != want[0] || a.Prompt.Comment != want[1] || a.Prompt.Placeholder != "login" || a.Prompt.MaxLength != 5 {
|
||||
t.Fatalf("%s acme_link prompt = %+v", locale, a.Prompt)
|
||||
}
|
||||
case "acme_note":
|
||||
if a.Prompt == nil || a.Prompt.Label != want[0] || a.Prompt.MaxLength != 255 {
|
||||
t.Fatalf("%s acme_note prompt = %+v", locale, a.Prompt)
|
||||
}
|
||||
case "acme_ping":
|
||||
if a.Prompt != nil {
|
||||
t.Fatalf("acme_ping carries a prompt: %+v", a.Prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("value trimmed and capped in runes", func(t *testing.T) {
|
||||
prompt := &pact.AdminActionPrompt{MaxLength: 5}
|
||||
for raw, want := range map[string]string{`" ąęś "`: "ąęś", `"abcde"`: "abcde", "\"\\tabc\\n\"": "abc"} {
|
||||
if v, err := promptValue(context.Background(), nil, prompt, json.RawMessage(raw)); err != nil || v != want {
|
||||
t.Fatalf("%s = %q, %v", raw, v, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{`"abcdef"`, `"ąęśćżź"`, `""`, `" "`, `7`, `null`, `["a"]`, `{"v":"a"}`} {
|
||||
if v, err := promptValue(context.Background(), nil, prompt, json.RawMessage(raw)); err == nil {
|
||||
t.Fatalf("%s accepted as %q", raw, v)
|
||||
}
|
||||
}
|
||||
if v, err := promptValue(context.Background(), nil, nil, json.RawMessage(`"x"`)); err != nil || v != "" {
|
||||
t.Fatalf("no prompt = %q, %v", v, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("route refusals", func(t *testing.T) {
|
||||
svc := &service{app: backpack.New(nil), reg: reg}
|
||||
post := func(action, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
for k, v := range map[string]string{"vendor": "summercms", "plugin": "cabana", "controller": "users", "id": "5", "action": action} {
|
||||
req.SetPathValue(k, v)
|
||||
}
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), super))
|
||||
rec := httptest.NewRecorder()
|
||||
svc.recordAction(rec, req)
|
||||
return rec
|
||||
}
|
||||
for _, body := range []string{`{"payload":42}`, `{"payload":true}`, `{"payload":"abcdefgh"}`, `{"payload":""}`, `{}`} {
|
||||
if rec := post("acme_link", body); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"payload"`) {
|
||||
t.Fatalf("%s = %d %s", body, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if rec := post("acme_ping", `{"payload":"x"}`); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("payload on a plain action = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("a refused request ran the action: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
49
modules/cabana/scope_limit_for_test.go
Normal file
49
modules/cabana/scope_limit_for_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// TestScopeLimitForMethods (phase 19.7 D-22, the scope ceiling of an admin
|
||||
// personal token on the MCP endpoint): ScopeLimitFor filters a token's
|
||||
// stored scopes by the dispatched route's method exactly as the backend
|
||||
// guard does for a request. A GET-only scope limits GET and leaves no scope
|
||||
// for POST (known 1, method-allowed 0), "*" is full for every method, and
|
||||
// scopes the registry no longer knows count as none (unauthenticated).
|
||||
func TestScopeLimitForMethods(t *testing.T) {
|
||||
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{
|
||||
id: "acme.demo",
|
||||
perms: []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}},
|
||||
scopes: []pact.TokenScope{{
|
||||
Name: "demo-read", Label: "Demo", Description: "Reads the demo.",
|
||||
Permissions: []string{"acme.demo.access"}, Methods: []string{"GET", "HEAD"},
|
||||
}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish(&backendResolver{guard: &compositeGuard{reg: reg}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
limit, known, ok := ScopeLimitFor(app, []string{"demo-read"}, http.MethodGet)
|
||||
if limit == nil || limit.Full || known != 1 || ok != 1 || len(limit.Scopes) != 1 || !slices.Equal(limit.Scopes[0], []string{"acme.demo.access"}) {
|
||||
t.Fatalf("GET = %+v, %d, %d", limit, known, ok)
|
||||
}
|
||||
if limit, known, ok := ScopeLimitFor(app, []string{"demo-read"}, http.MethodPost); limit != nil || known != 1 || ok != 0 {
|
||||
t.Fatalf("POST = %+v, %d, %d, want no scope for the method", limit, known, ok)
|
||||
}
|
||||
if limit, known, ok := ScopeLimitFor(app, []string{"*"}, http.MethodDelete); limit == nil || !limit.Full || known != 1 || ok != 1 {
|
||||
t.Fatalf("* DELETE = %+v, %d, %d, want full", limit, known, ok)
|
||||
}
|
||||
if limit, known, ok := ScopeLimitFor(app, []string{"gone"}, http.MethodGet); limit != nil || known != 0 || ok != 0 {
|
||||
t.Fatalf("unknown scope = %+v, %d, %d", limit, known, ok)
|
||||
}
|
||||
}
|
||||
212
modules/pier/catalog_test.go
Normal file
212
modules/pier/catalog_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
173
modules/pier/dispatch_test.go
Normal file
173
modules/pier/dispatch_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
135
modules/pier/fanout_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
138
modules/pier/guard_rules_test.go
Normal file
138
modules/pier/guard_rules_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
112
modules/pier/sessions_test.go
Normal file
112
modules/pier/sessions_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
}
|
||||
100
modules/surf/pier_mount_test.go
Normal file
100
modules/surf/pier_mount_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
164
modules/surf/streaming_test.go
Normal file
164
modules/surf/streaming_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
80
modules/wristband/mcp_scopes_test.go
Normal file
80
modules/wristband/mcp_scopes_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user