|
30 | 30 | * The protocol-level half — the same verdict through the real `saveMetaItem` |
31 | 31 | * and `publishMetaItem` — is the #22032 block of |
32 | 32 | * `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`. |
| 33 | + * |
| 34 | + * ## #22042 — one level down |
| 35 | + * |
| 36 | + * A `conditional` rule's `then` / `otherwise` is a rule the evaluator runs, |
| 37 | + * yet only the null-guard gate reached its predicates: the same unregistered |
| 38 | + * function or bare field the top level refuses published clean one level |
| 39 | + * down, in `os build` and at this door alike. The pass now runs the same |
| 40 | + * `check()` on every nested predicate, at the location the null-guard gate |
| 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 |
| 44 | + * pins it; its protocol half is the #22042 block of the same protocol file. |
33 | 45 | */ |
34 | 46 | import { describe, expect, it } from 'vitest'; |
| 47 | +import { ObjectSchema } from '@objectstack/spec/data'; |
35 | 48 | import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js'; |
36 | 49 | import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js'; |
37 | 50 |
|
@@ -173,3 +186,148 @@ describe('#22032 pass 1 — the object door gives the build\'s validation-rule v |
173 | 186 | expect(expressionFindings(result.errors), dump(result)).toEqual([]); |
174 | 187 | }); |
175 | 188 | }); |
| 189 | + |
| 190 | +/** #22042 — the card's two bodies, one level down: an unregistered function in `then`, a bare field in `otherwise`. */ |
| 191 | +const NESTED_REFUSED = { |
| 192 | + type: 'conditional', |
| 193 | + name: 'outer', |
| 194 | + when: "record.status == 'open'", |
| 195 | + message: 'x', |
| 196 | + then: { type: 'script', name: 'inner', condition: 'sqrt(record.amount) > 1', message: 'y' }, |
| 197 | + otherwise: { type: 'script', name: 'other', condition: 'amont > 1', message: 'z' }, |
| 198 | +}; |
| 199 | +const THEN_WHERE = "object 'fx_rule' · validation rule 'outer' then → 'inner'"; |
| 200 | +const OTHERWISE_WHERE = "object 'fx_rule' · validation rule 'outer' otherwise → 'other'"; |
| 201 | + |
| 202 | +/** Two levels: a bare field in a nested `conditional`'s own `when`, an unregistered function one level below it. */ |
| 203 | +const TWO_LEVEL = { |
| 204 | + type: 'conditional', |
| 205 | + name: 'outer', |
| 206 | + when: "record.status == 'open'", |
| 207 | + message: 'x', |
| 208 | + then: { |
| 209 | + type: 'conditional', |
| 210 | + name: 'mid', |
| 211 | + when: 'amount > 1', |
| 212 | + message: 'y', |
| 213 | + then: { type: 'script', name: 'deep', condition: 'sqrt(record.amount) > 1', message: 'z' }, |
| 214 | + }, |
| 215 | +}; |
| 216 | + |
| 217 | +/** Valid, guarded predicates in both branches and two levels down. */ |
| 218 | +const NESTED_VALID = { |
| 219 | + type: 'conditional', |
| 220 | + name: 'outer', |
| 221 | + when: "record.status == 'open'", |
| 222 | + message: 'x', |
| 223 | + then: { |
| 224 | + type: 'conditional', |
| 225 | + name: 'mid', |
| 226 | + when: 'record.amount != null', |
| 227 | + message: 'y', |
| 228 | + // Guarded in its own source: the null-guard gate does not credit the enclosing `when`. |
| 229 | + then: { type: 'script', name: 'deep', condition: 'record.amount != null && record.amount > 100', message: 'z' }, |
| 230 | + }, |
| 231 | + otherwise: { type: 'script', name: 'other', condition: 'record.amount != null && record.amount < 0', message: 'w' }, |
| 232 | +}; |
| 233 | + |
| 234 | +/** A probe object with a reference field, for the per-slot traversal checks. */ |
| 235 | +const fxRef = (validations: unknown[]) => { |
| 236 | + const base = fxRule(validations); |
| 237 | + return { ...base, fields: { ...base.fields, account: { type: 'lookup', label: 'Account', reference: 'fx_rule' } } }; |
| 238 | +}; |
| 239 | +/** Reads more than one relationship hop — a shape `checkPredicate` refuses, and `checkConditional` never judges. */ |
| 240 | +const MULTI_HOP = 'record.account.owner.email != null'; |
| 241 | +const HYDRATION = { |
| 242 | + type: 'conditional', |
| 243 | + name: 'outer', |
| 244 | + when: "record.status == 'open'", |
| 245 | + message: 'x', |
| 246 | + then: { type: 'script', name: 'inner', condition: MULTI_HOP, message: 'y' }, |
| 247 | + otherwise: { |
| 248 | + type: 'conditional', |
| 249 | + name: 'mid', |
| 250 | + when: MULTI_HOP, |
| 251 | + message: 'z', |
| 252 | + then: { type: 'script', name: 'deep', condition: 'record.amount != null', message: 'w' }, |
| 253 | + }, |
| 254 | +}; |
| 255 | + |
| 256 | +describe('#22042 — a `conditional` rule\'s nested predicates meet the same verdict, at the build and at the door', () => { |
| 257 | + it('the fixtures are spec-valid: each refusal below is the expression verdict, not the schema\'s', () => { |
| 258 | + for (const body of [fxRule([NESTED_REFUSED]), fxRule([TWO_LEVEL]), fxRule([NESTED_VALID]), fxRef([HYDRATION])]) { |
| 259 | + const parsed = ObjectSchema.safeParse(body); |
| 260 | + expect(parsed.success, dump(parsed.error?.issues)).toBe(true); |
| 261 | + } |
| 262 | + }); |
| 263 | + |
| 264 | + it('⭐ LIT — an unregistered function in `then` and a bare field in `otherwise` are REFUSED by `os build`, located at the nested rule', () => { |
| 265 | + const atBuild = buildFindings(fxRule([NESTED_REFUSED])); |
| 266 | + |
| 267 | + expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual([THEN_WHERE, OTHERWISE_WHERE]); |
| 268 | + for (const f of atBuild) expect(f).toMatchObject({ severity: 'error', path: f.where }); |
| 269 | + expect(atBuild[0]!.message).toContain('`sqrt` is not a callable name here'); |
| 270 | + expect(atBuild[1]!.message).toContain('bare reference `amont`'); |
| 271 | + }); |
| 272 | + |
| 273 | + it('⭐ LIT — the object door REFUSES the same body, and its findings ARE the build\'s', () => { |
| 274 | + const result = gateObject(fxRule([NESTED_REFUSED])); |
| 275 | + |
| 276 | + expect(result.rulesRun).toContain('validateStackExpressions'); |
| 277 | + const atDoor = expressionFindings(result.errors); |
| 278 | + expect(atDoor.map((f) => f.where), dump(result)).toEqual([THEN_WHERE, OTHERWISE_WHERE]); |
| 279 | + expect(atDoor).toEqual(buildFindings(fxRule([NESTED_REFUSED]))); |
| 280 | + }); |
| 281 | + |
| 282 | + it('⭐ LIT — two levels down: a nested `conditional`\'s `when` and the rule below it are judged', () => { |
| 283 | + const atBuild = buildFindings(fxRule([TWO_LEVEL])); |
| 284 | + |
| 285 | + expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual([ |
| 286 | + "object 'fx_rule' · validation rule 'outer' then → 'mid' when-predicate", |
| 287 | + "object 'fx_rule' · validation rule 'outer' then → 'mid' then → 'deep'", |
| 288 | + ]); |
| 289 | + expect(atBuild[0]!.message).toContain('bare reference `amount`'); |
| 290 | + expect(atBuild[1]!.message).toContain('`sqrt` is not a callable name here'); |
| 291 | + expect(expressionFindings(gateObject(fxRule([TWO_LEVEL])).errors)).toEqual(atBuild); |
| 292 | + }); |
| 293 | + |
| 294 | + it('⭐ CONTROL — valid nested predicates publish clean, two levels down and in `otherwise`', () => { |
| 295 | + const result = gateObject(fxRule([NESTED_VALID])); |
| 296 | + |
| 297 | + expect(expressionFindings(result.errors), dump(result)).toEqual([]); |
| 298 | + expect(expressionFindings(result.advisories), dump(result)).toEqual([]); |
| 299 | + expect(buildFindings(fxRule([NESTED_VALID]))).toEqual([]); |
| 300 | + }); |
| 301 | + |
| 302 | + it('each predicate is judged ONCE: the rule\'s own `condition` / `when` keep their location and are not re-judged as nested', () => { |
| 303 | + // A top-level `condition` — one finding, at the rule's own location only. |
| 304 | + const top = buildFindings(fxRule([UNREGISTERED])); |
| 305 | + expect(top.map((f) => f.where), dump(top)).toEqual(["object 'fx_rule' · validation 'amount_root'"]); |
| 306 | + // A faulting top-level `when` beside a faulting nested `then` — one finding each. |
| 307 | + const both = buildFindings(fxRule([{ ...WHEN, then: NESTED_REFUSED.then }])); |
| 308 | + expect(both.map((f) => f.where), dump(both)).toEqual([ |
| 309 | + "object 'fx_rule' · validation 'gate' when", |
| 310 | + "object 'fx_rule' · validation rule 'gate' then → 'inner'", |
| 311 | + ]); |
| 312 | + }); |
| 313 | + |
| 314 | + it('the traversal checks follow the evaluator per slot: ON for a nested `condition`, OFF for a nested `when`', () => { |
| 315 | + const atBuild = buildFindings(fxRef([HYDRATION])); |
| 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. |
| 322 | + const topWhen = buildFindings(fxRef([{ ...HYDRATION, when: MULTI_HOP, then: NESTED_VALID.otherwise, otherwise: undefined }])); |
| 323 | + expect(topWhen, dump(topWhen)).toEqual([]); |
| 324 | + expect(expressionFindings(gateObject(fxRef([HYDRATION])).errors)).toEqual(atBuild); |
| 325 | + }); |
| 326 | + |
| 327 | + it('a stored sibling\'s nested fault is not this write\'s to answer for (the differential)', () => { |
| 328 | + const sibling = { ...fxRule([NESTED_REFUSED, TWO_LEVEL]), name: 'fx_sibling' }; |
| 329 | + const result = runRuntimeAuthoringRules({ type: 'object', item: fxRule([NESTED_VALID]), context: { objects: [sibling] } }); |
| 330 | + |
| 331 | + expect(expressionFindings(result.errors), dump(result)).toEqual([]); |
| 332 | + }); |
| 333 | +}); |
0 commit comments