Skip to content

Commit 7107bde

Browse files
docs(adr-0120): the per-object batch upsert by id joins the create doors; pin the edit-without-read class (#28797)
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk ## 维护者速读 - 改了什么:ADR-0120 的 2026-11-01 修订注记末尾再加一条同日注记,并在其「新建门」清单第一条补一句:带 `id` 的 `POST /data/:object/batch` `upsert`,对调用方读不到的行就是一扇新建门——已被占用但读不到的 `id` 只得到撞键(`409 UNIQUE_VIOLATION`)而不是匹配,没有新建权则得到新建的拒绝;同时记下 driver-memory 开启持久化时重复行会写入开发库这一已接受的残余。dogfood 用例的 `HIDDEN` 表新增「有编辑、无读取」这一类主体(无新建权、有新建权各一);`protocol-batch-data.ts` 的分叉注释补一句。零运行时改动。 - 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开) - 风险与回滚:只动 ADR、一处代码注释和一个测试文件,运行时行为不变;回滚即 revert 本 PR。今后若有人改动该分叉的选臂规则,新用例会转红,这是有意的。 - 先看:`docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md`(风险 hunk:修订注记末尾新增的日期注记,以及清单第一条的补句,核对它与 2026-11-01 修订的裁决范围一致)→ `packages/qa/dogfood/test/upsert-by-id-caller-read.dogfood.test.ts`(`HIDDEN` 表新增的两行,及「不存在的 id」用例里无新建权的分支)→ `packages/metadata-protocol/src/protocol-batch-data.ts`(分叉注释第一条理由的补句)。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1 test/upsert-by-id-caller-read.dogfood.test.ts`,预期 1 个文件、36 个用例全绿(原有 28 个加新增 8 个)。 - 需要你做的:本 PR 触及 `docs/adr/**`(Tier H),请审阅新增注记的措辞后亲手合并或入队。 ## What changes A docs + pin + comment PR. **No runtime change.** - **ADR-0120, the 2026-11-01 amendment.** A dated note is appended to the amendment in the record's own amendment form, and the amendment's first fact (the create-door list) gains one clause pointing at it. The note records that `POST /data/:object/batch` with `operation: 'upsert'` is a create door for a row naming an `id` the caller cannot read: the door's match key is the `id`, read as the caller; a readable row takes the update arm, an unreadable one takes the create arm with the create's answer. Contracts it states: - a held `id` the caller cannot read meets the collision, never a match: `409 UNIQUE_VIOLATION` on a store that enforces the primary key, the same row error a batch create naming that `id` carries, identical for a row hidden inside the caller's organization and one held by another; import `upsert` matches on the mapping's declared key fields, this door on the `id` itself; - a caller without the create grant gets the create's refusal, the same for a held `id` and for one that names no row; - on `driver-memory` the create arm lands a second row with that `id`, and with that driver's persistence configured the duplicate is written to the development store; recorded as the same accepted residual (the development and test driver declares no primary key). Provenance in the note: ruling record `5985885287`, applied to this door by commit `7b62f0a77`. The status line is unchanged; its existing 2026-11-01 entry already points at the amendment this note extends. - **Dogfood pin**, `upsert-by-id-caller-read.dogfood.test.ts` (stock showcase): the `HIDDEN` table gains the edit-without-read principal class on `showcase_semantic_zoo` (`public_read_write`, so the platform's `update` ownership floor does not bind it, and no baseline set grants anything on it), once without and once with `allowCreate`. Each table row now carries the create's expected answer (`403 PERMISSION_DENIED` without the create grant, `409 UNIQUE_VIOLATION` with it); the held-id case asserts the upsert's body equals a batch create naming that id and the stored row is byte-identical afterwards; the unknown-id case answers as the create does (created with the grant; without it, the create's refusal, nothing stored, and the same answer a held id gets). The guard case reads each new principal's grants through `/security/explain` (`object_crud` verdict `grants` on update, `denies` on read), its by-id read (`403`) and its create (`201` / `403`). - **Fork comment**, `packages/metadata-protocol/src/protocol-batch-data.ts`: one clause in the first reason of the reversal paragraph naming the earlier fork's third outcome, stated as the rule. Comment only. ## Tests All at head `751c15af6`, exit codes captured before any pipe. - Dogfood file, under the verify lock, `--maxWorkers=1`: `Test Files 1 passed (1)`, `Tests 36 passed (36)`, lock `VERDICT command-exit 0`. 28 before plus 8 new (two classes, atomic and not, held id and unknown id). - `@objectstack/metadata-protocol` unit tests that drive `batchData` (9 files: `protocol.upsert-existence`, `protocol.batch-atomic`, `protocol.batch-row-driver-text`, `protocol.batch-row-http-status`, `protocol.batch-row-sandbox-refusal`, `protocol.bulk-record-not-found`, `protocol.readonly-insert`, `protocol.write-response-internal-fields.tripwire`, `protocol.bracketed-refusal-opener-absence`), under the verify lock: `Test Files 9 passed (9)`, `Tests 150 passed (150)`, lock `VERDICT command-exit 0`. - Type check: `tsc --noEmit --listFiles` over the dogfood package's own `tsconfig.json` (what its `typecheck` script runs), under the verify lock: exit 0, 0 errors; the edited test is in the program (2261 files listed, 435 of them under `packages/qa/dogfood/test/`). <details> <summary>One-shot ablation of the new pin (reverted fork, measured, restored with proof)</summary> Driven by `scripts/ablation-replace.mjs` in wrap mode (trap-armed restore, absolute path), the whole sequence under the verify lock (`VERDICT command-exit 0`). - Mutate leg: the fork's caller read replaced by a system-context existence probe. Anchor `1 -> 0`, replacement `0 -> 1`, blob `f26864308` to `8037e64df`. `pnpm --filter @objectstack/metadata-protocol build` exit 0. `ablation-dist-preflight` exit 0: marker present in 2 built files. - Run: `Tests 16 failed | 20 passed (36)`. The six new cases are among them: the held-id case for both new classes, atomic and not (4), and the unknown-id case of the no-create class, atomic and not (2). The other ten are the existing hidden-row cases. The with-create class's unknown-id case stays green, as it should: an unknown id takes the create arm under either fork. - Restore leg: blob back to the HEAD blob `f26864308`, `git diff HEAD` empty for the path, whole-tree `git status --porcelain` empty. Rebuilt (exit 0); `ablation-dist-preflight --absent` exit 0: marker absent from all 24 built files, tree clean. Nothing of the ablation is in the tree or the branch. </details> ## Acceptance notes - The import contrast in the note: the import door also matches as the caller and creates on a miss, so what separates the two doors is the match key, the mapping's declared key fields there and the `id` itself here. That is what makes this door's answer to a held-but-unreadable `id` the collision rather than a match, and the note says it that way so the sentence holds for both doors. - Not run locally, by design: `test:repo` (this diff edits none of its tests and the derivation names none of it; `Test Core` runs it), and the repo-wide scans CI owns. - `pnpm check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3: seven packages outside the dogfood closure had no `dist/`). Built those seven with turbo (all cache hits) and re-ran it: exit 0. Both readings are recorded here; the list below carries the measured one. - No changeset. The diff touches `docs/adr/**`, the private `@objectstack/dogfood` package, and one code comment in `packages/metadata-protocol/src`. Measured: the comment's text has 0 hits in the built `dist/` (the package's `files[]` ships `dist`), against the positive control `callerMayReadObject` with 2 hits. `skip-changeset` is declared at creation; `pnpm check:empty-changeset` exit 0. ## Verification Head `1e7ced88b`. It adds one commit on top of `751c15af6`, from contract review 6563439682: two wording corrections in the ADR-0120 note, prose only and in that one file. The driver-memory bullet now names the driver's public `persistence` option ("a custom adapter included") in place of a private field name. The facts-carry sentence now reads "holds for that arm, which no readable row enters". Re-run at `1e7ced88b`, exit codes captured before any pipe: - `node scripts/check-adr-links.mjs` :: exit 0 (967 relative link destinations resolve) - `pnpm check:adr-index` :: exit 0 (`docs/adr/README.md is current`) - `pnpm check:doc-anchors` :: exit 0 (857 internal fragment links resolve) - `pnpm check:doc-authoring` :: exit 0 The dogfood run, the unit tests, the type check, the ablation and the derived gate families below were taken at `751c15af6`. The commit after it touches neither test file nor any code path. At `751c15af6`: - `node scripts/check-adr-links.mjs` :: exit 0 (967 relative link destinations resolve) - `pnpm check:adr-index` :: exit 0 (`docs/adr/README.md is current`) - `pnpm check:doc-anchors` :: exit 0 (857 internal fragment links resolve) - `pnpm check:doc-authoring` :: exit 0 - `pnpm check:nul-bytes` :: exit 0, and a control-byte scan of the three changed files: no hits - `node scripts/pm/dispatch-gates.mjs --ran` :: exit 0, `63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN`, every line carrying its exit code <details> <summary>Derived gate families (dispatch-gates --commands, reconciled with --ran)</summary> Each line is `command :: exit code`, captured before any pipe, at `751c15af6`. - `node scripts/check-adr-links.mjs` :: exit 0 - `node scripts/check-adr-links.mjs --self-test` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-corpus.mjs` :: exit 0 - `node scripts/check-dts-emitted.mjs --self-test` :: exit 0 - `node scripts/check-issue-citations.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs --self-test` :: exit 0 - `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` :: exit 0 - `node scripts/ci/plan-test-core-shards.mjs --self-test` :: exit 0 - `node scripts/ci/prune-turbo-cache.mjs --self-test` :: exit 0 - `node scripts/docs-audit/check-affected-docs.mjs` :: exit 0 - `node scripts/docs-audit/check-drift-comment.mjs` :: exit 0 - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` :: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` :: exit 0 - `pnpm --filter @objectstack/spec run check:empty-state` :: exit 0 - `pnpm --filter @objectstack/spec run check:liveness` :: exit 0 - `pnpm --filter @objectstack/spec run check:strictness-ledger` :: exit 0 - `pnpm --filter @objectstack/spec run check:variant-docs` :: exit 0 - `pnpm check:adr-anchors` :: exit 0 - `pnpm check:adr-index` :: exit 0 - `pnpm check:adr-symbol-anchors` :: exit 0 - `pnpm check:ci-filter-parity` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:dispatcher-error-vocabulary` :: exit 0 - `pnpm check:doc-authoring` :: exit 0 - `pnpm check:driver-memory-census` :: exit 0 - `pnpm check:dts-closure` :: exit 0 - `pnpm check:dual-build-cjs-loads` :: exit 0 - `pnpm check:durability-log-level` :: exit 0 - `pnpm check:engine-double-contract` :: exit 0 - `pnpm check:error-status-conformance` :: exit 0 - `pnpm check:gitlink-declared` :: exit 0 - `pnpm check:issue-citations` :: exit 0 - `pnpm check:keyed-text-bounds` :: exit 0 - `pnpm check:lean-entry-closure` :: exit 0 - `pnpm check:logger-receiver-detach` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 - `pnpm check:objectql-double-limit` :: exit 0 - `pnpm check:org-identifier` :: exit 0 - `pnpm check:page-declaration-shape` :: exit 0 - `pnpm check:platform-object-tenancy-census` :: exit 0 - `pnpm check:pm-governed-merges` :: exit 0 - `pnpm check:pm-prior-rulings` :: exit 0 - `pnpm check:published-files` :: exit 0 - `pnpm check:query-options-erasure` :: exit 0 - `pnpm check:refd-timer-probe` :: exit 0 - `pnpm check:registry-log-declared` :: exit 0 - `pnpm check:rest-log-spy-declared` :: exit 0 - `pnpm check:slot-lookup` :: exit 0 - `pnpm check:sourcemap-no-sources-content` :: exit 0 - `pnpm check:system-context-census` :: exit 0 - `pnpm check:test-source-alias` :: exit 0 - `pnpm check:tier-file-adoption` :: exit 0 - `pnpm check:type-check-coverage` :: exit 0 - `pnpm check:type-check-debt` :: exit 0 - `pnpm check:undeclared-dep-imports` :: exit 0 - `pnpm check:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 - `pnpm check:doc-anchors` :: exit 0 - `pnpm check:empty-changeset` :: exit 0 </details> --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 00918c8 commit 7107bde

3 files changed

Lines changed: 127 additions & 22 deletions

File tree

‎docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,10 @@ Two properties of this table are the ADR's acceptance criteria:
183183
> - **Every create door accepts a caller-supplied `id`, and a fresh one lands as given:**
184184
> `POST /data/:object`, `POST /data/:object/batch` with `operation: 'create'`, `createMany`,
185185
> the cross-object `POST /batch`, import in `insert` mode and in `upsert` mode (an upsert
186-
> whose match, run as the caller, finds no row creates), and clone's `overrides.id`.
186+
> whose match, run as the caller, finds no row creates), and clone's `overrides.id`; and
187+
> `POST /data/:object/batch` with `operation: 'upsert'` for a row naming an `id` the caller
188+
> cannot read or an `id` that names no row (added by the dated note at the end of this
189+
> amendment).
187190
> - **A create that collides on `id` answers `409 UNIQUE_VIOLATION` and writes nothing**, on a
188191
> store that enforces the primary key — the SQL-family drivers do (`driver-sql`, and
189192
> `driver-sqlite-wasm` and `driver-turso`, which extend it); `driver-memory`, the
@@ -244,6 +247,37 @@ Two properties of this table are the ADR's acceptance criteria:
244247
> (same organization, the stock showcase) and
245248
> [`create-caller-id-collision-walled.dogfood.test.ts`](../../packages/qa/dogfood/test/create-caller-id-collision-walled.dogfood.test.ts)
246249
> (another organization, `isolated` and `group`).
250+
>
251+
> **Amendment (2026-11-01, ruling record
252+
> [`5985885287`](https://github.com/objectstack-ai/objectstack/issues/21771#issuecomment-5985885287)
253+
> applied to the per-object batch `upsert` by commit `7b62f0a77`) — `POST /data/:object/batch`
254+
> with `operation: 'upsert'` is a create door for a row naming an `id` the caller cannot read,
255+
> and joins the list above.**
256+
>
257+
> The by-id upsert matches on the row's `id`, read as the caller — the object-level read
258+
> admission, then the caller's own read of that id, under the context its write runs with. A
259+
> row the caller can read is matched and takes the update arm, where the engine still judges
260+
> the write. A row the caller cannot read is, on the write doors, a row that does not exist
261+
> (ruling record `5985885287`), so it takes the create arm: the same insert and the same row
262+
> answer as `operation: 'create'` naming that `id`. Every fact above holds for that arm, which no
263+
> readable row enters, and two are particular to it:
264+
>
265+
> - **A held `id` the caller cannot read meets the collision, never a match.** Import `upsert`
266+
> matches on the mapping's declared key fields; this door's match key is the `id` itself, so
267+
> the only answer a held-but-unreadable `id` can get here is the create's: `409
268+
> UNIQUE_VIOLATION` on a store that enforces the primary key, the same row error a batch
269+
> create naming that `id` carries, identical whether the row is hidden inside the caller's
270+
> organization or held by another one. A caller without the create grant gets the create's
271+
> refusal instead, the same for a held `id` and for one that names no row.
272+
> - **On `driver-memory` the create arm lands a second row with that `id`**, as every create
273+
> door does there; when that driver runs with persistence (its opt-in `persistence` option,
274+
> a custom adapter included), the duplicate is also written to that development store.
275+
> Accepted as the same residual: the development and test driver declares no primary key.
276+
>
277+
> Pinned at the HTTP door, atomic and not, by
278+
> [`upsert-by-id-caller-read.dogfood.test.ts`](../../packages/qa/dogfood/test/upsert-by-id-caller-read.dogfood.test.ts)
279+
> (the stock showcase, and a walled two-organization boot), and at the protocol seam by
280+
> [`protocol.upsert-existence.test.ts`](../../packages/metadata-protocol/src/protocol.upsert-existence.test.ts).
247281
248282
## Posture portability: one app package, every tenancy posture
249283

‎packages/metadata-protocol/src/protocol-batch-data.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,9 @@ export async function runBatchDataLoop(
404404
// row — so a caller holding create learned that a
405405
// row it cannot read exists, which ruling
406406
// 5985885287 closed on the by-id update and delete
407-
// doors;
407+
// doors — and that fork could reach the update arm
408+
// for a principal the caller-read rule now sends
409+
// to the create arm;
408410
// - the key collision #5187 called a misdirection is
409411
// the answer the maintainer ratified on 2026-11-01
410412
// as the accepted cost of a deployment-global key,

‎packages/qa/dogfood/test/upsert-by-id-caller-read.dogfood.test.ts‎

Lines changed: 89 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515
//
1616
// 1. ⭐ an upsert naming a row the caller cannot read answers the SAME body as
1717
// a batch create naming that id (the row's `id` echo aside), whose code and
18-
// status are the plain `POST /data/:object` create's `409
19-
// UNIQUE_VIOLATION`; the stored row is byte-identical afterwards and the
20-
// answer names no other organization;
21-
// 2. an upsert naming an id that exists nowhere is created, as the create is;
18+
// status are the plain `POST /data/:object` create's — `409
19+
// UNIQUE_VIOLATION` for a caller holding the create grant, the create's
20+
// `403 PERMISSION_DENIED` for one without it; the stored row is
21+
// byte-identical afterwards and the answer names no other organization;
22+
// 2. an upsert naming an id that exists nowhere answers as the create does:
23+
// created with the create grant, the create's refusal without it (and
24+
// then the same refusal as for a held id);
2225
// 3. a row the caller CAN read keeps the update arm: written with the edit
2326
// grant, the engine's refusal without it.
2427
//
@@ -32,6 +35,12 @@
3235
// reads: the update arm's refusal, unchanged;
3336
// - a create-only principal on `showcase_contact` (no read at all) — every
3437
// row is one it cannot read;
38+
// - the edit-without-read class on `showcase_semantic_zoo`: a principal
39+
// holding edit and no read, once without and once with create. The object
40+
// declares `public_read_write`, so the platform's `update` ownership floor
41+
// does not bind it, and no baseline set grants anything on it; an edit
42+
// grant makes no row readable, so every row is one the principal cannot
43+
// read;
3544
// - control: member, `showcase_account` (read only) — one refusal for a row
3645
// that exists and for one that does not, unchanged.
3746
//
@@ -100,13 +109,22 @@ describe('upsert by id: a row the caller cannot read answers as a create naming
100109
let adminTok: string;
101110
let memberTok: string;
102111
let creatorTok: string;
112+
let editorTok: string;
113+
let editCreatorTok: string;
103114
const d = doors(() => stack);
104115
const rows: Record<string, string> = {};
105116
let projectId = '';
106117

107118
const CREATE_ONLY = 'qa_upsert_read_contact_create_only';
108119
const CREATOR = 'upsert-read-creator@verify.test';
109120
const MEMBER = 'upsert-read-member@verify.test';
121+
// The edit-without-read class, on an object whose OWD keeps the platform's
122+
// `update` ownership floor out of play and that no baseline set grants.
123+
const OPEN_WRITE = 'showcase_semantic_zoo';
124+
const EDIT_ONLY = 'qa_upsert_read_zoo_edit_only';
125+
const EDIT_CREATE = 'qa_upsert_read_zoo_edit_create';
126+
const EDITOR = 'upsert-read-editor@verify.test';
127+
const EDIT_CREATOR = 'upsert-read-edit-creator@verify.test';
110128

111129
const stored = async (object: string, id: string) =>
112130
JSON.stringify(await ql.findOne(object, { where: { id }, context: SYS }));
@@ -117,6 +135,27 @@ describe('upsert by id: a row the caller cannot read answers as a create naming
117135
showcase_task: () => ({ title: 'upsert probe', project: projectId, status: 'todo' }),
118136
showcase_contact: () => ({ name: 'Upsert Probe', email: 'upsert-probe@verify.test' }),
119137
showcase_account: () => ({ name: 'Upsert Probe', status: 'prospect' }),
138+
[OPEN_WRITE]: () => ({ name: 'Upsert Probe' }),
139+
};
140+
141+
/** Save a permission set granting `grant` on one object, and bind it to a fresh user. */
142+
const principalWith = async (set: string, object: string, grant: Record<string, boolean>, email: string): Promise<string> => {
143+
const saved = await stack.apiAs(adminTok, 'PUT', `/meta/permission/${set}`, {
144+
name: set, label: set, systemPermissions: [], objects: { [object]: grant },
145+
});
146+
expect(saved.status, await saved.clone().text()).toBe(200);
147+
const token = await stack.signUp(email, PASS);
148+
const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id;
149+
expect(userId, `${email} provisioned`).toBeTruthy();
150+
await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set }, { context: SYS });
151+
return token;
152+
};
153+
154+
/** The `object_crud` layer's verdict `/security/explain` gives the caller for one operation. */
155+
const crudVerdict = async (token: string, object: string, operation: string): Promise<string> => {
156+
const res = await d.call(token, 'GET', `/security/explain?object=${object}&operation=${operation}`);
157+
expect(res.status, JSON.stringify(res.body)).toBe(200);
158+
return String((res.body?.layers ?? []).find((l: any) => l?.layer === 'object_crud')?.verdict);
120159
};
121160

