|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21520] The write half of the stored-metadata family's boundary for |
| 5 | + * app-authored bodies: a sandboxed body may not write a family table. |
| 6 | + * |
| 7 | + * Pinned against a counting scoped-API double (it records which verb reached |
| 8 | + * it, and stores nothing), then through the real QuickJS sandbox on both body |
| 9 | + * faces. What each case asserts: |
| 10 | + * |
| 11 | + * - every write verb on each family table is refused with the boundary's |
| 12 | + * envelope (`PERMISSION_DENIED` / 403) BEFORE the verb runs; |
| 13 | + * - a predicate write is refused the same way whatever its predicate names, |
| 14 | + * so a refused write answers identically and runs nothing; |
| 15 | + * - reads still pass to the read seam, and every other object writes as |
| 16 | + * before; |
| 17 | + * - every derived context is refused the same way; |
| 18 | + * - the read seam ALONE — a host code handler's `ctx.api` — keeps its writes: |
| 19 | + * the boundary refuses bodies only. |
| 20 | + */ |
| 21 | + |
| 22 | +import { describe, it, expect } from 'vitest'; |
| 23 | +import { assertEngineUpdateDispatch, assertEngineDeleteDispatch } from '@objectstack/metadata-core'; |
| 24 | +import { STORED_METADATA_BODY_OBJECTS } from '@objectstack/spec/kernel'; |
| 25 | +import { refuseStoredMetadataBodyWrites, serveStoredMetadataReadsThrough } from './stored-metadata-reader-seam.js'; |
| 26 | +import { actionBodyRunnerFactory, hookBodyRunnerFactory } from './sandbox/body-runner.js'; |
| 27 | +import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; |
| 28 | + |
| 29 | +const FAMILY = [...STORED_METADATA_BODY_OBJECTS]; |
| 30 | +const ORDINARY = 'boundary_note'; |
| 31 | +const WRITE_VERBS = ['insert', 'create', 'update', 'updateById', 'upsert', 'delete', 'deleteById', 'updateMany', 'deleteMany']; |
| 32 | +const READ_VERBS = ['find', 'findOne', 'count', 'aggregate']; |
| 33 | + |
| 34 | +/** A scoped-API double that counts `<object>.<verb>` calls and answers neutral values. */ |
| 35 | +function countingApi(calls: string[] = []): any { |
| 36 | + const repo = (name: string) => { |
| 37 | + const record = (verb: string) => calls.push(`${name}.${verb}`); |
| 38 | + return { |
| 39 | + async find() { record('find'); return []; }, |
| 40 | + async findOne() { record('findOne'); return null; }, |
| 41 | + async count() { record('count'); return 0; }, |
| 42 | + async aggregate() { record('aggregate'); return []; }, |
| 43 | + async insert() { record('insert'); return { id: 'n1' }; }, |
| 44 | + async create() { record('create'); return { id: 'n1' }; }, |
| 45 | + async update(data?: any, opts?: any) { assertEngineUpdateDispatch(data, opts); record('update'); return 1; }, |
| 46 | + async updateById() { record('updateById'); return { id: 'n1' }; }, |
| 47 | + async upsert() { record('upsert'); return { id: 'n1' }; }, |
| 48 | + async delete(opts?: any) { assertEngineDeleteDispatch(opts); record('delete'); return 1; }, |
| 49 | + async deleteById() { record('deleteById'); return true; }, |
| 50 | + async updateMany() { record('updateMany'); return 0; }, |
| 51 | + async deleteMany() { record('deleteMany'); return 0; }, |
| 52 | + }; |
| 53 | + }; |
| 54 | + return { |
| 55 | + object: repo, |
| 56 | + sudo: () => countingApi(calls), |
| 57 | + withRunAs: () => countingApi(calls), |
| 58 | + async transaction(callback: (trx: any) => Promise<unknown>) { return callback(countingApi(calls)); }, |
| 59 | + async beginTransaction() { return { ctx: countingApi(calls), handle: 'trx_1', owned: true }; }, |
| 60 | + }; |
| 61 | +} |
| 62 | + |
| 63 | +/** The arguments a verb is called with here: a payload, then a by-id or predicate option. */ |
| 64 | +const argsOf = (verb: string): unknown[] => |
| 65 | + verb === 'update' ? [{ label: 'x' }, { where: { id: 'r1' } }] |
| 66 | + : verb === 'delete' ? [{ where: { id: 'r1' } }] |
| 67 | + : verb.endsWith('ById') ? ['r1', { label: 'x' }] |
| 68 | + : [{ label: 'x' }]; |
| 69 | + |
| 70 | +function expectRefused(promise: Promise<unknown>, object: string, verb: string) { |
| 71 | + return expect(promise).rejects.toMatchObject({ |
| 72 | + code: 'PERMISSION_DENIED', |
| 73 | + status: 403, |
| 74 | + object, |
| 75 | + operation: verb, |
| 76 | + message: expect.stringContaining('/api/v1/meta/'), |
| 77 | + }); |
| 78 | +} |
| 79 | + |
| 80 | +describe('[#21520] refuseStoredMetadataBodyWrites — every family write is refused before it runs', () => { |
| 81 | + for (const object of FAMILY) { |
| 82 | + it(`each write verb on '${object}' answers PERMISSION_DENIED / 403 and never reaches the store`, async () => { |
| 83 | + const calls: string[] = []; |
| 84 | + const api = refuseStoredMetadataBodyWrites(countingApi(calls)); |
| 85 | + for (const verb of WRITE_VERBS) { |
| 86 | + await expectRefused(api.object(object)[verb](...argsOf(verb)), object, verb); |
| 87 | + } |
| 88 | + expect(calls).toEqual([]); |
| 89 | + }); |
| 90 | + } |
| 91 | + |
| 92 | + it('a predicate write is refused identically whatever its predicate names, and runs nothing', async () => { |
| 93 | + const calls: string[] = []; |
| 94 | + const api = refuseStoredMetadataBodyWrites(countingApi(calls)); |
| 95 | + const answers: unknown[] = []; |
| 96 | + for (const where of [{ type: 'view' }, { metadata: 'x' }, { checksum: 'x' }]) { |
| 97 | + for (const verb of ['updateMany', 'deleteMany', 'update', 'delete']) { |
| 98 | + const args = verb === 'update' ? [{ label: 'x' }, { where, multi: true }] : [{ where, multi: true }]; |
| 99 | + const err: any = await api.object(FAMILY[0])[verb](...args).catch((e: unknown) => e); |
| 100 | + answers.push(`${verb}:${err?.code}:${err?.status}:${err?.message}`); |
| 101 | + } |
| 102 | + } |
| 103 | + // Three predicates, four verbs: the answer depends on the verb only. |
| 104 | + expect(new Set(answers).size).toBe(4); |
| 105 | + expect(calls).toEqual([]); |
| 106 | + }); |
| 107 | + |
| 108 | + it('reads on a family table pass through to the read seam beneath it', async () => { |
| 109 | + const calls: string[] = []; |
| 110 | + const api = refuseStoredMetadataBodyWrites(countingApi(calls)); |
| 111 | + for (const verb of READ_VERBS) await api.object(FAMILY[0])[verb]({}); |
| 112 | + expect(calls).toEqual(READ_VERBS.map((verb) => `${FAMILY[0]}.${verb}`)); |
| 113 | + }); |
| 114 | + |
| 115 | + it('an ordinary table writes as before (control)', async () => { |
| 116 | + const calls: string[] = []; |
| 117 | + const api = refuseStoredMetadataBodyWrites(countingApi(calls)); |
| 118 | + for (const verb of WRITE_VERBS) await api.object(ORDINARY)[verb](...argsOf(verb)); |
| 119 | + expect(calls).toEqual(WRITE_VERBS.map((verb) => `${ORDINARY}.${verb}`)); |
| 120 | + }); |
| 121 | + |
| 122 | + it('every derived context refuses the same way: sudo, withRunAs, transaction(fn), beginTransaction', async () => { |
| 123 | + const calls: string[] = []; |
| 124 | + const api = refuseStoredMetadataBodyWrites(countingApi(calls)); |
| 125 | + await expectRefused(api.sudo().object(FAMILY[0]).insert({ label: 'x' }), FAMILY[0], 'insert'); |
| 126 | + await expectRefused(api.withRunAs('system', {}).object(FAMILY[1]).insert({ label: 'x' }), FAMILY[1], 'insert'); |
| 127 | + await api.transaction(async (trx: any) => { |
| 128 | + await expectRefused(trx.object(FAMILY[0]).updateMany({ where: { type: 'view' } }), FAMILY[0], 'updateMany'); |
| 129 | + }); |
| 130 | + const begun = await api.beginTransaction(); |
| 131 | + expect(begun.handle).toBe('trx_1'); |
| 132 | + await expectRefused(begun.ctx.object(FAMILY[0]).delete({ where: { id: 'r1' } }), FAMILY[0], 'delete'); |
| 133 | + expect(calls).toEqual([]); |
| 134 | + }); |
| 135 | + |
| 136 | + it('layers over the read seam once: idempotent, and the read seam still sees its own mark', () => { |
| 137 | + const served = serveStoredMetadataReadsThrough(countingApi(), {}); |
| 138 | + const layered = refuseStoredMetadataBodyWrites(served); |
| 139 | + expect(refuseStoredMetadataBodyWrites(layered)).toBe(layered); |
| 140 | + expect(serveStoredMetadataReadsThrough(layered, {})).toBe(layered); |
| 141 | + }); |
| 142 | + |
| 143 | + it('the read seam ALONE — a host code handler\'s ctx.api — keeps its family writes (bodies only)', async () => { |
| 144 | + const calls: string[] = []; |
| 145 | + const api = serveStoredMetadataReadsThrough(countingApi(calls), {}); |
| 146 | + await api.object(FAMILY[0]).insert({ label: 'x' }); |
| 147 | + expect(calls).toEqual([`${FAMILY[0]}.insert`]); |
| 148 | + }); |
| 149 | +}); |
| 150 | + |
| 151 | +describe('[#21520] through the real sandbox — both body faces hold the refusing API', () => { |
| 152 | + const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000 }); |
| 153 | + |
| 154 | + it('an action body\'s family write is refused with the envelope; its ordinary write lands', async () => { |
| 155 | + const calls: string[] = []; |
| 156 | + const factory = actionBodyRunnerFactory(runner, { ql: {}, appId: 'boundary' }); |
| 157 | + const handler = factory({ |
| 158 | + name: 'writes_family', |
| 159 | + type: 'script', |
| 160 | + body: { |
| 161 | + language: 'js', |
| 162 | + capabilities: ['api.write'], |
| 163 | + source: `await ctx.api.object('${ORDINARY}').insert({ label: 'x' }); |
| 164 | + await ctx.api.object('${FAMILY[0]}').insert({ label: 'x' }); |
| 165 | + return { unreachable: true };`, |
| 166 | + }, |
| 167 | + }); |
| 168 | + await expect(handler!({ api: countingApi(calls), params: {} })) |
| 169 | + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); |
| 170 | + expect(calls).toEqual([`${ORDINARY}.insert`]); |
| 171 | + }); |
| 172 | + |
| 173 | + it('a hook body on an ordinary table, writing a family table, is refused the same way', async () => { |
| 174 | + const calls: string[] = []; |
| 175 | + const factory = hookBodyRunnerFactory(runner, { ql: {}, appId: 'boundary' }); |
| 176 | + const handler = factory({ |
| 177 | + name: 'ordinary_hook_writes_family', |
| 178 | + object: ORDINARY, |
| 179 | + events: ['afterInsert'], |
| 180 | + body: { |
| 181 | + language: 'js', |
| 182 | + capabilities: ['api.write'], |
| 183 | + source: `await ctx.api.object('${FAMILY[1]}').insert({ label: 'x' });`, |
| 184 | + }, |
| 185 | + } as any); |
| 186 | + await expect(handler!({ object: ORDINARY, event: 'afterInsert', input: {}, api: countingApi(calls) })) |
| 187 | + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); |
| 188 | + expect(calls).toEqual([]); |
| 189 | + }); |
| 190 | +}); |
0 commit comments