|
14 | 14 | // |
15 | 15 | // Driven end-to-end through the real engine and a real (in-memory) driver, so |
16 | 16 | // "nothing is persisted" is read off the store rather than inferred from a |
17 | | -// throw (PD #10: check the CALL SITE, bulk path included). The two CONTROL |
18 | | -// blocks pin what must NOT move: a predicate that evaluates behaves exactly as |
19 | | -// before in both directions, and option `visibleWhen` — which D2 does not |
20 | | -// reach — stays fail-open. |
| 17 | +// throw (PD #10: check the CALL SITE, bulk path included). The CONTROL block |
| 18 | +// pins what must NOT move: a predicate that evaluates behaves exactly as |
| 19 | +// before in both directions. |
| 20 | +// |
| 21 | +// Block (d) is the option gate. #22402 (ruling A) settled that D2 reaches it on |
| 22 | +// the WRITE path — the gate is the server's enforcement of who may pick the |
| 23 | +// option (ADR-0124 D1) — so a faulting option `visibleWhen` refuses the write |
| 24 | +// through the same envelope, `constraint.rule: 'visibleWhen'`, with the option |
| 25 | +// as `value`. Only a system write with no acting user whose predicate reads |
| 26 | +// one stays admitted. |
21 | 27 |
|
22 | 28 | import { describe, it, expect, beforeEach } from 'vitest'; |
23 | 29 | import { validationFailureDetails } from '@objectstack/types'; |
@@ -195,9 +201,18 @@ describe('ADR-0137 D2 — a faulting field-rule predicate refuses the submit (se |
195 | 201 | engine.registry.registerObject({ |
196 | 202 | name: 'fp_option', |
197 | 203 | fields: { |
| 204 | + status: { type: 'text' }, |
198 | 205 | tier: { |
199 | 206 | type: 'select', |
200 | | - options: [{ value: 'basic' }, { value: 'gold', visibleWhen: "record.statsu == 'vip'" }], |
| 207 | + options: [ |
| 208 | + { value: 'basic' }, |
| 209 | + // The typo, on an option gate. |
| 210 | + { value: 'gold', visibleWhen: "record.statsu == 'vip'" }, |
| 211 | + // Evaluates: the control both ways. |
| 212 | + { value: 'silver', visibleWhen: "record.status == 'vip'" }, |
| 213 | + // Reads the acting user: a system write has nobody to ask about. |
| 214 | + { value: 'staff', visibleWhen: "'org_admin' in current_user.positions" }, |
| 215 | + ], |
201 | 216 | }, |
202 | 217 | }, |
203 | 218 | } as any, 'test-package'); |
@@ -320,12 +335,75 @@ describe('ADR-0137 D2 — a faulting field-rule predicate refuses the submit (se |
320 | 335 | }); |
321 | 336 | }); |
322 | 337 |
|
323 | | - // ── (d) CONTROL: option `visibleWhen` is outside D2 ─────────────────────── |
| 338 | + // ── (d) option `visibleWhen`: D2 reaches the gate on the write path ─────── |
| 339 | + |
| 340 | + describe('(d) an option `visibleWhen` whose predicate faults (#22402)', () => { |
| 341 | + const ACTING = { context: { userId: 'u1', positions: ['org_member'] } } as any; |
| 342 | + |
| 343 | + /** The option-gate refusal, asserted as a whole: the D2 envelope, naming the option, the field and the fault. */ |
| 344 | + function expectOptionGateRefusal(err: any, field: string, option: string) { |
| 345 | + expect(err.name).toBe('ValidationError'); |
| 346 | + expect(err.code).toBe('VALIDATION_FAILED'); |
| 347 | + expect(validationFailureDetails(err)).toMatchObject({ code: 'VALIDATION_FAILED' }); |
| 348 | + const entry = (err.fields as any[]).find((f) => f.field === field); |
| 349 | + expect(entry).toMatchObject({ |
| 350 | + field, |
| 351 | + code: 'rule_violation', |
| 352 | + value: option, |
| 353 | + constraint: expect.objectContaining({ rule: 'visibleWhen', reason: 'unevaluable', missingKey: 'statsu' }), |
| 354 | + }); |
| 355 | + expect(entry.message.startsWith(`Option '${option}' of field '${field}' visibleWhen could not be evaluated (runtime: No such key: statsu)`)) |
| 356 | + .toBe(true); |
| 357 | + return entry; |
| 358 | + } |
| 359 | + |
| 360 | + it('refuses an authenticated INSERT — and nothing is stored', async () => { |
| 361 | + const err = await refusalOf(() => engine.insert('fp_option', { tier: 'gold' }, ACTING)); |
| 362 | + expectOptionGateRefusal(err, 'tier', 'gold'); |
| 363 | + expect(rows('fp_option')).toHaveLength(0); |
| 364 | + }); |
| 365 | + |
| 366 | + it('refuses a system INSERT too: the predicate names no user, so the gate is broken, not unasked', async () => { |
| 367 | + const err = await refusalOf(() => engine.insert('fp_option', { tier: 'gold' })); |
| 368 | + expectOptionGateRefusal(err, 'tier', 'gold'); |
| 369 | + expect(rows('fp_option')).toHaveLength(0); |
| 370 | + }); |
| 371 | + |
| 372 | + it('refuses a by-id UPDATE — and the stored row is untouched', async () => { |
| 373 | + storeFor('fp_option').set('o1', { id: 'o1', status: 'open', tier: 'basic' }); |
| 374 | + const err = await refusalOf(() => engine.update('fp_option', { id: 'o1', tier: 'gold' }, ACTING)); |
| 375 | + expectOptionGateRefusal(err, 'tier', 'gold'); |
| 376 | + expect(storeFor('fp_option').get('o1')).toEqual({ id: 'o1', status: 'open', tier: 'basic' }); |
| 377 | + }); |
| 378 | + |
| 379 | + it('refuses on the BULK path, before any matched row is written', async () => { |
| 380 | + storeFor('fp_option').set('b1', { id: 'b1', status: 'open', tier: 'basic' }); |
| 381 | + storeFor('fp_option').set('b2', { id: 'b2', status: 'open', tier: 'basic' }); |
| 382 | + const err = await refusalOf(() => |
| 383 | + engine.update('fp_option', { tier: 'gold' }, { where: { status: 'open' }, multi: true, ...ACTING } as any)); |
| 384 | + expectOptionGateRefusal(err, 'tier', 'gold'); |
| 385 | + expect(rows('fp_option').map((r) => r.tier)).toEqual(['basic', 'basic']); |
| 386 | + }); |
| 387 | + |
| 388 | + it('validate() previews the same refusal', async () => { |
| 389 | + const preview = await engine.validate('fp_option', { tier: 'gold' }, { mode: 'insert', ...ACTING } as any); |
| 390 | + expect(preview.results?.[0]?.valid).toBe(false); |
| 391 | + expect(preview.results?.[0]?.errors).toEqual([ |
| 392 | + expect.objectContaining({ field: 'tier', code: 'rule_violation', value: 'gold' }), |
| 393 | + ]); |
| 394 | + }); |
| 395 | + |
| 396 | + it('CONTROL — an evaluable gate is judged exactly as before: FALSE refuses `invalid_option`, TRUE writes', async () => { |
| 397 | + const err = await refusalOf(() => engine.insert('fp_option', { status: 'open', tier: 'silver' }, ACTING)); |
| 398 | + expect(err.fields).toEqual([expect.objectContaining({ field: 'tier', code: 'invalid_option' })]); |
| 399 | + const row = await engine.insert('fp_option', { status: 'vip', tier: 'silver' }, ACTING); |
| 400 | + expect(row).toMatchObject({ tier: 'silver' }); |
| 401 | + expect(rows('fp_option')).toHaveLength(1); |
| 402 | + }); |
324 | 403 |
|
325 | | - describe('(d) CONTROL — option `visibleWhen` is not a field-rule predicate, and stays fail-open', () => { |
326 | | - it('a faulting option predicate still lets the choice through', async () => { |
327 | | - const row = await engine.insert('fp_option', { tier: 'gold' }); |
328 | | - expect(row).toMatchObject({ tier: 'gold' }); |
| 404 | + it('CONTROL — a system write of a user-gated option stays ADMITTED (`no-acting-user`)', async () => { |
| 405 | + const row = await engine.insert('fp_option', { tier: 'staff' }); |
| 406 | + expect(row).toMatchObject({ tier: 'staff' }); |
329 | 407 | expect(rows('fp_option')).toHaveLength(1); |
330 | 408 | }); |
331 | 409 | }); |
|
0 commit comments