Skip to content

Commit c74d843

Browse files
fix(core)!: an import's reference resolution asks the lookup target its declared exposure before matching a cell (#22739) (#22770)
Fixes #22739 Clause-②: yes (narrowing) Census row 7 of #22661: the import door's reference resolution matched a lookup cell against a TARGET object nobody addressed, without asking that target its declared exposure. A match stored the target record's id and a miss answered `reference_not_found` per row, so the row report told the two apart even for a target every data route refuses. ## What changed - **The decision.** `servesReferenceTarget` (new, `packages/core/src/utils/import-runner.ts`) asks the spec's one exposure decision, `canServeApiOperation` (`@objectstack/spec/data`), of the target's own `enable` block, read through the protocol's `getMetaItem`. `resolveRef` (the `RefResolver` that `runImport` builds) asks it first, once per target per import. No second rule and no second list: the decision function is the one #22661 settled. - **The answer for a refused target** is the measured precedent for a target the caller cannot read: every cell, naming a record or not, a pasted id included, answers `reference_not_found`, and nothing of the target is read. A declaration that cannot be read withholds too (fail-closed). No new error code, no status change. - **The operation is `list`.** Matching a cell is a predicate read over the target (`findData` with a `where` on one candidate field), the runtime `find` the spec maps to `list` in `DATA_ACTION_TO_API_OPERATION`, and the same read a list route with a field filter performs. `get` would keep serving a name match on a get-only target, which refuses exactly that read; #22661's two reads asked `get` because each turns an id the caller already holds into a record. - **The target is read through the arbiter, for a field that declares `reference`.** `buildFieldMetaMap` (`import-field-meta.ts`) treats exactly `main`'s set of fields as references (those carrying a `reference` string), and reads such a field's target through `referenceTargetOf`. So the #22661 enumeration pin now sees this caller and classifies it `decided` (operation `list`, decision `servesReferenceTarget`). A `user` field written without `reference` is unchanged (REWORK 6105610630); its gap is carried by #22785. - **`ImportProtocolLike`** gains one optional member, `getMetaItem`. A protocol without it (plugin-auth's identity import, whose reads run under the system identity) has no declaration to judge and is not judged; none is fabricated. The decision takes no caller, so an administrator, a member and a system context (the connector pull, which drives `runImport` through the real protocol) are answered the same, as #22661's two reads answer every caller the same. ## Measured on a real stack (fixture objects only) `@objectstack/verify` boot with the real SecurityPlugin, ObjectQL, SQL driver and REST layers; the #22661 fixture targets plus a row-scoped one; an administrator and a member; the synchronous import door and the async jobs door; three cells per target (naming the record, naming none, the record's id). | target declaration | before (base `bf515e724d`) | after | |---|---|---| | off switch / whitelist without `list` (create-only, get-only); deny-all pinned in the core unit test only | match and id stored, miss `reference_not_found` | all three `reference_not_found` | | no `enable` block / whitelist granting `list` | match and id stored, miss `reference_not_found` | unchanged | | PRECEDENT: member without read on the target, or record hidden by row scope | all three `reference_not_found` | unchanged | Identical for both personas and both doors (the member's job report was read with a read grant on its own import jobs added to the fixture member). The refused rows carry the precedent's exact sentence shape (field label, then the cell). Before-readings reproduce #22661's census row 7. ## Pins and ablation - `packages/core/src/utils/import-runner-reference-exposure.test.ts` (14): per persona, four refusing declarations answer match, miss and id alike and never call `findData`; two serving declarations resolve (controls); one fail-closed case; and the `user`-field control (a `user` field without `reference` has no target and its cell reaches the write unresolved, as on `main`). - `packages/qa/dogfood/test/import-reference-exposure.dogfood.test.ts` (11): per persona, an ARMED leg (each target's own list answer read off the data door), then per door (sync and jobs) a withheld leg and a served control; plus the precedent leg. - `packages/core/src/security/second-object-read-exposure.pin.test.ts` (4): the new `decided` row. Ablation, each through `scripts/ablation-replace.mjs` on committed `22bfc2770b`, anchors 1 to 0, each restore proven (blob equals HEAD, `git diff HEAD` empty): - A, wiring bypassed in `resolveRef`: core pin 9 red / 4 green (every refusing leg plus fail-closed). - A2, operation `get`: 4 red / 9 green (get-only refused legs and list-only controls flip). - A3, the catch fails open: 1 red (fail-closed). - D, `import-field-meta.ts` back on the raw carrier: enumeration pin 2 red (the stale classification and the matcher leg). - G (rework, on committed `f06912e442`), the guard removed (`reference: referenceTargetOf(f)`): the `user`-field control 1 red / 13 green. - C, A on the built artifact: core rebuilt, `ablation-dist-preflight` marker present (exit 0), dogfood pin 4 red / 7 green (the withheld leg of both doors for both personas); restore rebuilt, preflight `--absent` exit 0, rerun 11 / 11. ## Verification Each reading names the commit it was taken on. The final head is `c06ec74a87`. The rework merged `origin/main` twice: `0d326bfb12`, then `c06ec74a87`, because #22766 edited the enumeration pin. The decided lists were united: `servesExpansionTarget`, `servesLabelTarget`, `servesPayloadDisplayTarget`, `servesReferenceTarget`, `servesSummaryTitleTarget`. - `@objectstack/core` at `c06ec74a87`, after a full workspace build: `test` 93 files / 2349 passed; `test:repo` 5 files / 55 passed; `typecheck` exit 0, including `check:test-typecheck`. - `@objectstack/dogfood` at `c06ec74a87`: this pin 11 / 11 and #22661's `second-object-exposure` pin 25 / 25 (36 / 36); `typecheck` exit 0. - Importers at `c06ec74a87`: `@objectstack/rest` `typecheck` exit 0, and its 38 test files that reach the import, export or template doors 38 / 38; `@objectstack/plugin-auth` `admin-import-users` 2 files / 50; `@objectstack/service-automation` connector pull 3 files / 22. - Lint, narrowed and proven: `eslint --no-inline-config` on the 6 changed `.ts` files; population read from `--print-config`; `--format json` 6 files, 0 errors, 0 warnings. `eslint.config.mjs` enables no type-aware linting. Repo-wide `pnpm lint` is CI's. - Gates at `c06ec74a87`: `dispatch-gates --commands` derived 69 (the dispatch's 51 are a subset); all 69 exit 0; `--ran`: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. ## File surface - `packages/rest/src/export-format.test.ts` is outside the claim's surface. Its presentation-keys fixture declared `reference` on a `number` field; read through the arbiter a non-reference type names no target, so the fixture now declares a `lookup` (the fixture declared a key that is inert on a `number` field (`FieldSchema` has no per-type refinement on `reference`)). Test-only, two literals. - The door-level pin reuses the #22661 fixture without editing it; the member read grant on its import jobs is added in the new test file. ## Acceptance notes - **`Clause-②: yes (narrowing)`** (edited by the seat after the contract review 6106439614): no accept set widens, but `ImportProtocolLike`, exported from `@objectstack/core`, gains one optional member (`getMetaItem?`). That is an additive, type-level public-surface change, spelled `yes` by the fleet's practice. The changeset's line follows on this PR's next head. - **`user` fields written without `reference`.** The field set the import, export and template doors treat as references is exactly `main`'s, so such a field is unchanged here, and pinned. The door disagrees with the spec's arbiter, which gives it `sys_user`. That gap is measured on `main` and carried by #22785, because fixing it is a Clause-② widening. - **The residual narrowing: a non-reference type that declares `reference`.** Read through the arbiter, it names no target. The only population whose answer moves is the legacy, schema-refused `type: 'reference'` spelling, which is still listed in the doors' own type tables. An AST census over every git-tracked non-test source under `packages/` and `examples/` (3,636 files at `0d326bfb12`, the same at `c06ec74a87`) found 0 shipped field declaring it. Controls: 30 reference-typed literals and 80 `Field.lookup`/`masterDetail`/`user`/`tree` calls. Positive control: three planted shapes were all found. The changeset states it in FROM → TO. - **Measured producers.** On `origin/main` `bf515e724d`, twelve in-repo objects refuse `list` by declaration and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false`; control: 75 lookups into `sys_user`. No shipped object's import answer changes. - `toFailedResult` is untouched; #22741 follows in this file. --- _Generated by [Claude Code](https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8bd0fcd commit c74d843

