|
39 | 39 | * down, in `os build` and at this door alike. The pass now runs the same |
40 | 40 | * `check()` on every nested predicate, at the location the null-guard gate |
41 | 41 | * already gives it (`validation rule 'outer' then → 'inner'`), with the |
42 | | - * relationship-traversal checks on a nested `condition` (ObjectQL hydrates it) |
43 | | - * and not on a nested `when` (it does not). The second describe block below |
| 42 | + * relationship-traversal checks on a nested `condition` and — since #24512 — |
| 43 | + * on a nested `when` (ObjectQL hydrates both). The second describe block below |
44 | 44 | * pins it; its protocol half is the #22042 block of the same protocol file. |
45 | 45 | */ |
46 | 46 | import { describe, expect, it } from 'vitest'; |
@@ -236,7 +236,7 @@ const fxRef = (validations: unknown[]) => { |
236 | 236 | const base = fxRule(validations); |
237 | 237 | return { ...base, fields: { ...base.fields, account: { type: 'lookup', label: 'Account', reference: 'fx_rule' } } }; |
238 | 238 | }; |
239 | | -/** Reads more than one relationship hop — a shape `checkPredicate` refuses, and `checkConditional` never judges. */ |
| 239 | +/** Reads more than one relationship hop — a shape `checkPredicate` and (#24512) `checkConditional` refuse. */ |
240 | 240 | const MULTI_HOP = 'record.account.owner.email != null'; |
241 | 241 | const HYDRATION = { |
242 | 242 | type: 'conditional', |
@@ -311,16 +311,21 @@ describe('#22042 — a `conditional` rule\'s nested predicates meet the same ver |
311 | 311 | ]); |
312 | 312 | }); |
313 | 313 |
|
314 | | - it('the traversal checks follow the evaluator per slot: ON for a nested `condition`, OFF for a nested `when`', () => { |
| 314 | + it('the traversal checks follow the evaluator per slot: ON for a nested `condition` and, since #24512, a nested `when`', () => { |
315 | 315 | const atBuild = buildFindings(fxRef([HYDRATION])); |
316 | 316 |
|
317 | | - // `checkPredicate` refuses a read deeper than one hop at any depth, so the build says so… |
318 | | - expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual(["object 'fx_rule' · validation rule 'outer' then → 'inner'"]); |
319 | | - expect(atBuild[0]!.message).toContain('ONE hop'); |
320 | | - // …and `checkConditional` never hydrates a `when`, so the same source there earns no |
321 | | - // traversal prescription — exactly as the top-level `when` site is opted out. |
| 317 | + // `checkPredicate` refuses a read deeper than one hop at any depth, and |
| 318 | + // `checkConditional` evaluates a `when` on the same hydrated scope, so the |
| 319 | + // build says so for both slots… |
| 320 | + expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual([ |
| 321 | + "object 'fx_rule' · validation rule 'outer' then → 'inner'", |
| 322 | + "object 'fx_rule' · validation rule 'outer' otherwise → 'mid' when-predicate", |
| 323 | + ]); |
| 324 | + for (const f of atBuild) expect(f.message).toContain('ONE hop'); |
| 325 | + // …and at the top-level `when` site too. |
322 | 326 | const topWhen = buildFindings(fxRef([{ ...HYDRATION, when: MULTI_HOP, then: NESTED_VALID.otherwise, otherwise: undefined }])); |
323 | | - expect(topWhen, dump(topWhen)).toEqual([]); |
| 327 | + expect(topWhen.map((f) => f.where), dump(topWhen)).toEqual(["object 'fx_rule' · validation 'outer' when"]); |
| 328 | + expect(topWhen[0]!.message).toContain('ONE hop'); |
324 | 329 | expect(expressionFindings(gateObject(fxRef([HYDRATION])).errors)).toEqual(atBuild); |
325 | 330 | }); |
326 | 331 |
|
@@ -382,3 +387,68 @@ describe('#24468 — the object door refuses a rule hop to a field the target do |
382 | 387 | expect(expressionFindings(result.advisories), dump(result)).toEqual([]); |
383 | 388 | }); |
384 | 389 | }); |
| 390 | + |
| 391 | +describe('#24512 — the object door judges a `conditional` rule\'s `when` hop as it judges a `condition`\'s', () => { |
| 392 | + /** The lookup's target, delivered by ANOTHER package the written one depends on. */ |
| 393 | + const account = { |
| 394 | + name: 'fx_account', label: 'Account', sharingModel: 'private', _packageId: 'com.example.accounts', |
| 395 | + fields: { status: { type: 'text', label: 'Status' } }, |
| 396 | + }; |
| 397 | + const order = (validations: unknown[]) => ({ |
| 398 | + name: 'fx_order', |
| 399 | + label: 'Order', |
| 400 | + sharingModel: 'private', |
| 401 | + fields: { |
| 402 | + amount: { type: 'number', label: 'Amount' }, |
| 403 | + account: { type: 'lookup', label: 'Account', reference: 'fx_account' }, |
| 404 | + }, |
| 405 | + validations, |
| 406 | + }); |
| 407 | + const gate = (when: string, name = 'gate') => ({ |
| 408 | + type: 'conditional', name, when, message: 'x', |
| 409 | + then: { type: 'script', name: 'cap', condition: 'record.amount != null && record.amount > 100', message: 'y' }, |
| 410 | + }); |
| 411 | + const gateOrder = (validations: unknown[]) => runRuntimeAuthoringRules({ |
| 412 | + type: 'object', |
| 413 | + item: order(validations), |
| 414 | + context: { objects: [account] }, |
| 415 | + packageScope: { packageId: 'com.example.orders', dependencies: ['com.example.accounts'] }, |
| 416 | + }); |
| 417 | + |
| 418 | + it('the fixtures are spec-valid: each refusal below is the expression verdict, not the schema\'s', () => { |
| 419 | + for (const when of ["record.account.region == 'north'", 'record.account.owner.email != null', "record.account.status == 'open'"]) { |
| 420 | + const parsed = ObjectSchema.safeParse(order([gate(when), { type: 'conditional', name: 'outer', when: 'record.amount != null', message: 'x', then: gate(when, 'inner') }])); |
| 421 | + expect(parsed.success, dump(parsed.error?.issues)).toBe(true); |
| 422 | + } |
| 423 | + }); |
| 424 | + |
| 425 | + it('⭐ LIT — a `when` reading a field the target does not declare is REFUSED under EXPRESSION_INVALID, naming the slot, the field and the target', () => { |
| 426 | + const result = gateOrder([gate("record.account.region == 'north'")]); |
| 427 | + |
| 428 | + const errs = expressionFindings(result.errors); |
| 429 | + expect(errs, dump(result)).toHaveLength(1); |
| 430 | + const where = "object 'fx_order' · validation 'gate' when"; |
| 431 | + expect(errs[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error', where, path: where }); |
| 432 | + expect(errs[0]!.message).toContain('unknown field `region` on `fx_account`'); |
| 433 | + expect(errs[0]!.message).toContain('the rule has no verdict'); |
| 434 | + }); |
| 435 | + |
| 436 | + it('⭐ LIT — a `when` reading a second hop is REFUSED, and a nested `when` is judged too', () => { |
| 437 | + const result = gateOrder([{ |
| 438 | + type: 'conditional', name: 'outer', when: 'record.amount != null', message: 'x', |
| 439 | + then: gate('record.account.owner.email != null', 'inner'), |
| 440 | + }]); |
| 441 | + |
| 442 | + const errs = expressionFindings(result.errors); |
| 443 | + expect(errs.map((e) => e.where), dump(result)).toEqual(["object 'fx_order' · validation rule 'outer' then → 'inner' when-predicate"]); |
| 444 | + expect(errs[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error' }); |
| 445 | + expect(errs[0]!.message).toContain('ONE hop'); |
| 446 | + }); |
| 447 | + |
| 448 | + it('⭐ CONTROL — a `when` reading a field the target declares publishes clean', () => { |
| 449 | + const result = gateOrder([gate("record.account.status == 'open'")]); |
| 450 | + |
| 451 | + expect(expressionFindings(result.errors), dump(result)).toEqual([]); |
| 452 | + expect(expressionFindings(result.advisories), dump(result)).toEqual([]); |
| 453 | + }); |
| 454 | +}); |
0 commit comments