diff --git a/admin/tests/account/AccountContributions.test.ts b/admin/tests/account/AccountContributions.test.ts index 79eafc80..3ed4d4cc 100644 --- a/admin/tests/account/AccountContributions.test.ts +++ b/admin/tests/account/AccountContributions.test.ts @@ -141,4 +141,83 @@ describe('My account contributions', () => { expect(wrapper.findAll('[data-account-contribution]')).toHaveLength(0) expect(wrapper.find('[data-api-tokens]').exists()).toBe(true) }) + + // Plan 07 hardening (B-WR-03, B-WR-06, B-IN-01). + + it('a javascript: URL answer renders no link and never opens a window', async () => { + const opened = vi.spyOn(window, 'open').mockImplementation(() => null) + const { wrapper } = await openAccount({ + [`POST ${OPEN}`]: envelope({ message: 'Done.', url: 'javascript:alert(1)' }), + }) + await wrapper.find('[data-account-contribution-action="open"]').trigger('click') + await flushPromises() + expect(opened).not.toHaveBeenCalled() + expect(wrapper.find('[data-account-contribution-url]').exists()).toBe(false) + expect(wrapper.html()).not.toContain('javascript:') + expect(toasts.value.map((toast) => toast.text)).toEqual(['Done.']) + }) + + it('a section with error: true shows the section error text', async () => { + setBundle({ ...langFixture.data, ...strings, 'backend::lang.myaccount.section_error': { other: 'This section could not be loaded.' } }, 'en') + const { wrapper } = await openAccount({ + [`GET ${CONTRIBUTIONS}`]: envelope([{ ...sections[0], status: '', error: true }, sections[1]]), + }) + const cards = wrapper.findAll('[data-account-contribution]') + expect(cards[0]!.find('[data-account-contribution-error]').text()).toBe('This section could not be loaded.') + expect(cards[0]!.find('[data-account-contribution-status]').exists()).toBe(false) + expect(cards[1]!.find('[data-account-contribution-error]').exists()).toBe(false) + expect(cards[1]!.find('[data-account-contribution-status]').text()).toBe('Weekly') + }) + + it('a 500 on the list reload shows the error toast and keeps the earlier sections', async () => { + setBundle({ ...langFixture.data, ...strings, 'backend::lang.form.error_generic': { other: 'Something went wrong.' } }, 'en') + let loads = 0 + const { wrapper } = await openAccount({ + [`GET ${CONTRIBUTIONS}`]: () => { + loads++ + return loads === 1 + ? envelope(sections) + : { status: 500, body: { error: { code: 'server_error', message: 'Server error', details: {} } } } + }, + [`POST ${OPEN}`]: envelope({ message: 'Done.', url: '' }), + }) + await wrapper.find('[data-account-contribution-action="open"]').trigger('click') + await flushPromises() + expect(loads).toBe(2) + expect(wrapper.findAll('[data-account-contribution]').map((card) => card.attributes('data-account-contribution'))).toEqual([ + 'site_link', + 'newsletter', + ]) + // Newest toast first. + expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([ + ['Something went wrong.', 'danger'], + ['Done.', 'success'], + ]) + }) + + it('the confirm and stale texts are the My account ones, not the record form ones', async () => { + setBundle( + { + ...langFixture.data, + ...strings, + 'backend::lang.myaccount.action_confirm': { other: 'Run “:action”?' }, + 'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' }, + 'backend::lang.form.action_stale': { other: 'This action no longer applies to this record.' }, + }, + 'en', + ) + const STOP = `${CONTRIBUTIONS}/newsletter/actions/stop` + const { wrapper } = await openAccount({ + [`GET ${CONTRIBUTIONS}`]: envelope([sections[0], { ...sections[1], actions: [{ ...sections[1]!.actions[0], confirm: '' }] }]), + [`POST ${STOP}`]: { status: 409, body: { error: { code: 'conflict', message: 'Conflict', details: {} } } }, + }) + await wrapper.find('[data-account-contribution-action="stop"]').trigger('click') + await flushPromises() + const dialog = document.body.querySelector('[data-confirm-dialog]') + expect(dialog?.textContent).toContain('Run “Stop the digest”?') + expect(dialog?.textContent).not.toContain('on this record') + dialog!.querySelector('[data-action="confirm"]')!.click() + await flushPromises() + expect(toasts.value.map((toast) => toast.text)).toEqual(['This action no longer applies.']) + }) }) diff --git a/admin/tests/app/hotkeysEditing.test.ts b/admin/tests/app/hotkeysEditing.test.ts new file mode 100644 index 00000000..725fe6fc --- /dev/null +++ b/admin/tests/app/hotkeysEditing.test.ts @@ -0,0 +1,181 @@ +// Shortcuts while editing (phase 19.7 plan 07, B-WR-01 and B-WR-02): plain +// keys never fire from an input inside an open shadow root or from a custom +// field element inside a form (a closed root hides its input), and the save +// shortcuts commit a taglist's typed-but-uncommitted tag before saving, the +// way a pointer click on the button does. No application names appear in +// framework tests. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises } from '@vue/test-utils' +import { closeShortcutHelp, installHotkeys, shortcutHelp } from '../../src/app/hotkeys' +import { setBundle } from '../../src/app/i18n' +import { clearUser } from '../../src/state/useAuth' +import { setNavigation } from '../../src/state/useNavigation' +import { setSettings } from '../../src/state/useSettings' +import { clearToasts } from '../../src/state/useToasts' +import lang from '../fixtures/lang.json' +import formSchema from '../fixtures/widgets.form-schema.json' +import record from '../fixtures/widgets.record.json' +import listSchema from '../fixtures/widgets.list-schema.json' +import listRows from '../fixtures/widgets.list.json' +import { API, mountApp, press, requestsTo, wait, type Route } from '../helpers' + +let uninstalls: Array<() => void> = [] + +beforeEach(() => { + clearUser() + setNavigation([]) + setSettings([]) + clearToasts() + setBundle(lang.data, 'pl') + vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Linux x86_64') +}) + +afterEach(() => { + for (const uninstall of uninstalls) { + uninstall() + } + uninstalls = [] + closeShortcutHelp() + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +function hotkeyButton(hotkey: string): { element: HTMLButtonElement; clicks: () => number } { + const element = document.createElement('button') + element.setAttribute('data-hotkey', hotkey) + let count = 0 + element.addEventListener('click', () => count++) + document.body.appendChild(element) + return { element, clicks: () => count } +} + +function composedKey(target: EventTarget, key: string): KeyboardEvent { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, composed: true }) + target.dispatchEvent(event) + return event +} + +class ClosedField extends HTMLElement { + constructor() { + super() + const root = this.attachShadow({ mode: 'closed' }) + root.appendChild(document.createElement('input')) + } +} +if (!customElements.get('acme-closed-field')) { + customElements.define('acme-closed-field', ClosedField) +} + +describe('plain keys from editable controls hidden in shadow DOM', () => { + it('ignores ? and Escape from an input inside an open shadow root', () => { + uninstalls.push(installHotkeys()) + const leave = hotkeyButton('escape') + const host = document.createElement('div') + document.body.appendChild(host) + const input = document.createElement('input') + host.attachShadow({ mode: 'open' }).appendChild(input) + + const escape = composedKey(input, 'Escape') + const help = composedKey(input, '?') + expect(leave.clicks()).toBe(0) + expect(escape.defaultPrevented).toBe(false) + expect(help.defaultPrevented).toBe(false) + expect(shortcutHelp.value).toBeNull() + + // A browser retargets the event to the shadow host by the time it + // reaches window; only composedPath() still starts at the input. + for (const key of ['Escape', '?']) { + const retargeted = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, composed: true }) + Object.defineProperty(retargeted, 'composedPath', { value: () => [input, host.shadowRoot, host, document.body, document, window] }) + host.dispatchEvent(retargeted) + expect(retargeted.defaultPrevented).toBe(false) + } + expect(leave.clicks()).toBe(0) + expect(shortcutHelp.value).toBeNull() + + // The same keys from the page itself still work. + composedKey(document.body, 'Escape') + expect(leave.clicks()).toBe(1) + }) + + it('ignores ? and Escape from a custom field element inside a form (closed shadow root)', () => { + uninstalls.push(installHotkeys()) + const leave = hotkeyButton('escape') + const form = document.createElement('form') + const field = document.createElement('acme-closed-field') + form.appendChild(field) + document.body.appendChild(form) + + const escape = composedKey(field, 'Escape') + const help = composedKey(field, '?') + expect(leave.clicks()).toBe(0) + expect(escape.defaultPrevented).toBe(false) + expect(help.defaultPrevented).toBe(false) + expect(shortcutHelp.value).toBeNull() + + // Outside a form a custom element is not an editable control. + const loose = document.createElement('acme-closed-field') + document.body.appendChild(loose) + composedKey(loose, 'Escape') + expect(leave.clicks()).toBe(1) + }) +}) + +describe('save shortcuts commit a taglist draft', () => { + const BASE = `${API}/acme/demo/widgets` + const RECORD = `${BASE}/1` + const schema = structuredClone(formSchema) + schema.data.fields.push({ name: 'labels', label: 'Labels', type: 'taglist', span: 'full', mode: 'array' } as never) + const loaded = structuredClone(record) + ;(loaded.data as Record).labels = ['first'] + const routes: Record = { + [`GET ${BASE}/schema/form`]: { body: schema }, + [`GET ${RECORD}`]: { body: loaded }, + [`PUT ${RECORD}`]: { body: loaded }, + [`GET ${BASE}/schema/list`]: { body: listSchema }, + [`GET ${BASE}`]: { body: listRows }, + } + + async function typeDraft(): Promise { + const input = document.querySelector('#field-labels') + expect(input).not.toBeNull() + input!.focus() + input!.value = 'draft' + input!.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() + return input! + } + + async function putBody(calls: Request[]): Promise> { + const puts = requestsTo(calls, 'PUT', RECORD) + expect(puts).toHaveLength(1) + return (await puts[0]!.clone().json()) as Record + } + + it('Ctrl+S saves the record with the draft tag', async () => { + const { calls } = await mountApp('/acme/demo/widgets/1', routes, { attach: true }) + const input = await typeDraft() + + expect(press(input, 's', { ctrlKey: true }).defaultPrevented).toBe(true) + await flushPromises() + await wait(0) + await flushPromises() + const body = await putBody(calls) + expect(JSON.stringify(body)).toContain('"draft"') + expect(JSON.stringify(body)).toContain('"first"') + }) + + it('Ctrl+Enter saves the draft tag before closing', async () => { + const { calls, router } = await mountApp('/acme/demo/widgets/1', routes, { attach: true }) + const input = await typeDraft() + + expect(press(input, 'Enter', { ctrlKey: true }).defaultPrevented).toBe(true) + await flushPromises() + await wait(0) + await flushPromises() + const body = await putBody(calls) + expect(JSON.stringify(body)).toContain('"draft"') + expect(router.currentRoute.value.path).toBe('/acme/demo/widgets') + }) +}) diff --git a/modules/cabana/account_contributions_hardening_test.go b/modules/cabana/account_contributions_hardening_test.go new file mode 100644 index 00000000..a3b42108 --- /dev/null +++ b/modules/cabana/account_contributions_hardening_test.go @@ -0,0 +1,188 @@ +package cabana_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "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/pact" +) + +// syncLog is a goroutine-safe slog destination. +type syncLog struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncLog) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncLog) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// captureProcessLog routes the process logger (and so diary's components) +// into a buffer for the rest of the test. +func captureProcessLog(t *testing.T) *syncLog { + t.Helper() + buf := &syncLog{} + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + return buf +} + +// sectionPlugin contributes the sections it is given. +type sectionPlugin struct { + id string + sections []pact.AccountContribution +} + +func (p sectionPlugin) ID() string { return p.id } +func (sectionPlugin) Requires() []string { return nil } +func (sectionPlugin) Register(*backpack.App) error { return nil } +func (sectionPlugin) Boot(*backpack.App) error { return nil } +func (p sectionPlugin) AccountContributions() []pact.AccountContribution { return p.sections } + +// contributionViews decodes the list answer. +type contributionViews struct { + Data []struct { + Name string `json:"name"` + Status string `json:"status"` + Error bool `json:"error"` + Actions []struct { + Name string `json:"name"` + } `json:"actions"` + } `json:"data"` +} + +// TestAccountContributionURL pins plan 07's URL rule for account actions +// (B-WR-03 server side, P-33): an action answering a javascript:, data:, +// vbscript: or protocol-relative URL is answered without it (url empty) and +// the refusal is logged without the URL; an https URL with a fragment and a +// same-origin path pass unchanged. +func TestAccountContributionURL(t *testing.T) { + var answer atomic.Value + answer.Store("") + plugin := sectionPlugin{id: "acme.links", sections: []pact.AccountContribution{{ + Name: "site_link", Label: "Site account", + Actions: []pact.AccountAction{{Name: "open", Label: "Open", Run: func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) { + return pact.AccountActionResult{Message: "Done", URL: answer.Load().(string)}, nil + }}}, + }}} + h, err := assembleWithoutDB(t, plugin) + if err != nil { + t.Fatal(err) + } + admin := &bouncer.Principal{ID: 42, Backend: true} + run := func(t *testing.T, raw string) string { + t.Helper() + answer.Store(raw) + rec := asAdmin(h, http.MethodPost, "/account/contributions/site_link/actions/open", admin, nil) + var out struct { + Data struct { + Message string `json:"message"` + URL string `json:"url"` + } `json:"data"` + } + if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil || out.Data.Message != "Done" { + t.Fatalf("%q: %d %s", raw, rec.Code, rec.Body.String()) + } + return out.Data.URL + } + for _, raw := range []string{"javascript:alert(1)", "data:text/html,x", "//evil.example/x", "vbscript:msgbox(1)"} { + t.Run("drops "+raw, func(t *testing.T) { + logs := captureProcessLog(t) + if got := run(t, raw); got != "" { + t.Fatalf("%q answered url %q", raw, got) + } + logged := logs.String() + if !strings.Contains(logged, "answered a non-http URL") { + t.Fatalf("the refusal is not logged: %s", logged) + } + if strings.Contains(logged, raw) { + t.Fatalf("the log carries the refused URL: %s", logged) + } + }) + } + for _, raw := range []string{"https://site/link-admin#code=x", "/relative"} { + t.Run("keeps "+raw, func(t *testing.T) { + if got := run(t, raw); got != raw { + t.Fatalf("%q answered url %q", raw, got) + } + }) + } +} + +// TestAccountContributionsIsolation pins plan 07's per-section isolation +// (B-WR-06 server side, B-IN-03): a plugin whose Status fails gets +// error: true and an empty status while another plugin's section and +// actions are intact and the list answers 200; an action whose Applies +// fails is dropped alone; and a dynamic status text is sent as is, never +// looked up as a phrase key. +func TestAccountContributionsIsolation(t *testing.T) { + run := func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) { + return pact.AccountActionResult{Message: "Done"}, nil + } + failing := sectionPlugin{id: "acme.alpha", sections: []pact.AccountContribution{{ + Name: "alpha", Label: "Alpha", + Status: func(context.Context, uint) (string, error) { return "", errors.New("alpha: database is down") }, + Actions: []pact.AccountAction{ + {Name: "keep", Label: "Keep", Run: run}, + {Name: "broken", Label: "Broken", Run: run, Applies: func(context.Context, uint) (bool, error) { + return false, errors.New("alpha: applies failed") + }}, + }, + }}} + healthy := sectionPlugin{id: "acme.beta", sections: []pact.AccountContribution{{ + Name: "beta", Label: "Beta", + Status: func(context.Context, uint) (string, error) { return "Linked as jan@example.test", nil }, + Actions: []pact.AccountAction{{Name: "open", Label: "Open", Run: run}}, + }}} + h, err := assembleWithoutDB(t, failing, healthy) + if err != nil { + t.Fatal(err) + } + logs := captureProcessLog(t) + rec := asAdmin(h, http.MethodGet, "/account/contributions", &bouncer.Principal{ID: 42, Backend: true}, nil) + var out contributionViews + if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil || len(out.Data) != 2 { + t.Fatalf("list = %d %s", rec.Code, rec.Body.String()) + } + alpha, beta := out.Data[0], out.Data[1] + if alpha.Name != "alpha" || !alpha.Error || alpha.Status != "" { + t.Fatalf("failing section = %+v", alpha) + } + if len(alpha.Actions) != 1 || alpha.Actions[0].Name != "keep" { + t.Fatalf("failing section's actions = %+v (the Applies error drops only that action)", alpha.Actions) + } + if beta.Name != "beta" || beta.Error || beta.Status != "Linked as jan@example.test" || len(beta.Actions) != 1 || beta.Actions[0].Name != "open" { + t.Fatalf("healthy section = %+v", beta) + } + if strings.Contains(rec.Body.String(), `"error":false`) { + t.Fatalf("error is sent for a healthy section: %s", rec.Body.String()) + } + logged := logs.String() + if !strings.Contains(logged, "account contribution status failed") || !strings.Contains(logged, "account contribution applies failed") { + t.Fatalf("the plugin failures are not logged: %s", logged) + } + // Labels are phrase keys or literal text, so "Alpha" may be looked up; + // the dynamic status text never is. + if strings.Contains(logged, "jan@example.test") { + t.Fatalf("a dynamic status text was looked up as a phrase key: %s", logged) + } +} diff --git a/modules/cabana/account_token_events_test.go b/modules/cabana/account_token_events_test.go new file mode 100644 index 00000000..c282c650 --- /dev/null +++ b/modules/cabana/account_token_events_test.go @@ -0,0 +1,61 @@ +package cabana_test + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "sync" + "testing" + + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// TestAccountTokenRevokeEvent pins plan 07's token-specific revocation +// (B-WR-04): revoking one g15_ token from My account fires exactly one +// revoked subject event for the admin, after the commit, whose TokenID is +// the revoked row's id, and no event for the admin as a whole (their other +// tokens and OAuth sessions stay). Postgres only: under -short nothing runs. +func TestAccountTokenRevokeEvent(t *testing.T) { + if testing.Short() { + return + } + t.Run("one revoked event with the token id", func(t *testing.T) { + env := newScreensEnv(t) + var mu sync.Mutex + var got []pact.MCPSubjectChanged + env.app.Events.Listen("test.revoke", func(_ context.Context, ev *pact.MCPSubjectChanged) error { + mu.Lock() + got = append(got, *ev) + mu.Unlock() + return nil + }) + admin, jwt := env.admin(t, env.unique("revoke"), true, "") + ids := make([]uint, 2) + for i := range ids { + out := env.expect(t, http.StatusCreated, http.MethodPost, "/account/tokens", jwt, map[string]any{ + "name": env.unique("agent"), "expires": "90d", "scopes": []string{"*"}, + }) + raw, _ := json.Marshal(out["data"]) + var created cabana.AdminAPITokenCreated + if err := json.Unmarshal(raw, &created); err != nil || created.ID == 0 { + t.Fatalf("created token = %s (%v)", raw, err) + } + ids[i] = created.ID + } + mu.Lock() + got = nil + mu.Unlock() + env.expect(t, http.StatusOK, http.MethodDelete, "/account/tokens/"+strconv.FormatUint(uint64(ids[0]), 10), jwt, map[string]any{}) + mu.Lock() + defer mu.Unlock() + if len(got) != 1 { + t.Fatalf("revoking one token fired %d events, want 1: %+v", len(got), got) + } + ev := got[0] + if ev.Kind != pact.MCPSubjectRevoked || ev.Guard != "backend" || ev.UserID != admin.ID || ev.TokenID != ids[0] || ev.ClientID != "" { + t.Fatalf("event = %+v, want revoked backend %d with token %d", ev, admin.ID, ids[0]) + } + }) +} diff --git a/modules/cabana/record_action_prompt_localized_test.go b/modules/cabana/record_action_prompt_localized_test.go new file mode 100644 index 00000000..ad9d1920 --- /dev/null +++ b/modules/cabana/record_action_prompt_localized_test.go @@ -0,0 +1,68 @@ +package cabana + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "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" +) + +// TestRecordActionPromptLocalized pins plan 07's localized prompt refusal +// (B-IN-02): a prompted record action posted a non-string payload answers +// 422 on payload with the backend::lang.form.action_prompt_invalid text in +// the request's language (en and pl), and the action never runs. +func TestRecordActionPromptLocalized(t *testing.T) { + var got []string + extender := screensExtender{extensions: []pact.AdminActionExtension{{ + Controller: AdministratorsControllerID, + RecordActions: []pact.AdminRecordAction{promptedAction("acme_link", &pact.AdminActionPrompt{Label: "Login"}, &got)}, + }}} + reg, err := compileWithScreens(t, []party.Plugin{extender}) + if err != nil { + t.Fatal(err) + } + app := backpack.New(nil) + if err := phrasebook.Activate(app, []party.Plugin{}); err != nil { + t.Fatal(err) + } + svc := &service{app: app, reg: reg} + if svc.translator() == nil { + t.Fatal("no translator published") + } + super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true} + for locale, want := range map[string]string{"en": "The answer must be text.", "pl": "Odpowiedź musi być tekstem."} { + for _, body := range []string{`{"payload":42}`, `{"payload":true}`, `{"payload":["a"]}`, `{"payload":{"v":"a"}}`} { + t.Run(locale+" "+body, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + for k, v := range map[string]string{"vendor": "summercms", "plugin": "cabana", "controller": "users", "id": "5", "action": "acme_link"} { + req.SetPathValue(k, v) + } + req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), super), locale)) + rec := httptest.NewRecorder() + svc.recordAction(rec, req) + var out struct { + Error struct { + Details map[string][]string `json:"details"` + } `json:"error"` + } + if rec.Code != http.StatusUnprocessableEntity || json.Unmarshal(rec.Body.Bytes(), &out) != nil { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + if msgs := out.Error.Details["payload"]; len(msgs) != 1 || msgs[0] != want { + t.Fatalf("payload errors = %v, want [%q] (%s)", msgs, want, rec.Body.String()) + } + }) + } + } + if len(got) != 0 { + t.Fatalf("a refused request ran the action: %v", got) + } +} diff --git a/modules/cabana/token_resolver_remote_test.go b/modules/cabana/token_resolver_remote_test.go new file mode 100644 index 00000000..9151a8e9 --- /dev/null +++ b/modules/cabana/token_resolver_remote_test.go @@ -0,0 +1,78 @@ +package cabana_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/towel" +) + +// TestResolveBackendTokenRemote pins plan 07's client address on the MCP +// token audit line (B-IN-04): ResolveBackendToken logs the address the +// caller put on the context with towel.WithClientIP as remote, on a failed +// and on a successful resolve, and never the token. +func TestResolveBackendTokenRemote(t *testing.T) { + const bogus = "g15_0000000000000000000000000000000000000000" + + t.Run("a failed resolve without the admin logs remote", func(t *testing.T) { + logs := captureProcessLog(t) + ctx := towel.WithClientIP(context.Background(), "198.51.100.4") + if _, _, _, err := cabana.ResolveBackendToken(ctx, backpack.New(nil), bogus); !errors.Is(err, cabana.ErrUnauthenticated) { + t.Fatalf("err = %v", err) + } + logged := logs.String() + if !strings.Contains(logged, "admin.auth") || !strings.Contains(logged, "outcome=failed") || !strings.Contains(logged, "remote=198.51.100.4") { + t.Fatalf("audit line = %s", logged) + } + }) + + // 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("failed and successful resolves log remote", func(t *testing.T) { + env := newScreensEnv(t) + admin, jwt := env.admin(t, env.unique("remote"), true, "") + out := env.expect(t, http.StatusCreated, http.MethodPost, "/account/tokens", jwt, map[string]any{ + "name": env.unique("agent"), "expires": "90d", "scopes": []string{"*"}, + }) + raw, _ := json.Marshal(out["data"]) + var created cabana.AdminAPITokenCreated + if err := json.Unmarshal(raw, &created); err != nil || created.Token == "" { + t.Fatalf("created token = %s (%v)", raw, err) + } + logs := captureProcessLog(t) + ctx := towel.WithClientIP(context.Background(), "203.0.113.9") + if _, _, _, err := cabana.ResolveBackendToken(ctx, env.app, bogus); !errors.Is(err, cabana.ErrUnauthenticated) { + t.Fatalf("unknown token err = %v", err) + } + principal, row, _, err := cabana.ResolveBackendToken(ctx, env.app, created.Token) + if err != nil || principal == nil || principal.ID != admin.ID || row == nil || row.ID != created.ID { + t.Fatalf("resolve = %+v, %+v, %v", principal, row, err) + } + var failed, success bool + for _, line := range strings.Split(logs.String(), "\n") { + if !strings.Contains(line, "admin.auth") { + continue + } + if strings.Contains(line, created.Token) || strings.Contains(line, bogus) { + t.Fatalf("the audit line carries a token: %s", line) + } + if !strings.Contains(line, "remote=203.0.113.9") { + t.Fatalf("audit line without the client address: %s", line) + } + failed = failed || strings.Contains(line, "outcome=failed") + success = success || strings.Contains(line, "outcome=success") + } + if !failed || !success { + t.Fatalf("audit lines (failed %v, success %v): %s", failed, success, logs.String()) + } + }) +} diff --git a/modules/cabana/token_scope_name_test.go b/modules/cabana/token_scope_name_test.go new file mode 100644 index 00000000..ffada275 --- /dev/null +++ b/modules/cabana/token_scope_name_test.go @@ -0,0 +1,41 @@ +package cabana + +import ( + "testing" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// TestTokenScopeNameParity pins plan 07's shared scope name rule (P-30): +// cabana's plugin scope validation accepts and refuses exactly the names +// pact.ValidTokenScopeName does (the table of pact's TestFrontendTokenScopes; +// "*" is refused by both, by cabana also as reserved). +func TestTokenScopeNameParity(t *testing.T) { + names := []struct { + name string + ok bool + }{ + {"read", true}, + {"servers:write", true}, + {"a_b-c:d", true}, + {"", false}, + {"Read", false}, + {"servers:", false}, + {":x", false}, + {"a:b:c", false}, + {"*", false}, + {"with space", false}, + } + for _, c := range names { + if got := pact.ValidTokenScopeName(c.name); got != c.ok { + t.Fatalf("pact.ValidTokenScopeName(%q) = %v, want %v (the table drifted from pact's)", c.name, got, c.ok) + } + cat := &tokenScopeCatalog{byName: map[string]*compiledTokenScope{}} + scope := pact.TokenScope{Name: c.name, Label: "Label", Description: "Description", Permissions: []string{"acme.*"}, Methods: []string{"GET"}} + reg := &Registry{permissions: map[string]pact.Permission{"acme.read": {Code: "acme.read"}}} + err := validatePluginTokenScope(reg, "acme.plugin", scope, cat) + if (err == nil) != c.ok { + t.Errorf("validatePluginTokenScope(%q) err = %v, want accepted %v", c.name, err, c.ok) + } + } +} diff --git a/modules/pact/frontend_token_scopes_test.go b/modules/pact/frontend_token_scopes_test.go new file mode 100644 index 00000000..264e0cee --- /dev/null +++ b/modules/pact/frontend_token_scopes_test.go @@ -0,0 +1,58 @@ +package pact + +import "testing" + +// tokenScopeNames is the name table of TestFrontendTokenScopes; cabana's +// TestTokenScopeNameParity checks its own validation against the same +// names. +var tokenScopeNames = []struct { + name string + ok bool +}{ + {"read", true}, + {"servers:write", true}, + {"a_b-c:d", true}, + {"", false}, + {"Read", false}, + {"servers:", false}, + {":x", false}, + {"a:b:c", false}, + {"*", false}, + {"with space", false}, +} + +// sampleScopesPlugin declares member token scopes, as an application's +// plugin does for E-2. +type sampleScopesPlugin struct{} + +func (sampleScopesPlugin) FrontendTokenScopes() []FrontendTokenScope { + return []FrontendTokenScope{{Name: "servers:write", Label: "Report servers", Description: "Lets an agent report server components."}} +} + +// TestFrontendTokenScopes pins plan 07's member token scope seam (E-2, +// P-30): ValidTokenScopeName accepts and refuses the table's names, and a +// plugin declaring FrontendTokenScopes is discovered through +// HasFrontendTokenScopes (and not as an admin HasTokenScopes plugin). +func TestFrontendTokenScopes(t *testing.T) { + t.Run("ValidTokenScopeName", func(t *testing.T) { + for _, c := range tokenScopeNames { + if got := ValidTokenScopeName(c.name); got != c.ok { + t.Errorf("ValidTokenScopeName(%q) = %v, want %v", c.name, got, c.ok) + } + } + }) + t.Run("a sample plugin is discovered", func(t *testing.T) { + var plugin any = sampleScopesPlugin{} + src, ok := plugin.(HasFrontendTokenScopes) + if !ok { + t.Fatal("the sample plugin is not a HasFrontendTokenScopes") + } + scopes := src.FrontendTokenScopes() + if len(scopes) != 1 || scopes[0].Name != "servers:write" || !ValidTokenScopeName(scopes[0].Name) { + t.Fatalf("scopes = %+v", scopes) + } + if _, admin := plugin.(HasTokenScopes); admin { + t.Fatal("a member token scope plugin is also an admin token scope plugin") + } + }) +} diff --git a/modules/pier/catalog_path_fields_test.go b/modules/pier/catalog_path_fields_test.go new file mode 100644 index 00000000..23f28165 --- /dev/null +++ b/modules/pier/catalog_path_fields_test.go @@ -0,0 +1,65 @@ +package pier_test + +import ( + "net/http" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +type optionalPathInput struct { + ID string `json:"id,omitempty" path:"id"` +} + +type requiredPathInput struct { + ID string `json:"id" path:"id"` +} + +type pageFields struct { + Title string `json:"title"` +} + +type embeddedInput struct { + pageFields + ID string `json:"id" path:"id"` +} + +// TestCatalogPathFields pins plan 06's boot rules for tool inputs (the boot +// half of A-WR-02, A-IN-07): a path field marked omitempty fails boot and +// the error names the field, the same field without it compiles, and an +// embedded struct (whose promoted fields would be body fields) fails boot +// on a GET tool and compiles on a POST tool. +func TestCatalogPathFields(t *testing.T) { + route := func(method string) testRoute { return guarded(method, "/fields/{id}") } + tool := func(method string, input any) pact.MCPTool { + return pact.MCPTool{Name: "f", Description: "Fields.", Method: method, Path: "/fields/{id}", Input: input} + } + mount := func(method string, input any) error { + _, err := catalogMount(t, routePlugin{id: "test.fields", routes: []testRoute{route(method)}, tools: []pact.MCPTool{tool(method, input)}}) + return err + } + + t.Run("an omitempty path field fails boot naming the field", func(t *testing.T) { + err := mount(http.MethodGet, optionalPathInput{}) + if err == nil || !strings.Contains(err.Error(), `path field "id" must be required`) { + t.Fatalf("err = %v", err) + } + }) + t.Run("a required path field compiles", func(t *testing.T) { + if err := mount(http.MethodGet, requiredPathInput{}); err != nil { + t.Fatal(err) + } + }) + t.Run("an embedded struct fails boot on a GET tool", func(t *testing.T) { + err := mount(http.MethodGet, embeddedInput{}) + if err == nil || !strings.Contains(err.Error(), `embedded field "pageFields"`) || !strings.Contains(err.Error(), "GET") { + t.Fatalf("err = %v", err) + } + }) + t.Run("an embedded struct compiles on a POST tool", func(t *testing.T) { + if err := mount(http.MethodPost, embeddedInput{}); err != nil { + t.Fatal(err) + } + }) +} diff --git a/modules/pier/dispatch_hardening_test.go b/modules/pier/dispatch_hardening_test.go new file mode 100644 index 00000000..c54899c6 --- /dev/null +++ b/modules/pier/dispatch_hardening_test.go @@ -0,0 +1,207 @@ +package pier_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// dispatchGuard is a fakeGuard whose MCPDispatch answer is given per +// route method. +type dispatchGuard struct { + *fakeGuard + byMethod func(pact.MCPSubject, string) (pact.MCPDispatch, error) +} + +func (g dispatchGuard) MCPDispatch(_ context.Context, s pact.MCPSubject, method string) (pact.MCPDispatch, error) { + return g.byMethod(s, method) +} + +type oneSegmentInput struct { + ID string `json:"id" path:"id"` +} + +type manySegmentsInput struct { + Rest string `json:"rest" path:"rest"` +} + +type numberInput struct { + ID int64 `json:"id" path:"id"` + N int64 `json:"n,omitempty" query:"n"` +} + +// TestDispatchHardening pins plan 06's dispatch hardening (A-WR-01, the +// runtime half of A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08): path values +// the mux never delivers (., .., empty segments, a slash in a single +// segment) are refused before the route; a missing path value is refused; +// a provider error answers a fixed text and its message reaches only the +// log; a large integer keeps its digits in the path and the query; the +// inner request carries the outer Host and forwarding headers but never the +// outer credentials; and a provider whose answer contradicts the tool's +// guard is refused. +func TestDispatchHardening(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var hits atomic.Int64 + seen := func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": r.PathValue("id"), + "rest": r.PathValue("rest"), + "n": r.URL.Query().Get("n"), + "host": r.Host, + "proto": r.Header.Get("X-Forwarded-Proto"), + "fhost": r.Header.Get("X-Forwarded-Host"), + "auth": r.Header.Get("Authorization"), + "cookie": r.Header.Get("Cookie"), + }) + } + hard := routePlugin{id: "test.hard", + routes: []testRoute{ + {method: http.MethodPost, path: "/hard/one/{id}", mw: []string{"acme.auth"}, h: seen}, + {method: http.MethodGet, path: "/hard/many/{rest...}", mw: []string{"acme.auth"}, h: seen}, + {method: http.MethodGet, path: "/hard/num/{id}", mw: []string{"acme.auth"}, h: seen}, + }, + tools: []pact.MCPTool{ + {Name: "one", Method: "POST", Path: "/hard/one/{id}", Input: oneSegmentInput{}}, + {Name: "many", Method: "GET", Path: "/hard/many/{rest...}", Input: manySegmentsInput{}}, + {Name: "num", Method: "GET", Path: "/hard/num/{id}", Input: numberInput{}}, + }} + // One frontend guard provider (an OAuth grant acts under exactly one); + // its dispatch answer depends on the route's method: PUT fails like a + // database error, PATCH names the backend guard, DELETE answers a + // backend principal. + pgxLike := "ERROR: relation \"backend_users\" does not exist (SQLSTATE 42P01)" + guard := dispatchGuard{fakeGuard: &fakeGuard{}} + guard.byMethod = func(s pact.MCPSubject, method string) (pact.MCPDispatch, error) { + switch method { + case http.MethodPut: + return pact.MCPDispatch{}, errors.New(pgxLike) + case http.MethodPatch: + return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID}, Guards: []string{"backend"}}, nil + case http.MethodDelete: + return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID, Backend: true}, Guards: []string{"acme"}}, nil + } + return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID}, Guards: []string{"acme"}}, nil + } + hard.routes = append(hard.routes, + testRoute{method: http.MethodPut, path: "/hard/broken", mw: []string{"acme.auth"}, h: seen}, + testRoute{method: http.MethodPatch, path: "/hard/claims-backend", mw: []string{"acme.auth"}, h: seen}, + testRoute{method: http.MethodDelete, path: "/hard/backend-principal", mw: []string{"acme.auth"}, h: seen}, + ) + hard.tools = append(hard.tools, + pact.MCPTool{Name: "broken", Method: "PUT", Path: "/hard/broken", Input: struct{}{}}, + pact.MCPTool{Name: "claims_backend", Method: "PATCH", Path: "/hard/claims-backend", Input: struct{}{}}, + pact.MCPTool{Name: "backend_principal", Method: "DELETE", Path: "/hard/backend-principal", Input: struct{}{}}, + ) + app := newApp(t, appSpec{guards: []pact.MCPGuard{guard}}) + in := startInstance(t, app, hard) + t.Cleanup(in.pier().StopReaper) + outer, err := url.Parse(in.srv.URL) + if err != nil { + t.Fatal(err) + } + logs := captureLogs(t) + cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{headers: map[string]string{ + "Cookie": "summer_session=outer", + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "site.example", + }}) + 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 + } + refused := func(t *testing.T, name string, args map[string]any, want string) { + t.Helper() + before := hits.Load() + res := call(name, args) + if !res.IsError || !strings.Contains(resultText(t, res), want) { + t.Fatalf("%s %v = %+v, want a tool error with %q", name, args, res, want) + } + if hits.Load() != before { + t.Fatalf("%s %v reached the route", name, args) + } + } + + t.Run("path values the mux never delivers are refused", func(t *testing.T) { + for _, v := range []string{"..", "."} { + refused(t, "hard_one", map[string]any{"id": v}, "must not contain empty, . or .. segments") + } + refused(t, "hard_one", map[string]any{"id": ""}, "one non-empty path segment") + refused(t, "hard_one", map[string]any{"id": "a/b"}, "one non-empty path segment") + for _, v := range []string{"a/../b", "a//b", "./a", "a/.."} { + refused(t, "hard_many", map[string]any{"rest": v}, "must not contain empty, . or .. segments") + } + if res := call("hard_many", map[string]any{"rest": "a/b"}); res.IsError || structured(t, res)["rest"] != "a/b" { + t.Fatalf("a/b on a {rest...} route = %+v", res) + } + }) + + t.Run("a missing path value is refused", func(t *testing.T) { + refused(t, "hard_one", map[string]any{}, "invalid arguments") + }) + + t.Run("a provider error answers the fixed text and is only logged", func(t *testing.T) { + before := hits.Load() + res := call("hard_broken", nil) + if !res.IsError || resultText(t, res) != "pier: the tool could not be run for this caller" { + t.Fatalf("provider error = %+v %q", res, resultText(t, res)) + } + if strings.Contains(resultText(t, res), "SQLSTATE") { + t.Fatal("the provider's message reached the agent") + } + if hits.Load() != before { + t.Fatal("a failed dispatch reached the route") + } + if !strings.Contains(logs.String(), "42P01") || !strings.Contains(logs.String(), "guard dispatch failed") { + t.Fatalf("the provider's message is not in the log: %s", logs.String()) + } + }) + + t.Run("a large integer keeps its digits in the path and the query", func(t *testing.T) { + res := cs.CallTool + raw := json.RawMessage(`{"id":9007199254740993,"n":9007199254740993}`) + out, err := res(ctx, &mcp.CallToolParams{Name: "hard_num", Arguments: raw}) + if err != nil { + t.Fatal(err) + } + got := structured(t, out) + if out.IsError || got["id"] != "9007199254740993" || got["n"] != "9007199254740993" { + t.Fatalf("large integer = %+v", got) + } + }) + + t.Run("the inner request has the outer Host and forwarding headers, never credentials", func(t *testing.T) { + got := structured(t, call("hard_num", map[string]any{"id": 5})) + if got["host"] != outer.Host || got["proto"] != "https" || got["fhost"] != "site.example" { + t.Fatalf("inner request host %v proto %v forwarded host %v, want %s https site.example", got["host"], got["proto"], got["fhost"], outer.Host) + } + if got["auth"] != "" || got["cookie"] != "" { + t.Fatalf("outer credentials reached the route: auth %q cookie %q", got["auth"], got["cookie"]) + } + }) + + t.Run("a dispatch contradicting the tool's guard is refused", func(t *testing.T) { + for _, name := range []string{"hard_claims_backend", "hard_backend_principal"} { + refused(t, name, nil, "pier: the tool could not be run for this caller") + } + if !strings.Contains(logs.String(), "contradicts the tool's guard") { + t.Fatalf("no contradiction line: %s", logs.String()) + } + }) +} diff --git a/modules/pier/export_test.go b/modules/pier/export_test.go index 43ff2f14..bcce350f 100644 --- a/modules/pier/export_test.go +++ b/modules/pier/export_test.go @@ -85,3 +85,7 @@ func (p *Pier) SessionDone(sid string) (<-chan struct{}, bool) { }() return done, true } + +// WrapStore replaces the session store with wrap(current store), so a test +// can count calls or hide the store's optional capabilities. +func (p *Pier) WrapStore(wrap func(Store) Store) { p.store = wrap(p.store) } diff --git a/modules/pier/lock_scope_test.go b/modules/pier/lock_scope_test.go new file mode 100644 index 00000000..a602cc08 --- /dev/null +++ b/modules/pier/lock_scope_test.go @@ -0,0 +1,161 @@ +package pier_test + +import ( + "bytes" + "context" + "io" + "net/http" + "sync/atomic" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/pact" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// blockingGuard is the acme guard whose MCPAllows blocks for user 8 while +// block is set, until release closes; entered is signalled once per block. +type blockingGuard struct { + acmeGuard + block atomic.Bool + entered chan struct{} + release chan struct{} +} + +func (g *blockingGuard) MCPAllows(ctx context.Context, s pact.MCPSubject, _ pact.MCPRequirements, _ []string) (bool, error) { + if s.UserID == 8 && g.block.Load() { + select { + case g.entered <- struct{}{}: + default: + } + select { + case <-g.release: + case <-ctx.Done(): + } + } + return true, nil +} + +// rawStatus sends one request with a bearer, an optional session id and a +// protocol version, and answers the status (safe from any goroutine). +func rawStatus(ctx context.Context, method, url, token, sid, version, body string) (int, error) { + var rd io.Reader + if body != "" { + rd = bytes.NewBufferString(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, rd) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Mcp-Protocol-Version", version) + if sid != "" { + req.Header.Set("Mcp-Session-Id", sid) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, err + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + _ = resp.Body.Close() + return resp.StatusCode, nil +} + +// TestLockScope pins plan 06's lock scope (A-WR-07): while one client's +// initialize is blocked in a guard check (as a database query would block +// it), another client's call on an existing session, a DELETE and a +// stateless request each finish promptly, because no router-wide lock is +// held across guard I/O. +func TestLockScope(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + guard := &blockingGuard{entered: make(chan struct{}, 1), release: make(chan struct{})} + in := startInstance(t, newApp(t, appSpec{ + mcp: "enabled: false\nsessions:\n store: memory\n", + guards: []pact.MCPGuard{guard}, + }), acmePlugin{tools: []pact.MCPTool{echoTool(), showTool()}}) + t.Cleanup(in.pier().StopReaper) + + active := openSession(t, ctx, in, "oas_at_test") + doomed := openSession(t, ctx, in, "oas_at_test") + + guard.block.Store(true) + blocked := make(chan int, 1) + go func() { + status, _ := initStatus(ctx, in.url(), "oas_at_other", initializeBody) + blocked <- status + }() + select { + case <-guard.entered: + case <-time.After(10 * time.Second): + t.Fatal("the blocked initialize never reached the guard") + } + + within := func(name string, fn func() error) { + t.Helper() + done := make(chan error, 1) + go func() { done <- fn() }() + select { + case err := <-done: + if err != nil { + t.Fatalf("%s: %v", name, err) + } + case <-time.After(3 * time.Second): + t.Fatalf("%s waited for the blocked initialize", name) + } + } + statusIs := func(want int, method, sid, version, body string) func() error { + return func() error { + got, err := rawStatus(ctx, method, in.url(), "oas_at_test", sid, version, body) + if err != nil { + return err + } + if got != want { + return &statusError{got: got, want: want} + } + return nil + } + } + t.Run("a call on an existing session", func(t *testing.T) { + within("tools/call", statusIs(http.StatusOK, http.MethodPost, active, "2025-11-25", callBody)) + }) + t.Run("a DELETE", func(t *testing.T) { + within("DELETE", statusIs(http.StatusNoContent, http.MethodDelete, doomed, "2025-11-25", "")) + }) + t.Run("a stateless request", func(t *testing.T) { + within("stateless tools/list", func() error { + client := mcp.NewClient(&mcp.Implementation{Name: "lock-test", Version: "1"}, nil) + transport := &mcp.StreamableClientTransport{Endpoint: in.url(), HTTPClient: &http.Client{Transport: headerTransport{token: "oas_at_test"}}} + cs, err := client.Connect(ctx, transport, nil) + if err != nil { + return err + } + defer cs.Close() + _, err = cs.ListTools(ctx, nil) + return err + }) + }) + + select { + case status := <-blocked: + t.Fatalf("the blocked initialize finished early (%d)", status) + default: + } + close(guard.release) + select { + case status := <-blocked: + if status != http.StatusOK { + t.Fatalf("the released initialize = %d", status) + } + case <-time.After(10 * time.Second): + t.Fatal("the released initialize never finished") + } +} + +type statusError struct{ got, want int } + +func (e *statusError) Error() string { + return "status " + http.StatusText(e.got) + ", want " + http.StatusText(e.want) +} diff --git a/modules/pier/personal_binding_test.go b/modules/pier/personal_binding_test.go new file mode 100644 index 00000000..0c38731a --- /dev/null +++ b/modules/pier/personal_binding_test.go @@ -0,0 +1,65 @@ +package pier_test + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// TestPersonalTokenBinding pins plan 06's personal-token binding rule +// (A-WR-09): a resolver answering a subject without a TokenID is refused +// with 401 at /mcp (it would put every user of the resolver on one +// binding), and two users' personal tokens get distinct bindings, so one +// cannot use or DELETE the other's session. +func TestPersonalTokenBinding(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + personal := fakePersonalResolver{tokens: map[string]pact.MCPSubject{ + "usr_no_token_id": {Guard: "acme", UserID: 31, Scopes: []string{"read", "write"}}, + "usr_alice": {Guard: "acme", UserID: 32, TokenID: 501, Scopes: []string{"read", "write"}}, + "usr_bob": {Guard: "acme", UserID: 33, TokenID: 502, Scopes: []string{"read", "write"}}, + }} + in := startInstance(t, newApp(t, appSpec{ + mcp: "enabled: false\nallow_personal_tokens: true\nsessions:\n store: memory\n", + guards: []pact.MCPGuard{&acmeGuard{}}, + personal: personal, + }), acmePlugin{tools: []pact.MCPTool{echoTool()}}) + t.Cleanup(in.pier().StopReaper) + + t.Run("a subject without a TokenID is 401", func(t *testing.T) { + if r := rpc(t, ctx, in.url(), "usr_no_token_id", "", initializeBody); r.status != http.StatusUnauthorized { + t.Fatalf("initialize with a TokenID-less subject = %d %s", r.status, r.body) + } + if _, err := in.pier().Resolve(ctx, "usr_no_token_id"); err == nil { + t.Fatal("Resolve accepted a subject without a TokenID") + } + }) + + t.Run("two users get distinct bindings", func(t *testing.T) { + alice, err := in.pier().Resolve(ctx, "usr_alice") + if err != nil { + t.Fatal(err) + } + bob, err := in.pier().Resolve(ctx, "usr_bob") + if err != nil { + t.Fatal(err) + } + if alice.Binding != "pat:user:501" || bob.Binding != "pat:user:502" { + t.Fatalf("bindings = %q, %q", alice.Binding, bob.Binding) + } + sid := openSession(t, ctx, in, "usr_alice") + if r := rpc(t, ctx, in.url(), "usr_bob", sid, callBody); r.status != http.StatusForbidden || !strings.Contains(r.body, "session_binding") { + t.Fatalf("bob on alice's session = %d %s", r.status, r.body) + } + if code := deleteSession(t, ctx, in.url(), "usr_bob", sid); code != http.StatusForbidden { + t.Fatalf("bob's DELETE of alice's session = %d", code) + } + if r := rpc(t, ctx, in.url(), "usr_alice", sid, callBody); r.status != http.StatusOK || !strings.Contains(r.body, `\"principal\":32`) { + t.Fatalf("alice's session after bob's attempts = %d %s", r.status, r.body) + } + }) +} diff --git a/modules/pier/prompt_ceiling_test.go b/modules/pier/prompt_ceiling_test.go new file mode 100644 index 00000000..c9d35a86 --- /dev/null +++ b/modules/pier/prompt_ceiling_test.go @@ -0,0 +1,63 @@ +package pier_test + +import ( + "context" + "slices" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/pact" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// promptNames lists the prompt names a session sees, sorted. +func promptNames(t *testing.T, ctx context.Context, cs *mcp.ClientSession) []string { + t.Helper() + list, err := cs.ListPrompts(ctx, nil) + if err != nil { + t.Fatalf("prompts/list: %v", err) + } + var out []string + for _, p := range list.Prompts { + out = append(out, p.Name) + } + slices.Sort(out) + return out +} + +// TestPromptWriteCeiling pins plan 06's prompt ceiling (C-IN-01, P-28): a +// prompt declaring Writes is listed for a read and write grant and hidden +// from a read-only grant (and cannot be fetched by it), while a prompt +// without Writes is listed for both. +func TestPromptWriteCeiling(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + draft := summarizePrompt() + draft.Name = "draft" + draft.Writes = true + in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}}), + acmePlugin{tools: []pact.MCPTool{echoTool()}, prompts: []pact.MCPPrompt{summarizePrompt(), draft}}) + t.Cleanup(in.pier().StopReaper) + + for _, c := range []struct { + name string + token string + want []string + }{ + {"read and write grant", "oas_at_test", []string{"acme_draft", "acme_summarize"}}, + {"read-only grant", "oas_at_read", []string{"acme_summarize"}}, + } { + t.Run(c.name, func(t *testing.T) { + for _, version := range []string{"", "newest"} { + cs := sdkClient(t, ctx, in.url(), c.token, clientOpts{version: version}) + if got := promptNames(t, ctx, cs); !slices.Equal(got, c.want) { + t.Fatalf("version %q: prompts = %v, want %v", version, got, c.want) + } + _, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "acme_draft", Arguments: map[string]string{"topic": "x"}}) + if allowed := slices.Contains(c.want, "acme_draft"); (err == nil) != allowed { + t.Fatalf("version %q: get acme_draft err = %v, allowed %v", version, err, allowed) + } + } + }) + } +} diff --git a/modules/pier/reserve_test.go b/modules/pier/reserve_test.go new file mode 100644 index 00000000..b322013a --- /dev/null +++ b/modules/pier/reserve_test.go @@ -0,0 +1,194 @@ +package pier_test + +import ( + "bytes" + "context" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/pier" +) + +// initStatus posts one initialize without a session and answers the status +// (safe to call from any goroutine). +func initStatus(ctx context.Context, url, token, body string) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(body)) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Mcp-Protocol-Version", "2025-11-25") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, err + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + _ = resp.Body.Close() + return resp.StatusCode, nil +} + +// burst sends n concurrent initializes for token, spread over the urls, and +// counts the answers by status. +func burst(t *testing.T, ctx context.Context, n int, token string, urls ...string) map[int]int { + t.Helper() + var mu sync.Mutex + got := map[int]int{} + var wg sync.WaitGroup + start := make(chan struct{}) + for i := 0; i < n; i++ { + wg.Add(1) + go func(url string) { + defer wg.Done() + <-start + status, err := initStatus(ctx, url, token, initializeBody) + if err != nil { + status = -1 + } + mu.Lock() + got[status]++ + mu.Unlock() + }(urls[i%len(urls)]) + } + close(start) + wg.Wait() + return got +} + +// countingStore hides the shipped store's Reserve, Release and Update (it +// embeds only pier.Store) and counts Count and Put. +type countingStore struct { + pier.Store + counts atomic.Int64 + puts atomic.Int64 +} + +func (s *countingStore) Count(ctx context.Context, binding string) (int, error) { + s.counts.Add(1) + return s.Store.Count(ctx, binding) +} + +func (s *countingStore) Put(ctx context.Context, id string, rec pier.Record, ttl time.Duration) error { + s.puts.Add(1) + return s.Store.Put(ctx, id, rec, ttl) +} + +// TestSessionReserve pins the atomic per-binding reservation of plan 06 +// (A-WR-03, P-25): concurrent initializes of one binding open exactly +// mcp.sessions.max_per_binding sessions on the memory store and on a Redis +// store shared by two instances, the rest answer 429; an initialize that +// fails releases its slot; and a store without the reservation falls back +// to Count then Put. +func TestSessionReserve(t *testing.T) { + plugin := acmePlugin{tools: []pact.MCPTool{echoTool()}} + const cap = 3 + + t.Run("memory store opens exactly max_per_binding of 50 concurrent initializes", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + in := startInstance(t, newApp(t, appSpec{ + mcp: "enabled: false\nsessions:\n store: memory\n max_per_binding: 3\n", + guards: []pact.MCPGuard{&acmeGuard{}}, + }), plugin) + t.Cleanup(in.pier().StopReaper) + got := burst(t, ctx, 50, "oas_at_test", in.url()) + if got[http.StatusOK] != cap || got[http.StatusTooManyRequests] != 50-cap { + t.Fatalf("answers = %v, want %d OK and %d 429", got, cap, 50-cap) + } + if n := in.pier().LocalSessions(); n != cap { + t.Fatalf("local sessions = %d, want %d", n, cap) + } + }) + + t.Run("redis store opens exactly max_per_binding across two instances", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + a, b, _ := twoInstances(t, appSpec{ + mcp: "enabled: false\nsessions:\n store: redis\n max_per_binding: 3\n", + guards: []pact.MCPGuard{&acmeGuard{}}, + }, plugin) + t.Cleanup(a.pier().StopReaper) + t.Cleanup(b.pier().StopReaper) + got := burst(t, ctx, 50, "oas_at_test", a.url(), b.url()) + if got[http.StatusOK] != cap || got[http.StatusTooManyRequests] != 50-cap { + t.Fatalf("answers = %v, want %d OK and %d 429", got, cap, 50-cap) + } + if n := a.pier().LocalSessions() + b.pier().LocalSessions(); n != cap { + t.Fatalf("local sessions on both instances = %d, want %d", n, cap) + } + }) + + t.Run("a failed initialize releases its slot", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + in := startInstance(t, newApp(t, appSpec{ + mcp: "enabled: false\nsessions:\n store: memory\n max_per_binding: 1\n", + guards: []pact.MCPGuard{&acmeGuard{}}, + }), plugin) + t.Cleanup(in.pier().StopReaper) + bad := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":"not an object"}` + // Each one reserves a slot (it gets a session id) and fails in the + // SDK's initialize; with max_per_binding 1, a kept slot would make + // the next one 429. + // The SDK streams the error answer before serveNew releases the + // slot, so each attempt waits for the release (the local copy gone). + released := func(i int) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for in.pier().LocalSessions() != 0 { + if time.Now().After(deadline) { + t.Fatalf("failed initialize %d kept its local session", i) + } + time.Sleep(5 * time.Millisecond) + } + } + for i := 0; i < 3; i++ { + r := rpc(t, ctx, in.url(), "oas_at_test", "", bad) + if r.status == http.StatusTooManyRequests { + t.Fatalf("failed initialize %d hit the cap: an earlier one kept its slot", i) + } + if r.session == "" || !strings.Contains(r.body, `"error"`) { + t.Fatalf("failed initialize %d = %d session %q %s, want a reserved id and an error", i, r.status, r.session, r.body) + } + released(i) + } + openSession(t, ctx, in, "oas_at_test") + if third := rpc(t, ctx, in.url(), "oas_at_test", "", initializeBody); third.status != http.StatusTooManyRequests { + t.Fatalf("second session over max_per_binding 1 = %d %s", third.status, third.body) + } + }) + + t.Run("a store without the reservation falls back to Count and Put", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + in := startInstance(t, newApp(t, appSpec{ + mcp: "enabled: false\nsessions:\n store: memory\n max_per_binding: 2\n", + guards: []pact.MCPGuard{&acmeGuard{}}, + }), plugin) + p := in.pier() + p.StopReaper() + store := &countingStore{} + p.WrapStore(func(s pier.Store) pier.Store { + store.Store = s + return store + }) + first := openSession(t, ctx, in, "oas_at_test") + openSession(t, ctx, in, "oas_at_test") + if over := rpc(t, ctx, in.url(), "oas_at_test", "", initializeBody); over.status != http.StatusTooManyRequests { + t.Fatalf("third session = %d %s", over.status, over.body) + } + if store.counts.Load() < 3 || store.puts.Load() < 2 { + t.Fatalf("Count called %d times, Put %d times: the fallback did not run", store.counts.Load(), store.puts.Load()) + } + if call := rpc(t, ctx, in.url(), "oas_at_test", first, callBody); call.status != http.StatusOK { + t.Fatalf("call on a fallback session = %d %s", call.status, call.body) + } + }) +} diff --git a/modules/pier/stateless_fanout_test.go b/modules/pier/stateless_fanout_test.go new file mode 100644 index 00000000..6c2e8b8a --- /dev/null +++ b/modules/pier/stateless_fanout_test.go @@ -0,0 +1,173 @@ +package pier_test + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/pact" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// listenWatch is a client transport that adds the bearer and closes ended +// when the response body of the client's subscriptions/listen request ends +// (the server closed the stream). +type listenWatch struct { + token string + once sync.Once + ended chan struct{} +} + +func (l *listenWatch) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + r.Header.Set("Authorization", "Bearer "+l.token) + listen := false + if r.Body != nil { + raw, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(raw)) + listen = strings.Contains(string(raw), `"subscriptions/listen"`) + } + resp, err := http.DefaultTransport.RoundTrip(r) + if err != nil || !listen { + return resp, err + } + resp.Body = &watchedBody{ReadCloser: resp.Body, done: func() { l.once.Do(func() { close(l.ended) }) }} + return resp, nil +} + +type watchedBody struct { + io.ReadCloser + done func() +} + +func (b *watchedBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + if err != nil { + b.done() + } + return n, err +} + +// listenClient connects a newest-protocol client with a list_changed +// handler, so it opens a subscriptions/listen stream, and answers the +// channel closed when that stream ends. +func listenClient(t *testing.T, ctx context.Context, url, token string) (*mcp.ClientSession, <-chan struct{}) { + t.Helper() + watch := &listenWatch{token: token, ended: make(chan struct{})} + client := mcp.NewClient(&mcp.Implementation{Name: "listen-test", Version: "1"}, &mcp.ClientOptions{ + ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {}, + }) + cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: url, HTTPClient: &http.Client{Transport: watch}}, nil) + if err != nil { + t.Fatalf("connect with %s: %v", token, err) + } + t.Cleanup(func() { _ = cs.Close() }) + if cs.ID() != "" { + t.Fatalf("the newest client got a session %q", cs.ID()) + } + return cs, watch.ended +} + +func waitEnded(t *testing.T, ended <-chan struct{}, within time.Duration, what string) { + t.Helper() + select { + case <-ended: + case <-time.After(within): + t.Fatalf("%s: the listen stream is still open after %s", what, within) + } +} + +// TestStatelessFanOut pins plan 06's stateless lifecycle (A-WR-05, +// A-WR-06): an instance that served only stateless requests receives a +// revocation published by the other instance through Redis and evicts its +// entry; a subscriptions/listen stream ends within the re-check interval +// once its bearer stops resolving; and an LRU eviction at capacity closes +// the evicted server's listen stream. +func TestStatelessFanOut(t *testing.T) { + plugin := acmePlugin{tools: []pact.MCPTool{echoTool(), showTool()}} + + t.Run("a stateless-only instance applies a revocation from the other instance", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + a, b, _ := twoInstances(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}}, plugin) + t.Cleanup(a.pier().StopReaper) + t.Cleanup(b.pier().StopReaper) + cs := sdkClient(t, ctx, b.url(), "oas_at_test", clientOpts{version: "newest"}) + listNames(t, ctx, cs) + if n := b.pier().StatelessServers(); n != 1 { + t.Fatalf("B stateless servers = %d, want 1", n) + } + fireSubjectChanged(t, a.app, "acme", 7, pact.MCPSubjectRevoked) + deadline := time.Now().Add(10 * time.Second) + for b.pier().StatelessServers() != 0 { + if time.Now().After(deadline) { + t.Fatal("B never evicted its stateless server: the revocation did not reach it") + } + time.Sleep(20 * time.Millisecond) + } + }) + + t.Run("a listen stream ends once its bearer stops resolving", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + verifier := newFakeVerifier(map[string]pact.MCPGrant{"oas_at_listen": grant(12, "c7", 12, "read", "write")}) + in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}, verifier: verifier}), plugin) + t.Cleanup(in.pier().StopReaper) + in.pier().SetRecheck(50 * time.Millisecond) + logs := captureLogs(t) + _, ended := listenClient(t, ctx, in.url(), "oas_at_listen") + select { + case <-ended: + t.Fatal("the listen stream ended while the bearer resolved") + case <-time.After(300 * time.Millisecond): + } + verifier.revoke("oas_at_listen") + waitEnded(t, ended, 3*time.Second, "revoked bearer") + if !strings.Contains(logs.String(), "step=stream_closed") { + t.Fatalf("no stream_closed line: %s", logs.String()) + } + }) + + t.Run("an LRU eviction closes the evicted server's listen stream", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + const capacity = 16 // max_per_binding 1 times the LRU factor + extra := map[string]pact.MCPGrant{"oas_at_first": grant(20, "c20", 20, "read")} + for i := 0; i < capacity; i++ { + extra[fmt.Sprintf("oas_at_lru_%d", i)] = grant(uint(100+i), fmt.Sprintf("lru%d", i), uint(100+i), "read") + } + in := startInstance(t, newApp(t, appSpec{ + mcp: "enabled: false\nsessions:\n max_per_binding: 1\n", + guards: []pact.MCPGuard{&acmeGuard{}}, + verifier: newFakeVerifier(extra), + }), plugin) + t.Cleanup(in.pier().StopReaper) + _, ended := listenClient(t, ctx, in.url(), "oas_at_first") + for i := 0; i < capacity-1; i++ { + listNames(t, ctx, sdkClient(t, ctx, in.url(), fmt.Sprintf("oas_at_lru_%d", i), clientOpts{version: "newest"})) + } + select { + case <-ended: + t.Fatal("the listen stream ended below capacity") + case <-time.After(200 * time.Millisecond): + } + if n := in.pier().StatelessServers(); n != capacity { + t.Fatalf("stateless servers at capacity = %d, want %d", n, capacity) + } + listNames(t, ctx, sdkClient(t, ctx, in.url(), fmt.Sprintf("oas_at_lru_%d", capacity-1), clientOpts{version: "newest"})) + waitEnded(t, ended, 5*time.Second, "LRU eviction") + if n := in.pier().StatelessServers(); n != capacity { + t.Fatalf("stateless servers after the eviction = %d, want %d", n, capacity) + } + }) +} diff --git a/modules/pier/token_events_test.go b/modules/pier/token_events_test.go new file mode 100644 index 00000000..98322a16 --- /dev/null +++ b/modules/pier/token_events_test.go @@ -0,0 +1,174 @@ +package pier_test + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/pier" + "git.golem15.com/golem15/summercms/modules/surf" + "git.golem15.com/golem15/summercms/modules/towel" +) + +// TestTokenScopedRevoke pins plan 07's narrowed revocation (B-WR-04, the +// framework half of C-IN-02, P-31) across two instances on one Redis +// store: a revoked event fired on one instance with a TokenID closes, on +// the other, only the session opened with that personal token (another +// token's session and an OAuth session of the same user, even one whose +// grant carries the same token id, stay); with a ClientID only that +// client's OAuth sessions close; with neither every session of the user +// closes. +func TestTokenScopedRevoke(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + personal := fakePersonalResolver{tokens: map[string]pact.MCPSubject{ + "usr_first": {Guard: "acme", UserID: 40, TokenID: 601, Scopes: []string{"read", "write"}}, + "usr_second": {Guard: "acme", UserID: 40, TokenID: 602, Scopes: []string{"read", "write"}}, + }} + verifier := newFakeVerifier(map[string]pact.MCPGrant{ + // The same token id as usr_first: a TokenID event must not reach it. + "oas_at_u40_ca": grant(40, "ca", 601, "read", "write"), + "oas_at_u40_cb": grant(40, "cb", 702, "read", "write"), + }) + a, b, _ := twoInstances(t, appSpec{ + mcp: "enabled: false\nallow_personal_tokens: true\nsessions:\n store: redis\n", + guards: []pact.MCPGuard{&acmeGuard{}}, + verifier: verifier, + personal: personal, + }, acmePlugin{tools: []pact.MCPTool{echoTool()}}) + t.Cleanup(a.pier().StopReaper) + t.Cleanup(b.pier().StopReaper) + + sessions := map[string]string{} + for _, tok := range []string{"usr_first", "usr_second", "oas_at_u40_ca", "oas_at_u40_cb"} { + sessions[tok] = openSession(t, ctx, b, tok) + } + status := func(tok string) int { + return rpc(t, ctx, b.url(), tok, sessions[tok], callBody).status + } + waitClosed := func(tok string) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for status(tok) != http.StatusNotFound { + if time.Now().After(deadline) { + t.Fatalf("the session of %s survived the event", tok) + } + time.Sleep(20 * time.Millisecond) + } + } + alive := func(toks ...string) { + t.Helper() + for _, tok := range toks { + if got := status(tok); got != http.StatusOK { + t.Fatalf("the session of %s = %d after an event that does not name it", tok, got) + } + } + } + fire := func(ev *pact.MCPSubjectChanged) { + t.Helper() + ev.Guard, ev.UserID, ev.Kind = "acme", 40, pact.MCPSubjectRevoked + if err := a.app.Events.Fire(context.Background(), ev); err != nil { + t.Fatal(err) + } + } + + t.Run("a TokenID closes only that personal token's session", func(t *testing.T) { + fire(&pact.MCPSubjectChanged{TokenID: 601}) + waitClosed("usr_first") + alive("usr_second", "oas_at_u40_ca", "oas_at_u40_cb") + }) + t.Run("a ClientID closes only that client's OAuth sessions", func(t *testing.T) { + fire(&pact.MCPSubjectChanged{ClientID: "ca"}) + waitClosed("oas_at_u40_ca") + alive("usr_second", "oas_at_u40_cb") + }) + t.Run("neither closes every session of the user", func(t *testing.T) { + fire(&pact.MCPSubjectChanged{}) + waitClosed("usr_second") + waitClosed("oas_at_u40_cb") + }) +} + +// addressVerifier is the fake verifier that records the client address it +// finds on the resolve context. +type addressVerifier struct { + *fakeVerifier + mu sync.Mutex + seen []string +} + +func (v *addressVerifier) MCPVerifyToken(ctx context.Context, raw string) (pact.MCPGrant, error) { + ip, _ := towel.ClientIP(ctx) + v.mu.Lock() + v.seen = append(v.seen, ip) + v.mu.Unlock() + return v.fakeVerifier.MCPVerifyToken(ctx, raw) +} + +func (v *addressVerifier) last() string { + v.mu.Lock() + defer v.mu.Unlock() + if len(v.seen) == 0 { + return "" + } + return v.seen[len(v.seen)-1] +} + +// proxyHost is a test host that also answers the client address the way +// surf does behind a trusted proxy (here: the X-Forwarded-For header). +type proxyHost struct { + *testHost +} + +func (proxyHost) ClientIP(r *http.Request) string { return r.Header.Get("X-Forwarded-For") } + +// TestResolveClientAddress pins plan 07's client address on the resolve +// context (B-IN-04, P-32): a token resolver sees towel.ClientIP equal to +// the host part of RemoteAddr, or, through a host that implements +// ClientIP (surf's trusted-proxy answer), to that host's answer. +func TestResolveClientAddress(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + plugins := []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool()}}} + + t.Run("RemoteAddr host", func(t *testing.T) { + verifier := &addressVerifier{fakeVerifier: newFakeVerifier(nil)} + in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}, verifier: verifier}), plugins...) + t.Cleanup(in.pier().StopReaper) + openSession(t, ctx, in, "oas_at_test") + if got := verifier.last(); got != "127.0.0.1" { + t.Fatalf("resolver saw client address %q, want 127.0.0.1", got) + } + }) + + t.Run("a host answering ClientIP", func(t *testing.T) { + verifier := &addressVerifier{fakeVerifier: newFakeVerifier(nil)} + app := newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}, verifier: verifier}) + router, err := surf.BuildRouter(app, plugins) + if err != nil { + t.Fatal(err) + } + if err := app.Config.Set("mcp.enabled", true); err != nil { + t.Fatal(err) + } + host := proxyHost{testHost: &testHost{r: router, handlers: map[string]http.Handler{}}} + if err := pier.Mount(host, app, plugins); err != nil { + t.Fatal(err) + } + if p, ok := app.Lookup[*pier.Pier](); ok { + t.Cleanup(p.StopReaper) + } + srv := httptest.NewServer(host) + t.Cleanup(srv.Close) + cs := sdkClient(t, ctx, srv.URL+"/mcp", "oas_at_test", clientOpts{headers: map[string]string{"X-Forwarded-For": "203.0.113.7"}}) + listNames(t, ctx, cs) + if got := verifier.last(); got != "203.0.113.7" { + t.Fatalf("resolver saw client address %q, want the host's answer 203.0.113.7", got) + } + }) +} diff --git a/modules/pier/widening_test.go b/modules/pier/widening_test.go new file mode 100644 index 00000000..ddee91a1 --- /dev/null +++ b/modules/pier/widening_test.go @@ -0,0 +1,93 @@ +package pier_test + +import ( + "context" + "net/http" + "slices" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// setGrant replaces (or adds) the grant a fake verifier answers for raw. +func (v *fakeVerifier) setGrant(raw string, g pact.MCPGrant) { + v.mu.Lock() + v.grants[raw] = g + v.mu.Unlock() +} + +// TestScopeWidening pins plan 06's re-filtering of a returning binding +// (A-WR-04, P-26): a binding first seen with read and then with read write +// lists the write tool on its next request, on the stateless and on the +// stateful path; a backend subject added to the binding (the grant gains +// backend, the user is linked) adds the backend tools without any fan-out +// event; and a narrowed grant hides the tools again. +func TestScopeWidening(t *testing.T) { + links := newFakeLinks() + links.set(11, 42) + verifier := newFakeVerifier(nil) + front := &fakeGuard{} + back := &fakeGuard{name: "backend", tokenMW: "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{}}, + {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{}{}}}} + in := startInstance(t, newApp(t, appSpec{ + mcp: "enabled: false\nsessions:\n store: memory\n", + guards: []pact.MCPGuard{front, back}, + verifier: verifier, + links: links, + }), acme, office) + t.Cleanup(in.pier().StopReaper) + + readOnly := []string{"acme_show"} + readWrite := []string{"acme_echo", "acme_show"} + withBackend := []string{"acme_echo", "acme_show", "office_posts"} + + for _, path := range []struct { + name string + version string + token string + client string + }{ + {"stateless", "newest", "oas_at_wide_stateless", "c5"}, + {"stateful", "", "oas_at_wide_stateful", "c6"}, + } { + t.Run(path.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + verifier.setGrant(path.token, grant(11, path.client, 11, "read")) + cs := sdkClient(t, ctx, in.url(), path.token, clientOpts{version: path.version}) + if (cs.ID() == "") != (path.name == "stateless") { + t.Fatalf("%s client session id %q", path.name, cs.ID()) + } + if got := listNames(t, ctx, cs); !slices.Equal(got, readOnly) { + t.Fatalf("read grant lists %v", got) + } + + verifier.setGrant(path.token, grant(11, path.client, 11, "read", "write")) + if got := listNames(t, ctx, cs); !slices.Equal(got, readWrite) { + t.Fatalf("after widening to read write: %v", got) + } + + // The same binding returns with the backend scope; the user is + // linked, so a backend subject joins the identity. No event is + // fired: the request itself re-filters the cached server. + verifier.setGrant(path.token, grant(11, path.client, 11, "read", "write", "backend")) + if got := listNames(t, ctx, cs); !slices.Equal(got, withBackend) { + t.Fatalf("after adding the backend subject: %v", got) + } + + verifier.setGrant(path.token, grant(11, path.client, 11, "read")) + if got := listNames(t, ctx, cs); !slices.Equal(got, readOnly) { + t.Fatalf("after narrowing to read: %v", got) + } + }) + } +} diff --git a/modules/towel/client_ip_test.go b/modules/towel/client_ip_test.go new file mode 100644 index 00000000..71159abf --- /dev/null +++ b/modules/towel/client_ip_test.go @@ -0,0 +1,32 @@ +package towel + +import ( + "context" + "testing" +) + +// TestClientIP pins plan 07's client address on the context (B-IN-04): the +// value round-trips, an absent value answers false, and an empty address +// is stored as given. +func TestClientIP(t *testing.T) { + t.Run("round trip", func(t *testing.T) { + ip, ok := ClientIP(WithClientIP(context.Background(), "203.0.113.7")) + if !ok || ip != "203.0.113.7" { + t.Fatalf("ClientIP = %q, %v", ip, ok) + } + }) + t.Run("absent", func(t *testing.T) { + if ip, ok := ClientIP(context.Background()); ok || ip != "" { + t.Fatalf("ClientIP(empty context) = %q, %v", ip, ok) + } + if ip, ok := ClientIP(WithLocale(context.Background(), "pl")); ok || ip != "" { + t.Fatalf("ClientIP(other values only) = %q, %v", ip, ok) + } + }) + t.Run("empty string", func(t *testing.T) { + ip, ok := ClientIP(WithClientIP(context.Background(), "")) + if !ok || ip != "" { + t.Fatalf("ClientIP(empty address) = %q, %v", ip, ok) + } + }) +} diff --git a/scripts/check-phase19.7.sh b/scripts/check-phase19.7.sh index 97a1d08d..0d680035 100755 --- a/scripts/check-phase19.7.sh +++ b/scripts/check-phase19.7.sh @@ -121,6 +121,14 @@ GO_TESTS=( # Gap closure, plans 06 and 07: the smokes and the named tests of plan 09. ./modules/pier:TestPierReaperSmoke ./modules/bouncer:TestAuthTimeSmoke ./modules/pier:TestSessionReaper ./modules/bouncer:TestAuthTime + ./modules/pier:TestSessionReserve ./modules/pier:TestLockScope ./modules/pier:TestScopeWidening + ./modules/pier:TestStatelessFanOut ./modules/pier:TestPersonalTokenBinding ./modules/pier:TestDispatchHardening + ./modules/pier:TestCatalogPathFields ./modules/pier:TestPromptWriteCeiling ./modules/pier:TestTokenScopedRevoke + ./modules/pier:TestResolveClientAddress + ./modules/pact:TestFrontendTokenScopes ./modules/towel:TestClientIP + ./modules/cabana:TestTokenScopeNameParity ./modules/cabana:TestAccountContributionURL + ./modules/cabana:TestAccountContributionsIsolation ./modules/cabana:TestRecordActionPromptLocalized + ./modules/cabana:TestResolveBackendTokenRemote ) # go stage: framework tests that also run in a binary built with the MCP @@ -130,19 +138,26 @@ GO_TAGGED_TESTS=( ./modules/pier:TestGuardRules ./modules/pier:TestSessions ./modules/pier:TestFanOut ./modules/pier:TestPierTracerSmoke ./modules/pier:TestPierStatelessSmoke ./modules/pier:TestEndpointGuards ./modules/surf:TestPierMount ./modules/surf:TestStreamingRoute ./modules/surf:TestDispatchSmoke - ./modules/pier:TestSessionReaper + ./modules/pier:TestSessionReaper ./modules/pier:TestSessionReserve ./modules/pier:TestLockScope + ./modules/pier:TestScopeWidening ./modules/pier:TestStatelessFanOut ./modules/pier:TestPersonalTokenBinding + ./modules/pier:TestDispatchHardening ./modules/pier:TestCatalogPathFields ./modules/pier:TestPromptWriteCeiling + ./modules/pier:TestTokenScopedRevoke ./modules/pier:TestResolveClientAddress ) # docker stage: Postgres tests in the framework. DOCKER_TESTS=( ./modules/cabana:TestDispatchMarker ./modules/cabana:TestAccountContributions ./modules/cabana:TestRecordActionPromptRun ./modules/cabana:TestFireSubjectChangedWaitsForCommit + # Gap closure, plan 07 (their -short part returns before the Postgres + # subtests). + ./modules/cabana:TestAccountTokenRevokeEvent ./modules/cabana:TestResolveBackendTokenRemote ) # admin stage: vitest files that must pass. ADMIN_FILES=( tests/account/AccountContributions.test.ts tests/form/RecordActionsPrompt.test.ts + tests/app/hotkeysEditing.test.ts ) # go stage: My account's own handlers stay as they were before the phase @@ -181,6 +196,15 @@ PHASE_TEST_FILES=( admin/tests/account/AccountContributions.test.ts admin/tests/form/RecordActionsPrompt.test.ts modules/pier/reaper_test.go modules/pier/reaper_smoke_test.go modules/bouncer/auth_time_test.go modules/bouncer/auth_time_smoke_test.go + modules/pier/reserve_test.go modules/pier/lock_scope_test.go modules/pier/widening_test.go + modules/pier/stateless_fanout_test.go modules/pier/personal_binding_test.go + modules/pier/dispatch_hardening_test.go modules/pier/catalog_path_fields_test.go + modules/pier/prompt_ceiling_test.go modules/pier/token_events_test.go + modules/pact/frontend_token_scopes_test.go modules/towel/client_ip_test.go + modules/cabana/account_contributions_hardening_test.go modules/cabana/record_action_prompt_localized_test.go + modules/cabana/token_scope_name_test.go modules/cabana/account_token_events_test.go + modules/cabana/token_resolver_remote_test.go + admin/tests/app/hotkeysEditing.test.ts ) STACK_NAMES=("github.com/modelcontextprotocol/go-sdk" "github.com/google/jsonschema-go")