4501f118c2
* feat(mcp): approval-queue MCP Apps widget for staged operations gnubok_list_pending_operations(render_ui=true) now renders an interactive approval queue (claude.ai / Claude Desktop) where the user approves or rejects each staged operation with a click. High-risk operations arm the approve button and the second click sends confirmed=true, so the BFL 5 kap 5 acknowledgment is a first-party human action instead of the agent asserting confirmed=true on the user's behalf (the audit weakness flagged in dev_docs/erpclaw_analysis.md). - New widget ui://pending-operations/app.html following the established self-contained postMessage/JSON-RPC pattern (no fetch, theme-aware, Swedish labels, expandable preview_data per row). - Result-level _meta.ui hint gated on render_ui=true, mirroring the VAT report wiring; the tool stays data-only by default. - Widget tool references project per namespace (accounted_* clients see accounted_ names inside the HTML). - tools/list payload ceiling 58K -> 58.5K per the in-test convention: prose trimmed to the floor first, remainder is wire contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): time out the widget RPC bridge so a silent host cannot strand a row Review follow-up: sendRequest never settled if the host dropped a response, leaving op._working=true forever with the approve/reject buttons gone. A 30s timeout rejects the promise; the existing catch paths restore the row with an error message so the user can retry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
196 lines
7.2 KiB
TypeScript
196 lines
7.2 KiB
TypeScript
/**
|
|
* Tests for the pending-operations approval-queue widget: registration,
|
|
* resource serving, tool wiring, and namespace projection. Does NOT re-test
|
|
* approve/reject semantics (covered by pending-operations-tools tests);
|
|
* only the widget plumbing.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { tools } from '../server'
|
|
import { uiWidgets, findUiWidget } from '../widgets'
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn(),
|
|
createServiceClient: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
|
return {
|
|
...actual,
|
|
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
|
validateApiKey: vi.fn().mockResolvedValue({
|
|
userId: 'user-1',
|
|
companyId: '11111111-1111-4111-8111-111111111111',
|
|
scopes: ['pending_operations:read'],
|
|
}),
|
|
// Fully-chainable, awaitable proxy resolving to empty data: satisfies
|
|
// both loadAtomsAsSkills and the pending_operations list query without
|
|
// hand-enumerating each chain.
|
|
createServiceClientNoCookies: vi.fn(() => {
|
|
const makeChain = (): unknown =>
|
|
new Proxy(
|
|
{},
|
|
{
|
|
get(_t, prop) {
|
|
if (prop === 'then') {
|
|
return (resolve: (v: unknown) => void) => resolve({ data: [], error: null, count: 0 })
|
|
}
|
|
return () => makeChain()
|
|
},
|
|
},
|
|
)
|
|
const membershipChain: unknown = new Proxy(
|
|
{},
|
|
{
|
|
get(_t, prop) {
|
|
if (prop === 'then') {
|
|
return (resolve: (v: unknown) => void) =>
|
|
resolve({
|
|
data: {
|
|
company_id: '11111111-1111-4111-8111-111111111111',
|
|
role: 'owner',
|
|
},
|
|
error: null,
|
|
})
|
|
}
|
|
return () => membershipChain
|
|
},
|
|
}
|
|
)
|
|
return {
|
|
from: (table: string) => (table === 'company_members' ? membershipChain : makeChain()),
|
|
}
|
|
}),
|
|
}
|
|
})
|
|
|
|
import { handleMcpRequest } from '../server'
|
|
|
|
function mcpRequest(method: string, params?: Record<string, unknown>, namespace?: 'accounted'): Request {
|
|
const url = new URL('http://localhost:3000/api/extensions/ext/mcp-server/mcp')
|
|
if (namespace) url.searchParams.set('tool_namespace', namespace)
|
|
return new Request(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
|
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
|
})
|
|
}
|
|
|
|
async function parseResult(response: Response) {
|
|
const json = await response.json()
|
|
return json.result
|
|
}
|
|
|
|
describe('Pending operations widget', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('widget registration', () => {
|
|
it('registers the pending-operations widget in uiWidgets', () => {
|
|
const widget = findUiWidget('ui://pending-operations/app.html')
|
|
expect(widget).toBeDefined()
|
|
expect(widget?.name).toBe('Pending Operations')
|
|
expect(widget?.html).toContain('<!DOCTYPE html>')
|
|
expect(widget?.html).toContain('Att godkänna')
|
|
})
|
|
|
|
it('uiWidgets contains all three widgets', () => {
|
|
const uris = uiWidgets.map((w) => w.uri)
|
|
expect(uris).toContain('ui://receipt-matcher/app.html')
|
|
expect(uris).toContain('ui://vat-review/app.html')
|
|
expect(uris).toContain('ui://pending-operations/app.html')
|
|
})
|
|
|
|
it('times out stranded RPCs so a silent host cannot freeze a row', () => {
|
|
const widget = findUiWidget('ui://pending-operations/app.html')!
|
|
expect(widget.html).toContain('RPC_TIMEOUT_MS')
|
|
expect(widget.html).toContain('clearTimeout(timer)')
|
|
})
|
|
|
|
it('the widget calls the approve and reject tools and arms confirmed=true for high risk', () => {
|
|
const widget = findUiWidget('ui://pending-operations/app.html')!
|
|
expect(widget.html).toContain('gnubok_approve_pending_operation')
|
|
expect(widget.html).toContain('gnubok_reject_pending_operation')
|
|
// High-risk approvals send confirmed=true only from the armed second
|
|
// click: the human acknowledgment, never a default.
|
|
expect(widget.html).toContain('args.confirmed = true')
|
|
expect(widget.html).toContain("risk_level === 'high'")
|
|
})
|
|
})
|
|
|
|
describe('gnubok_list_pending_operations wiring', () => {
|
|
it('declares render_ui and points at the pending-operations widget', () => {
|
|
const tool = tools.find((t) => t.name === 'gnubok_list_pending_operations')!
|
|
expect((tool as { uiResourceUri?: string }).uiResourceUri).toBe(
|
|
'ui://pending-operations/app.html'
|
|
)
|
|
const props = (tool.inputSchema as { properties: Record<string, unknown> }).properties
|
|
expect(props.render_ui).toMatchObject({ type: 'boolean' })
|
|
expect(tool.annotations.readOnlyHint).toBe(true)
|
|
})
|
|
|
|
it('emits result-level _meta only when render_ui=true', async () => {
|
|
const withUi = await (
|
|
await handleMcpRequest(
|
|
mcpRequest('tools/call', {
|
|
name: 'gnubok_list_pending_operations',
|
|
arguments: { render_ui: true },
|
|
}),
|
|
)
|
|
).json()
|
|
expect(withUi.result.isError).toBeUndefined()
|
|
expect(withUi.result._meta).toEqual({
|
|
ui: { resourceUri: 'ui://pending-operations/app.html' },
|
|
})
|
|
|
|
const withoutUi = await (
|
|
await handleMcpRequest(
|
|
mcpRequest('tools/call', {
|
|
name: 'gnubok_list_pending_operations',
|
|
arguments: {},
|
|
}),
|
|
)
|
|
).json()
|
|
expect(withoutUi.result.isError).toBeUndefined()
|
|
expect(withoutUi.result._meta).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('protocol: resources/list + resources/read', () => {
|
|
it('lists the widget with the MCP Apps mime type', async () => {
|
|
const res = await handleMcpRequest(mcpRequest('resources/list'))
|
|
const result = await parseResult(res)
|
|
const widget = result.resources.find(
|
|
(r: { uri: string }) => r.uri === 'ui://pending-operations/app.html'
|
|
)
|
|
expect(widget).toMatchObject({
|
|
uri: 'ui://pending-operations/app.html',
|
|
name: 'Pending Operations',
|
|
mimeType: 'text/html;profile=mcp-app',
|
|
})
|
|
})
|
|
|
|
it('returns the widget HTML on resources/read', async () => {
|
|
const res = await handleMcpRequest(
|
|
mcpRequest('resources/read', { uri: 'ui://pending-operations/app.html' })
|
|
)
|
|
const result = await parseResult(res)
|
|
expect(result.contents).toHaveLength(1)
|
|
expect(result.contents[0].mimeType).toBe('text/html;profile=mcp-app')
|
|
expect(result.contents[0].text).toContain('Att godkänna')
|
|
})
|
|
|
|
it('projects the tool names inside the widget HTML for the accounted namespace', async () => {
|
|
const res = await handleMcpRequest(
|
|
mcpRequest('resources/read', { uri: 'ui://pending-operations/app.html' }, 'accounted')
|
|
)
|
|
const result = await parseResult(res)
|
|
const html = result.contents[0].text as string
|
|
expect(html).toContain('accounted_approve_pending_operation')
|
|
expect(html).toContain('accounted_reject_pending_operation')
|
|
expect(html).not.toContain('gnubok_approve_pending_operation')
|
|
})
|
|
})
|
|
})
|