|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#23528] `callData`'s ADDRESSED-object exposure refusal |
| 5 | + * (`refuseUnexposedDataAction`) is the data door's: an `Error` carrying `code` |
| 6 | + * and `status`, the addressed object as `object`, and the effective operation |
| 7 | + * set as `allowed` on a 405. Its nested-relation target refusal already |
| 8 | + * answered that way (`action-execution-calldata-relation-target-exposure.test.ts`), |
| 9 | + * and so do `RestServer` and the MCP stdio face for the same declaration. |
| 10 | + * |
| 11 | + * ## What was open |
| 12 | + * |
| 13 | + * The gate threw `{ statusCode, message }` with no `code`. Each door then |
| 14 | + * answered it without the data door's code: |
| 15 | + * |
| 16 | + * - the runtime dispatcher's `/data` door derived `404 RESOURCE_NOT_FOUND` / |
| 17 | + * `405 METHOD_NOT_ALLOWED` from the status |
| 18 | + * (`domains/data-write-request-gate.test.ts` pins both doors on a real store); |
| 19 | + * - the `/mcp` tools render a thrown `code` since #23509, and rendered this |
| 20 | + * refusal as its message alone (`buildMcpBridge` calls `callData` on every |
| 21 | + * data verb, pinned below). |
| 22 | + * |
| 23 | + * The status and the message are unchanged. Every row asserts `code` and |
| 24 | + * `status` and that neither the protocol nor the engine was asked. |
| 25 | + */ |
| 26 | + |
| 27 | +import { describe, expect, it, vi } from 'vitest'; |
| 28 | +import { callData, type ActionExecutionDeps } from './action-execution.js'; |
| 29 | +import { buildMcpBridge } from './domains/mcp.js'; |
| 30 | +import type { HttpProtocolContext } from './http-dispatcher.js'; |
| 31 | + |
| 32 | +const MEMBER = { userId: 'ax_member', isSystem: false, positions: [], permissions: [] } as any; |
| 33 | +const REQ = { request: {}, executionContext: MEMBER } as HttpProtocolContext; |
| 34 | + |
| 35 | +const object = (name: string, enable?: Record<string, unknown>) => ({ |
| 36 | + name, |
| 37 | + label: name, |
| 38 | + ...(enable ? { enable } : {}), |
| 39 | + fields: { |
| 40 | + id: { name: 'id', label: 'ID', type: 'text' }, |
| 41 | + name: { name: 'name', label: 'Name', type: 'text' }, |
| 42 | + }, |
| 43 | +}); |
| 44 | + |
| 45 | +/** The fixture: `ax_hidden` is not exposed at all, `ax_getonly` exposes `get` alone. */ |
| 46 | +const SCHEMAS: Record<string, unknown> = { |
| 47 | + ax_hidden: object('ax_hidden', { apiEnabled: false }), |
| 48 | + ax_getonly: object('ax_getonly', { apiMethods: ['get'] }), |
| 49 | +}; |
| 50 | + |
| 51 | +const HIDDEN = { code: 'OBJECT_API_DISABLED', status: 404, object: 'ax_hidden', allowed: undefined }; |
| 52 | +const HIDDEN_MESSAGE = 'object is not exposed via the API'; |
| 53 | + |
| 54 | +/** |
| 55 | + * The protocol and engine `callData` would reach, each verb a spy, so a refusal |
| 56 | + * is shown to ask neither. With the protocol present it serves every verb but |
| 57 | + * `aggregate`, which takes the engine; so the engine carries only the reads. |
| 58 | + */ |
| 59 | +function makeHarness() { |
| 60 | + const protocol = { |
| 61 | + createData: vi.fn(async (req: any) => ({ object: req.object, id: 'n1', record: req.data })), |
| 62 | + getData: vi.fn(async (req: any) => ({ object: req.object, id: req.id, record: { id: req.id } })), |
| 63 | + updateData: vi.fn(async (req: any) => ({ object: req.object, id: req.id, record: req.data })), |
| 64 | + deleteData: vi.fn(async (req: any) => ({ object: req.object, id: req.id, success: true })), |
| 65 | + findData: vi.fn(async (req: any) => ({ object: req.object, records: [], total: 0, hasMore: false })), |
| 66 | + }; |
| 67 | + const engine = { |
| 68 | + registry: { getObject: (n: string) => SCHEMAS[n] }, |
| 69 | + find: vi.fn(async () => []), |
| 70 | + aggregate: vi.fn(async () => []), |
| 71 | + }; |
| 72 | + const services: Record<string, unknown> = { |
| 73 | + metadata: { getObject: async (n: string) => SCHEMAS[n] }, |
| 74 | + protocol, |
| 75 | + objectql: engine, |
| 76 | + }; |
| 77 | + const deps: ActionExecutionDeps = { |
| 78 | + resolveService: (async (_ctx: HttpProtocolContext, name: string) => services[name]) as any, |
| 79 | + getObjectQL: async () => engine as any, |
| 80 | + }; |
| 81 | + const asked = () => [...Object.values(protocol), engine.find, engine.aggregate] |
| 82 | + .reduce((n, spy) => n + spy.mock.calls.length, 0); |
| 83 | + return { deps, protocol, asked }; |
| 84 | +} |
| 85 | + |
| 86 | +/** What a door reads off the refusal: the envelope pair and the two context fields. */ |
| 87 | +const refusal = (e: any) => ({ code: e?.code, status: e?.status, object: e?.object, allowed: e?.allowed }); |
| 88 | + |
| 89 | +/** Every action `callData` serves, with params naming `object`. */ |
| 90 | +const VERBS: ReadonlyArray<readonly [string, (object: string) => Record<string, unknown>]> = [ |
| 91 | + ['create', (o) => ({ object: o, data: { name: 'x' } })], |
| 92 | + ['get', (o) => ({ object: o, id: 'r1' })], |
| 93 | + ['update', (o) => ({ object: o, id: 'r1', data: { name: 'x' } })], |
| 94 | + ['delete', (o) => ({ object: o, id: 'r1' })], |
| 95 | + ['query', (o) => ({ object: o, query: {} })], |
| 96 | + ['find', (o) => ({ object: o, query: {} })], |
| 97 | + ['aggregate', (o) => ({ object: o, aggregations: [{ function: 'count', alias: 'n' }] })], |
| 98 | +]; |
| 99 | + |
| 100 | +describe('[#23528] callData refuses an addressed object its declaration does not expose with the data door\'s code', () => { |
| 101 | + for (const [action, params] of VERBS) { |
| 102 | + it(`${action} on an \`apiEnabled: false\` object: 404 OBJECT_API_DISABLED, nothing asked`, async () => { |
| 103 | + const h = makeHarness(); |
| 104 | + const err: any = await callData(h.deps, REQ, action, params('ax_hidden'), undefined, undefined, MEMBER).catch((e) => e); |
| 105 | + expect(err).toBeInstanceOf(Error); |
| 106 | + expect(refusal(err)).toEqual(HIDDEN); |
| 107 | + expect(err.message).toBe(HIDDEN_MESSAGE); |
| 108 | + expect(h.asked()).toBe(0); |
| 109 | + }); |
| 110 | + } |
| 111 | + |
| 112 | + it('create on an object exposing `get` alone: 405 OBJECT_API_METHOD_NOT_ALLOWED naming the effective set, nothing asked', async () => { |
| 113 | + const h = makeHarness(); |
| 114 | + const err: any = await callData(h.deps, REQ, 'create', { object: 'ax_getonly', data: { name: 'x' } }, undefined, undefined, MEMBER) |
| 115 | + .catch((e) => e); |
| 116 | + expect(err).toBeInstanceOf(Error); |
| 117 | + expect(refusal(err)).toEqual({ code: 'OBJECT_API_METHOD_NOT_ALLOWED', status: 405, object: 'ax_getonly', allowed: ['get'] }); |
| 118 | + expect(err.message).toBe("API operation 'create' is not allowed for this object"); |
| 119 | + expect(h.asked()).toBe(0); |
| 120 | + }); |
| 121 | + |
| 122 | + it('CONTROL: the verb the whitelist grants is served', async () => { |
| 123 | + const h = makeHarness(); |
| 124 | + await callData(h.deps, REQ, 'get', { object: 'ax_getonly', id: 'r1' }, undefined, undefined, MEMBER); |
| 125 | + expect(h.protocol.getData).toHaveBeenCalledTimes(1); |
| 126 | + }); |
| 127 | + |
| 128 | + it('CONTROL: a system context is not asked, so the hidden object is served', async () => { |
| 129 | + const h = makeHarness(); |
| 130 | + await callData(h.deps, REQ, 'query', { object: 'ax_hidden', query: {} }, undefined, undefined, { isSystem: true } as any); |
| 131 | + expect(h.protocol.findData).toHaveBeenCalledTimes(1); |
| 132 | + }); |
| 133 | +}); |
| 134 | + |
| 135 | +/** |
| 136 | + * The `/mcp` face: `buildMcpBridge` is the bridge `handleMcp` hands the MCP |
| 137 | + * tools, and each data member reaches `callData`. The tools render a thrown |
| 138 | + * string `code` and numeric `status` as `{ error: { code, message, status } }` |
| 139 | + * (`@objectstack/mcp`'s `thrownRefusalEnvelope`, pinned in its |
| 140 | + * `mcp-coded-refusal.test.ts`), so the bridge must hand them the coded refusal. |
| 141 | + */ |
| 142 | +describe('[#23528] the /mcp bridge hands its tools the coded refusal on every data member', () => { |
| 143 | + const MEMBERS: ReadonlyArray<readonly [string, (bridge: any) => Promise<unknown>]> = [ |
| 144 | + ['query', (b) => b.query('ax_hidden', {})], |
| 145 | + ['get', (b) => b.get('ax_hidden', 'r1')], |
| 146 | + ['aggregate', (b) => b.aggregate('ax_hidden', { aggregations: [{ function: 'count', alias: 'n' }] })], |
| 147 | + ['create', (b) => b.create('ax_hidden', { name: 'x' })], |
| 148 | + ['update', (b) => b.update('ax_hidden', 'r1', { name: 'x' })], |
| 149 | + ['remove', (b) => b.remove('ax_hidden', 'r1')], |
| 150 | + ]; |
| 151 | + |
| 152 | + for (const [member, call] of MEMBERS) { |
| 153 | + it(`${member}: 404 OBJECT_API_DISABLED, nothing asked`, async () => { |
| 154 | + const h = makeHarness(); |
| 155 | + const bridge = buildMcpBridge(h.deps as any, REQ); |
| 156 | + const err: any = await call(bridge).catch((e) => e); |
| 157 | + expect(err).toBeInstanceOf(Error); |
| 158 | + expect(refusal(err)).toEqual(HIDDEN); |
| 159 | + expect(err.message).toBe(HIDDEN_MESSAGE); |
| 160 | + expect(h.asked()).toBe(0); |
| 161 | + }); |
| 162 | + } |
| 163 | +}); |
0 commit comments