test(19.7-09): named tests for every framework and admin SPA fix of plans 06 and 07
- pier: TestSessionReserve (A-WR-03), TestLockScope (A-WR-07), TestScopeWidening (A-WR-04), TestStatelessFanOut (A-WR-05, A-WR-06), TestPersonalTokenBinding (A-WR-09), TestDispatchHardening (A-WR-01, A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08), TestCatalogPathFields (A-WR-02, A-IN-07), TestPromptWriteCeiling (C-IN-01), TestTokenScopedRevoke (B-WR-04, C-IN-02), TestResolveClientAddress (B-IN-04); test seam WrapStore - pact TestFrontendTokenScopes, towel TestClientIP, cabana TestTokenScopeNameParity - cabana TestAccountContributionURL (B-WR-03), TestAccountContributionsIsolation (B-WR-06, B-IN-03), TestRecordActionPromptLocalized (B-IN-02), and the Postgres TestAccountTokenRevokeEvent (B-WR-04) and TestResolveBackendTokenRemote (B-IN-04) - vitest: hotkeysEditing.test.ts (B-WR-01, B-WR-02) and the extended AccountContributions.test.ts (B-WR-03, B-WR-06, B-IN-01) - check-phase19.7.sh requires every name (GO_TESTS, GO_TAGGED_TESTS, DOCKER_TESTS, ADMIN_FILES) and scans the new files (PHASE_TEST_FILES)
This commit is contained in:
@@ -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<HTMLElement>('[data-confirm-dialog]')
|
||||
expect(dialog?.textContent).toContain('Run “Stop the digest”?')
|
||||
expect(dialog?.textContent).not.toContain('on this record')
|
||||
dialog!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||
await flushPromises()
|
||||
expect(toasts.value.map((toast) => toast.text)).toEqual(['This action no longer applies.'])
|
||||
})
|
||||
})
|
||||
|
||||
181
admin/tests/app/hotkeysEditing.test.ts
Normal file
181
admin/tests/app/hotkeysEditing.test.ts
Normal file
@@ -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<string, unknown>).labels = ['first']
|
||||
const routes: Record<string, Route> = {
|
||||
[`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<HTMLInputElement> {
|
||||
const input = document.querySelector<HTMLInputElement>('#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<Record<string, unknown>> {
|
||||
const puts = requestsTo(calls, 'PUT', RECORD)
|
||||
expect(puts).toHaveLength(1)
|
||||
return (await puts[0]!.clone().json()) as Record<string, unknown>
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
188
modules/cabana/account_contributions_hardening_test.go
Normal file
188
modules/cabana/account_contributions_hardening_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
61
modules/cabana/account_token_events_test.go
Normal file
61
modules/cabana/account_token_events_test.go
Normal file
@@ -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])
|
||||
}
|
||||
})
|
||||
}
|
||||
68
modules/cabana/record_action_prompt_localized_test.go
Normal file
68
modules/cabana/record_action_prompt_localized_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
78
modules/cabana/token_resolver_remote_test.go
Normal file
78
modules/cabana/token_resolver_remote_test.go
Normal file
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
41
modules/cabana/token_scope_name_test.go
Normal file
41
modules/cabana/token_scope_name_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
modules/pact/frontend_token_scopes_test.go
Normal file
58
modules/pact/frontend_token_scopes_test.go
Normal file
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
65
modules/pier/catalog_path_fields_test.go
Normal file
65
modules/pier/catalog_path_fields_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
207
modules/pier/dispatch_hardening_test.go
Normal file
207
modules/pier/dispatch_hardening_test.go
Normal file
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
161
modules/pier/lock_scope_test.go
Normal file
161
modules/pier/lock_scope_test.go
Normal file
@@ -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)
|
||||
}
|
||||
65
modules/pier/personal_binding_test.go
Normal file
65
modules/pier/personal_binding_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
63
modules/pier/prompt_ceiling_test.go
Normal file
63
modules/pier/prompt_ceiling_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
194
modules/pier/reserve_test.go
Normal file
194
modules/pier/reserve_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
173
modules/pier/stateless_fanout_test.go
Normal file
173
modules/pier/stateless_fanout_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
174
modules/pier/token_events_test.go
Normal file
174
modules/pier/token_events_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
93
modules/pier/widening_test.go
Normal file
93
modules/pier/widening_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
32
modules/towel/client_ip_test.go
Normal file
32
modules/towel/client_ip_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user