7 files changed

Lines changed: 367 additions & 21 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
fix(core)!: an import's reference resolution asks the lookup TARGET its declared exposure before matching a cell (#22739)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is one runtime read, in two places: the import's reference resolution stops matching cells against a lookup's target object whose existing `enable` declaration already refuses that read on every data route (the remedy is the declaration the author already wrote), and a field of the legacy, schema-refused type spelling `'reference'` no longer takes a target from its raw `reference` key, since the spec's arbiter gives it none (no shipped producer declares one). The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed — `ImportProtocolLike` gains one optional member (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
`POST /api/v1/data/:object/import` and the async `POST /api/v1/data/:object/import/jobs` judge the ADDRESSED object's `enable` block. A lookup cell is resolved by matching its text against the lookup's TARGET object — an object nobody addressed.
14+
15+
**FROM.**
16+
17+
- For an administrator and a member alike, a cell was matched against a target whose declaration refuses the read on every data route (`enable.apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `list`): a cell naming an existing record stored its id, and a cell naming none answered `reference_not_found` — so the row report told the two apart.
18+
- A field of the legacy type spelling `'reference'` (not a `FieldType`, so `ObjectSchema` refuses it, but still listed in the import, export and import-template doors' own type tables) took its target from the raw `reference` key: import matched its cells against that object and export expanded it.
19+
20+
**TO.**
21+
22+
- The runner asks the spec's one decision (`canServeApiOperation`) of the TARGET's `enable` block, for `list` — matching a cell is a predicate read over the target, the runtime `find` the spec maps to `list`. A target the decision does not serve answers every cell, naming a record or not (a pasted id included), `reference_not_found` — the answer a target the caller cannot read (no read permission, or a record its row scope hides) already gets. Nothing of the target is read. A target with no `enable` block, or a whitelist that grants `list`, resolves exactly as before. A declaration that cannot be read withholds the target too (fail-closed).
23+
- A field is a reference on those doors exactly when it declares a `reference` string, as before, and its target is read through the spec's arbiter `referenceTargetOf`, which names none for a type outside `lookup` / `master_detail` / `user` / `tree`: such a `'reference'`-typed field's cell is stored as written, and export leaves its stored id. Every other non-reference type was never resolved by these doors and is unchanged. Measured over every git-tracked non-test source under `packages/` and `examples/` (`.ts`, `.js`, `.json`; fixtures excluded) at `0d326bfb12`: no shipped field declares `reference` on a non-reference type (0 object literals, 0 `Field.*` helper calls; the one literal pairing `type` with `reference` is a key-classification table, not a field), against a control of 30 reference-typed literals and 80 `Field.lookup` / `masterDetail` / `user` / `tree` calls; a planted literal and a planted helper call were both found.
24+
25+
**Bindings.**
26+
27+
- **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it (plugin-auth's identity import, whose reads run under the system identity) is not judged.
28+
- **No new error code, no status change.** The refusal is the per-row `reference_not_found` the resolver already answers.
29+
- **Which fields count as references does not widen.** A `user` field written without `reference` still has no target on these doors (its cell reaches the write as written), although the arbiter gives it `sys_user`; adopting that target would widen what the import accepts and is not this change.
30+
31+
**Fix, if an import relied on the old answer:** the target's own declaration decides — grant `list` in its `enable.apiMethods`, or drop `enable.apiEnabled: false`, if the object is meant to be matched by import.
32+
33+
**Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` (spec and qa excluded) and `examples/`: twelve objects refuse `list` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (its own import door already answers 404); the control count of lookups into `sys_user` is 75. So no shipped object's import answer changes. Deployed and cloud-held object definitions were NOT MEASURED.

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
*
1111
* The data routes judge the ADDRESSED object. A read that follows a lookup
1212
* reaches an object nobody addressed: the data door's `$expand` served the row
13-
* fields of a target every data route refuses, and the dataset door's label
14-
* passes rendered its display names. Both now ask the target (behaviour-pinned
15-
* in their own packages and on a real stack in `@objectstack/dogfood`). This
13+
* fields of a target every data route refuses, the dataset door's label
14+
* passes rendered its display names, and the import door matched cells against
15+
* it (#22739). Each read classified `decided` below now asks the target
16+
* (behaviour-pinned in its own package). This
1617
* file is the ENUMERATION: it names the census of such reads, holds each
1718
* decided one to the decision, and goes red when a new one appears without
1819
* being classified here.
@@ -36,15 +37,10 @@
3637
* `origin/main` at eae3368a, those are the analytics relationship hop
3738
* (`service-analytics/src/hop-object.ts` — its target comes from the host's
3839
* relationship resolver; the analytics door already judges every hop's object
39-
* for `aggregate` over `queryObjects`); the import door's reference resolution
40-
* (`core/src/utils/import-runner.ts` `resolveRef`, whose target
41-
* `import-field-meta.ts` reads off the raw `reference` carrier — measured: a
42-
* cell's display text is matched against an unexposed target and the matched
43-
* id is stored, a miss is reported per row; open, carried by a follow-up of
44-
* #22661); and the reads whose target is a FIXED platform object rather than
45-
* an authored one (a file field's `sys_file` hydration, the `sys_user` display
46-
* names the approvals and audit surfaces resolve). They are named here, not
47-
* held.
40+
* for `aggregate` over `queryObjects`); and the reads whose target is a FIXED
41+
* platform object rather than an authored one (a file field's `sys_file`
42+
* hydration, the `sys_user` display names the approvals and audit surfaces
43+
* resolve). They are named here, not held.
4844
*
4945
* The scan surface and its prefilter follow `row-serving-door-exposure.pin.test.ts`
5046
* in this directory: git's authored-file list, never a directory crawl, and `.ts`
@@ -79,7 +75,7 @@ type Classification =
7975
/** The read, in words. */
8076
read: string;
8177
/** The operation the target is judged as. */
82-
operation: 'get';
78+
operation: 'get' | 'list';
8379
/** The function that asks the decision, and the file it lives in. */
8480
decision: { file: string; fn: string };
8581
/** Where the read takes its answer from that function: a call site that must exist. */
@@ -132,6 +128,14 @@ const CALLERS: Record<string, Classification> = {
132128
wiring: { file: 'packages/services/service-analytics/src/analytics-service.ts', call: 'servesLabelTarget(' },
133129
pin: 'packages/services/service-analytics/src/__tests__/dimension-label-exposure.test.ts',
134130
},
131+
'packages/core/src/utils/import-field-meta.ts': {
132+
kind: 'decided',
133+
read: "the import door's reference resolution (`resolveRef` matches a cell against the target; the export and template doors read the same map, the export's expansion decided in protocol.ts)",
134+
operation: 'list',
135+
decision: { file: 'packages/core/src/utils/import-runner.ts', fn: 'servesReferenceTarget' },
136+
wiring: { file: 'packages/core/src/utils/import-runner.ts', call: 'servesReferenceTarget(p, referenceObject' },
137+
pin: 'packages/core/src/utils/import-runner-reference-exposure.test.ts',
138+
},
135139
'packages/objectql/src/relation-filter-lowering.ts': {
136140
kind: 'open',
137141
read: "the data door's nested-relation filter condition (`ObjectQL.lowerRelationConditions` reads the related object to lower it)",
@@ -288,6 +292,7 @@ describe('[#22661] every read that reaches a second object asks that object its
288292
'servesExpansionTarget',
289293
'servesLabelTarget',
290294
'servesPayloadDisplayTarget',
295+
'servesReferenceTarget',
291296
'servesSummaryTitleTarget',
292297
]);
293298

‎packages/core/src/utils/import-field-meta.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,20 @@
1010
* needs. The export renderers stay in `rest`.
1111
*/
1212

13+
import { referenceTargetOf } from '@objectstack/spec/data';
14+
1315
export interface ExportFieldMeta {
1416
name: string;
1517
type?: string;
1618
label?: string;
1719
options?: Array<{ label?: string; value?: unknown; color?: string }>;
18-
/** Target object for lookup / master_detail / user fields. */
20+
/**
21+
* Target object of a field that declares a `reference` string, read through
22+
* the spec's one arbiter `referenceTargetOf` (so a non-reference type names
23+
* none). The import's reference resolution matches cells against it and asks
24+
* it its exposure first (#22739). Only a declared `reference` makes a field
25+
* one: a `user` field written without it keeps no target here, as before.
26+
*/
1927
reference?: string;
2028
/** Field on the referenced record to show as its label. */
2129
displayField?: string;
@@ -79,7 +87,7 @@ export function buildFieldMetaMap(schema: unknown): Map<string, ExportFieldMeta>
7987
type: typeof f.type === 'string' ? f.type : undefined,
8088
label: typeof f.label === 'string' ? f.label : undefined,
8189
options: Array.isArray(f.options) ? f.options : undefined,
82-
reference: typeof f.reference === 'string' ? f.reference : undefined,
90+
reference: typeof f.reference === 'string' ? referenceTargetOf(f) : undefined,
8391
displayField: typeof f.displayField === 'string' ? f.displayField : undefined,
8492
multiple: f.multiple === true,
8593
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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

Comments
 (0)