|
19 | 19 | * check precisely because this column held a value no `sys_user` row |
20 | 20 | * matched. With the sentinel gone the ordinary authoring paths must still |
21 | 21 | * pass — that is the regression #4441 was bitten by. |
| 22 | + * |
| 23 | + * [#14535] "The real thing" is a claim about the TARGET KEY too, and it was |
| 24 | + * false here until this change. The declaration spelled `referenceTo` — an |
| 25 | + * alias `FieldSchema` refuses by name (#11567) and `referenceTargetOf`, the |
| 26 | + * single arbiter the write-path guard resolves through, does not read at all. |
| 27 | + * So the lookup presented as TARGET-LESS and the guard skipped it at |
| 28 | + * `if (!target) continue`, whatever the `readonly` exemption did. Measured on |
| 29 | + * this very write path by counting the guard's own target probe: with the |
| 30 | + * alias spelled no probe ran even with the exemption deleted; with `reference` |
| 31 | + * spelled it runs. The exemption had therefore never been what admitted these |
| 32 | + * writes. Two pins below keep both halves honest — the declaration resolves, |
| 33 | + * and the exemption is what admits an actor id no `sys_user` row matches. |
22 | 34 | */ |
23 | 35 |
|
24 | 36 | import { describe, it, expect, beforeEach } from 'vitest'; |
25 | 37 | import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 38 | +import { referenceTargetOf } from '@objectstack/spec/data'; |
26 | 39 | import { ObjectQL } from './engine.js'; |
27 | 40 |
|
28 | 41 | const sysUserObject = { |
@@ -78,9 +91,11 @@ const sysMetadataHistoryObject = { |
78 | 91 | source: { name: 'source', label: 'Source', type: 'text' as const }, |
79 | 92 | organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, |
80 | 93 | // The real declaration, not a `text` stand-in — see the file header. |
| 94 | + // The target key is `reference`, the ONLY spelling `referenceTargetOf` |
| 95 | + // reads and the one `Field.lookup` emits; the pin below holds it there. |
81 | 96 | recorded_by: { |
82 | 97 | name: 'recorded_by', label: 'Recorded By', |
83 | | - type: 'lookup' as const, referenceTo: 'sys_user', readonly: true, |
| 98 | + type: 'lookup' as const, reference: 'sys_user', readonly: true, |
84 | 99 | }, |
85 | 100 | recorded_at: { name: 'recorded_at', label: 'At', type: 'datetime' as const, required: true }, |
86 | 101 | }, |
@@ -255,4 +270,35 @@ describe('#4556 — protocol write paths store NULL, not the sentinel string', ( |
255 | 270 | expect(v === null || users.has(v)).toBe(true); |
256 | 271 | } |
257 | 272 | }); |
| 273 | + |
| 274 | + it('[#14535] the declaration resolves to sys_user through the platform arbiter', () => { |
| 275 | + // The fidelity claim in the header, as an assertion instead of prose. |
| 276 | + // A raw object literal handed to the registry is never parsed by |
| 277 | + // `FieldSchema`, so the alias this fixture used to spell could never |
| 278 | + // be refused where it was written; `referenceTargetOf` is the reader |
| 279 | + // that decides whether the lookup has a target at all, and it is the |
| 280 | + // one the write-path guard resolves through. |
| 281 | + expect(referenceTargetOf(sysMetadataHistoryObject.fields.recorded_by)).toBe('sys_user'); |
| 282 | + }); |
| 283 | + |
| 284 | + it('[#14535] an actor id with no sys_user row is still admitted — the #4441 readonly exemption', async () => { |
| 285 | + // The second half of the file, now that the target resolves. This is |
| 286 | + // NOT the #4556 sentinel returning: `'system'` was a string the |
| 287 | + // PLATFORM minted for every actor-less write, which is what this suite |
| 288 | + // refuses above. An actor the caller named is the caller's own value, |
| 289 | + // and #4441 deliberately does not police a `readonly` lookup — the |
| 290 | + // value there was minted outside the check's stated scope, and the |
| 291 | + // residual is reported by the #4551 audit rather than refused here. |
| 292 | + // |
| 293 | + // Before the `reference` rename this passed for the wrong reason: the |
| 294 | + // field was target-less, so the guard skipped it whether or not the |
| 295 | + // exemption existed. Delete the exemption now and this goes red. |
| 296 | + await protocol.saveMetaItem({ |
| 297 | + type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('A'), actor: 'usr_not_a_row', |
| 298 | + }); |
| 299 | + |
| 300 | + const rows = await historyRows(); |
| 301 | + expect(rows).toHaveLength(1); |
| 302 | + expect(rows[0].recorded_by).toBe('usr_not_a_row'); |
| 303 | + }); |
258 | 304 | }); |
0 commit comments