122161
beforeAll(async () => {
@@ -137,12 +176,16 @@ describe('upsert by id: a row the caller cannot read answers as a create naming
137176
expect(creatorId, 'creator provisioned').toBeTruthy();
138177
await ql.insert('sys_user_permission_set', { user_id: creatorId, permission_set: CREATE_ONLY }, { context: SYS });
139178

179+
// The edit-without-read class: edit and no read, without and with create.
180+
editorTok = await principalWith(EDIT_ONLY, OPEN_WRITE, { allowRead: false, allowCreate: false, allowEdit: true, allowDelete: false }, EDITOR);
181+
editCreatorTok = await principalWith(EDIT_CREATE, OPEN_WRITE, { allowRead: false, allowCreate: true, allowEdit: true, allowDelete: false }, EDIT_CREATOR);
182+
140183
projectId = String((await ql.find('showcase_project', { limit: 1, context: SYS }))?.[0]?.id ?? '');
141184
expect(projectId, 'the showcase seed provides a project').toBeTruthy();
142185

143186
// Rows the admin owns: hidden from the member by the private OWD, and from
144-
// the creator by having no read at all.
145-
for (const object of ['showcase_inquiry', 'showcase_private_note', 'showcase_contact']) {
187+
// the creator and the edit-without-read principals by having no read at all.
188+
for (const object of ['showcase_inquiry', 'showcase_private_note', 'showcase_contact', OPEN_WRITE]) {
146189
const made = await d.create(adminTok, object, unknownId(), PAYLOAD[object]());
147190
expect(made.status, `admin ${object} created — ${JSON.stringify(made.body)}`).toBeLessThan(300);
148191
rows[`hidden:${object}`] = createdId(made);
@@ -171,15 +214,32 @@ describe('upsert by id: a row the caller cannot read answers as a create naming
171214
expect((await d.create(memberTok, 'showcase_account', unknownId(), PAYLOAD.showcase_account())).status, 'the member may not create accounts').toBe(403);
172215
expect((await d.call(creatorTok, 'GET', `/data/showcase_contact/${rows['hidden:showcase_contact']}`)).status, 'the creator reads no contact').toBe(403);
173216
expect((await d.create(creatorTok, 'showcase_contact', unknownId(), PAYLOAD.showcase_contact())).status, 'the creator creates contacts').toBe(201);
217+
218+
// The edit-without-read class: the edit grant is real, the read grant is
219+
// absent, and the create grant is exactly where the table says.
220+
const zooRow = rows[`hidden:${OPEN_WRITE}`];
221+
expect((await d.call(adminTok, 'GET', `/data/${OPEN_WRITE}/${zooRow}`)).status).toBe(200);
222+
for (const [who, tok, mayCreate] of [['edit only', editorTok, false], ['edit+create', editCreatorTok, true]] as const) {
223+
expect(await crudVerdict(tok, OPEN_WRITE, 'update'), `${who}: holds edit`).toBe('grants');
224+
expect(await crudVerdict(tok, OPEN_WRITE, 'read'), `${who}: holds no read`).toBe('denies');
225+
expect((await d.call(tok, 'GET', `/data/${OPEN_WRITE}/${zooRow}`)).status, `${who}: reads no row`).toBe(403);
226+
expect((await d.create(tok, OPEN_WRITE, unknownId(), PAYLOAD[OPEN_WRITE]())).status, `${who}: the create grant`).toBe(mayCreate ? 201 : 403);
227+
}
174228
});
175229

176-
const HIDDEN: Array<[string, string, () => string]> = [
177-
['member, read+create without edit, private row', 'showcase_inquiry', () => memberTok],
178-
['member, read+create+edit, private row', 'showcase_private_note', () => memberTok],
179-
['create-only principal, no read at all', 'showcase_contact', () => creatorTok],
230+
/** The plain create's answer for an id already held, by whether the caller holds the create grant. */
231+
const COLLISION = { status: 409, code: 'UNIQUE_VIOLATION', mayCreate: true } as const;
232+
const CREATE_REFUSED = { status: 403, code: 'PERMISSION_DENIED', mayCreate: false } as const;
233+
234+
const HIDDEN: Array<[string, string, () => string, typeof COLLISION | typeof CREATE_REFUSED]> = [
235+
['member, read+create without edit, private row', 'showcase_inquiry', () => memberTok, COLLISION],
236+
['member, read+create+edit, private row', 'showcase_private_note', () => memberTok, COLLISION],
237+
['create-only principal, no read at all', 'showcase_contact', () => creatorTok, COLLISION],
238+
['edit without read or create, open-write OWD', OPEN_WRITE, () => editorTok, CREATE_REFUSED],
239+
['edit+create without read, open-write OWD', OPEN_WRITE, () => editCreatorTok, COLLISION],
180240
];
181241

182-
for (const [label, object, tok] of HIDDEN) {
242+
for (const [label, object, tok, answer] of HIDDEN) {
183243
for (const atomic of [false, true]) {
184244
it(`${label} (${object}, ${atomic ? 'atomic' : 'non-atomic'}): the unreadable row answers as a create naming it`, async () => {
185245
const id = rows[`hidden:${object}`];
@@ -192,25 +252,34 @@ describe('upsert by id: a row the caller cannot read answers as a create naming
192252
// ⭐ The create's answer, whole: same status, same envelope, same row
193253
// refusal — nothing of the upsert's own.
194254
expect(comparable(ups), JSON.stringify(ups.body)).toEqual(comparable(batchCreate));
195-
expect(create.status, JSON.stringify(create.body)).toBe(409);
196-
expect(create.body?.code).toBe('UNIQUE_VIOLATION');
255+
expect(create.status, JSON.stringify(create.body)).toBe(answer.status);
256+
expect(create.body?.code).toBe(answer.code);
197257
expect(ups.body?.results?.[0]?.errors?.[0]).toMatchObject({ code: create.body.code, httpStatus: create.status });
198258
expect(ups.body?.results?.[0]?.id).toBe(id);
199259
// Nothing moved.
200260
expect(await stored(object, id)).toBe(before);
201261
});
202262

203-
it(`${label} (${object}, ${atomic ? 'atomic' : 'non-atomic'}): an id that names no row is created, as the create is`, async () => {
263+
it(`${label} (${object}, ${atomic ? 'atomic' : 'non-atomic'}): an id that names no row answers as the create does`, async () => {
204264
const id = unknownId();
205265
const ups = await d.upsert(tok(), object, id, PAYLOAD[object](), atomic);
206-
expect(ups.status, JSON.stringify(ups.body)).toBe(200);
207-
expect(ups.body?.success, JSON.stringify(ups.body)).toBe(true);
208-
expect(ups.body?.results?.[0]).toMatchObject({ id, success: true });
209-
expect(JSON.parse(await stored(object, id))?.id).toBe(id);
210-
211266
const other = unknownId();
212267
const batchCreate = await d.batchCreate(tok(), object, other, PAYLOAD[object](), atomic);
213-
expect(batchCreate.body?.success, JSON.stringify(batchCreate.body)).toBe(true);
268+
if (answer.mayCreate) {
269+
expect(ups.status, JSON.stringify(ups.body)).toBe(200);
270+
expect(ups.body?.success, JSON.stringify(ups.body)).toBe(true);
271+
expect(ups.body?.results?.[0]).toMatchObject({ id, success: true });
272+
expect(JSON.parse(await stored(object, id))?.id).toBe(id);
273+
expect(batchCreate.body?.success, JSON.stringify(batchCreate.body)).toBe(true);
274+
return;
275+
}
276+
// Without the create grant: the create's refusal, nothing stored, and
277+
// the same answer an upsert naming a held id gets.
278+
expect(comparable(ups), JSON.stringify(ups.body)).toEqual(comparable(batchCreate));
279+
expect(ups.body?.results?.[0]?.errors?.[0], JSON.stringify(ups.body)).toMatchObject({ code: answer.code, httpStatus: answer.status });
280+
expect(await ql.findOne(object, { where: { id }, context: SYS })).toBeFalsy();
281+
const held = await d.upsert(tok(), object, rows[`hidden:${object}`], PAYLOAD[object](), atomic);
282+
expect(comparable(held), JSON.stringify(held.body)).toEqual(comparable(ups));
214283
});
215284
}
216285
}

0 commit comments

Comments
 (0)