|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#22739] An import cell resolved against a lookup TARGET whose declared |
| 5 | + * exposure does not serve `list` answers as a target the caller cannot read: |
| 6 | + * `reference_not_found`, for a cell that matches and a cell that does not |
| 7 | + * alike, and the target is never read. |
| 8 | + * |
| 9 | + * The decision is the spec's one (`canServeApiOperation`), asked of the |
| 10 | + * target's `enable` block as the protocol's `getMetaItem` serves it. It takes |
| 11 | + * no caller, so a member and an administrator are answered the same. The real |
| 12 | + * doors (`POST /data/:object/import` and `/import/jobs`, both personas) are |
| 13 | + * pinned in `packages/qa/dogfood/test/import-reference-exposure.dogfood.test.ts`. |
| 14 | + */ |
| 15 | + |
| 16 | +import { describe, it, expect, vi } from 'vitest'; |
| 17 | +import { runImport, type ImportProtocolLike } from './import-runner'; |
| 18 | +import { buildFieldMetaMap, type ExportFieldMeta } from './import-field-meta.js'; |
| 19 | + |
| 20 | +type FindArgs = Parameters<ImportProtocolLike['findData']>[0]; |
| 21 | + |
| 22 | +const TARGET = 'rx_target'; |
| 23 | +const RECORD = { id: 'rx_1', name: 'Existing' }; |
| 24 | + |
| 25 | +const metaMap = new Map<string, ExportFieldMeta>([ |
| 26 | + ['name', { name: 'name', type: 'text' }], |
| 27 | + ['ref', { name: 'ref', type: 'lookup', reference: TARGET }], |
| 28 | +]); |
| 29 | + |
| 30 | +/** A protocol over one target record, whose declaration is `enable` (or a throw). */ |
| 31 | +function protocol(enable: unknown, opts: { metaThrows?: boolean } = {}) { |
| 32 | + const findData = vi.fn(async (args: FindArgs) => { |
| 33 | + if (args.object !== TARGET) return []; |
| 34 | + const [[field, value]] = Object.entries(args.query!.where!); |
| 35 | + return (RECORD as Record<string, unknown>)[field] === value ? [RECORD] : []; |
| 36 | + }); |
| 37 | + const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) => { |
| 38 | + if (opts.metaThrows) throw new Error('metadata store unavailable'); |
| 39 | + return { type: 'object', name, item: { name, ...(enable === undefined ? {} : { enable }) } }; |
| 40 | + }); |
| 41 | + const p: ImportProtocolLike = { findData, getMetaItem, createData: vi.fn(async (a) => ({ id: 'new', ...a.data })), updateData: vi.fn() }; |
| 42 | + return { p, findData, getMetaItem }; |
| 43 | +} |
| 44 | + |
| 45 | +const run = (p: ImportProtocolLike, context: Record<string, unknown>) => |
| 46 | + runImport({ |
| 47 | + p, context, objectName: 'rx_source', metaMap, writeMode: 'insert', matchFields: [], dryRun: true, |
| 48 | + runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false, |
| 49 | + rows: [{ name: 'a', ref: 'Existing' }, { name: 'b', ref: 'Missing' }, { name: 'c', ref: 'rx_1' }], |
| 50 | + }); |
| 51 | + |
| 52 | +const verdict = (r: { ok: boolean; code?: string; field?: string }) => ({ ok: r.ok, code: r.code, field: r.field }); |
| 53 | +const NOT_FOUND = { ok: false, code: 'reference_not_found', field: 'ref' }; |
| 54 | +const PERSONAS = { member: { userId: 'u_member', roles: ['member'] }, admin: { userId: 'u_admin', roles: ['admin'] } }; |
| 55 | + |
| 56 | +describe('[#22739] import reference resolution asks the target its declared exposure, for list', () => { |
| 57 | + for (const [persona, context] of Object.entries(PERSONAS)) { |
| 58 | + for (const [label, enable] of [ |
| 59 | + ['apiEnabled: false', { apiEnabled: false }], |
| 60 | + ['the deny-all whitelist', { apiMethods: [] }], |
| 61 | + ['a whitelist granting get but not list', { apiMethods: ['get'] }], |
| 62 | + ['a whitelist granting no read', { apiMethods: ['create'] }], |
| 63 | + ] as const) { |
| 64 | + it(`${persona}: ${label} answers a match, a miss and an id alike, and reads nothing`, async () => { |
| 65 | + const { p, findData } = protocol(enable); |
| 66 | + const summary = await run(p, context); |
| 67 | + expect(summary.results.map(verdict)).toEqual([NOT_FOUND, NOT_FOUND, NOT_FOUND]); |
| 68 | + expect(findData).not.toHaveBeenCalled(); |
| 69 | + }); |
| 70 | + } |
| 71 | + |
| 72 | + for (const [label, enable] of [ |
| 73 | + ['no enable block', undefined], |
| 74 | + ['a whitelist granting list', { apiMethods: ['list'] }], |
| 75 | + ] as const) { |
| 76 | + it(`CONTROL ${persona}: ${label} resolves a match and an id, and misses a miss`, async () => { |
| 77 | + const { p } = protocol(enable); |
| 78 | + const summary = await run(p, context); |
| 79 | + expect(summary.results.map(verdict)).toEqual([{ ok: true, code: undefined, field: undefined }, NOT_FOUND, { ok: true, code: undefined, field: undefined }]); |
| 80 | + }); |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + it('a declaration that cannot be read withholds the target (fail-closed), asked once per import', async () => { |
| 85 | + const { p, findData, getMetaItem } = protocol(undefined, { metaThrows: true }); |
| 86 | + const summary = await run(p, PERSONAS.member); |
| 87 | + expect(summary.results.map(verdict)).toEqual([NOT_FOUND, NOT_FOUND, NOT_FOUND]); |
| 88 | + expect(findData).not.toHaveBeenCalled(); |
| 89 | + expect(getMetaItem).toHaveBeenCalledTimes(1); |
| 90 | + }); |
| 91 | +}); |
| 92 | + |
| 93 | +/** |
| 94 | + * CONTROL — which fields the import treats as references is unchanged: a field |
| 95 | + * declaring a `reference` string. A `user` field written without one is NOT |
| 96 | + * resolved (its raw cell reaches the write, as on `main`), although the spec's |
| 97 | + * arbiter gives it `sys_user`; adopting that target widens the accept set and |
| 98 | + * is not this change. A non-reference type declaring `reference` names no target. |
| 99 | + */ |
| 100 | +describe('[#22739] the reference field set stays the declared-reference set', () => { |
| 101 | + it('a user field without reference passes its cell through unresolved; one declaring it is resolved', async () => { |
| 102 | + const meta = buildFieldMetaMap({ fields: { |
| 103 | + owner: { type: 'user', label: 'Owner' }, |
| 104 | + assignee: { type: 'user', label: 'Assignee', reference: 'sys_user' }, |
| 105 | + amount: { type: 'number', label: 'Amount', reference: 'rx_target' }, |
| 106 | + } }); |
| 107 | + expect([meta.get('owner')!.reference, meta.get('assignee')!.reference, meta.get('amount')!.reference]).toEqual([undefined, 'sys_user', undefined]); |
| 108 | + |
| 109 | + const findData = vi.fn(async (args: FindArgs) => (args.object === 'sys_user' && Object.values(args.query!.where!)[0] === 'Ann' ? [{ id: 'u_ann' }] : [])); |
| 110 | + const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) => ({ type: 'object', name, item: { name } })); |
| 111 | + const createData = vi.fn(async (a: { data: Record<string, unknown> }) => ({ id: 'new', ...a.data })); |
| 112 | + const p: ImportProtocolLike = { findData, getMetaItem, createData, updateData: vi.fn() }; |
| 113 | + const summary = await runImport({ |
| 114 | + p, objectName: 'rx_source', metaMap: meta, writeMode: 'insert', matchFields: [], dryRun: false, |
| 115 | + runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false, |
| 116 | + rows: [{ owner: 'Ann', assignee: 'Ann' }], |
| 117 | + }); |
| 118 | + expect(summary.results.map(verdict)).toEqual([{ ok: true, code: undefined, field: undefined }]); |
| 119 | + expect(createData.mock.calls[0]![0].data).toMatchObject({ owner: 'Ann', assignee: 'u_ann' }); |
| 120 | + expect(getMetaItem.mock.calls.map(([r]) => r.name)).toEqual(['sys_user']); |
| 121 | + }); |
| 122 | +}); |
0 commit comments