|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #22565 — the build door refuses a flow CEL root the flow does not bind. |
| 5 | + * |
| 6 | + * Measured on the flow scope the engine builds (`AutomationEngine.celScope` |
| 7 | + * over `seedRunVariables`' map): `user.id == "u1"`, `ctx.user.id == "u1"`, |
| 8 | + * `os.user.id == "u1"` and `foo.bar == 1` each fail the run with |
| 9 | + * `Unknown variable: <root>`, while `validateStackExpressions` answered 0 issues |
| 10 | + * for every one of them. The three user spellings are the ones formulas, RLS and |
| 11 | + * the client bind; flow CEL binds the run's user as `current_user` only. |
| 12 | + * |
| 13 | + * The judge lives in `flow-cel-root-scope.ts`; these pins drive it through the |
| 14 | + * door `objectstack validate` and the runtime publish gate both call. |
| 15 | + */ |
| 16 | + |
| 17 | +import { describe, expect, it } from 'vitest'; |
| 18 | + |
| 19 | +import { flowCelRootScope, unboundFlowCelRoots } from './flow-cel-root-scope.js'; |
| 20 | +import { validateStackExpressions } from './validate-expressions.js'; |
| 21 | + |
| 22 | +type AnyRec = Record<string, unknown>; |
| 23 | + |
| 24 | +const OBJECTS = [{ name: 'acct', fields: { status: { type: 'text' }, amount: { type: 'number' } } }]; |
| 25 | + |
| 26 | +/** start → (edge carrying `edgeCondition`) → done, plus any `middle` nodes, on an optional trigger object. */ |
| 27 | +function flowStack(opts: { |
| 28 | + edgeCondition?: unknown; |
| 29 | + startCondition?: unknown; |
| 30 | + variables?: AnyRec[]; |
| 31 | + middle?: AnyRec[]; |
| 32 | + objectName?: string; |
| 33 | + objects?: AnyRec[]; |
| 34 | +}): AnyRec { |
| 35 | + return { |
| 36 | + objects: opts.objects ?? OBJECTS, |
| 37 | + flows: [{ |
| 38 | + name: 'f', |
| 39 | + label: 'F', |
| 40 | + type: 'autolaunched', |
| 41 | + ...(opts.variables ? { variables: opts.variables } : {}), |
| 42 | + nodes: [ |
| 43 | + { |
| 44 | + id: 'start', |
| 45 | + type: 'start', |
| 46 | + config: { |
| 47 | + ...(opts.objectName ? { objectName: opts.objectName } : {}), |
| 48 | + ...(opts.startCondition !== undefined ? { condition: opts.startCondition } : {}), |
| 49 | + }, |
| 50 | + }, |
| 51 | + ...(opts.middle ?? []), |
| 52 | + { id: 'done', type: 'end', config: {} }, |
| 53 | + ], |
| 54 | + edges: [ |
| 55 | + { id: 'e1', source: 'start', target: 'done', ...(opts.edgeCondition !== undefined ? { condition: opts.edgeCondition } : {}) }, |
| 56 | + ], |
| 57 | + }], |
| 58 | + }; |
| 59 | +} |
| 60 | + |
| 61 | +const errorsOf = (stack: AnyRec) => validateStackExpressions(stack).filter((i) => (i.severity ?? 'error') === 'error'); |
| 62 | +/** |
| 63 | + * The judge's own findings — every refusal it writes says the root "is not bound |
| 64 | + * in" a flow scope. Controls and stand-downs read these, so a node-config refusal |
| 65 | + * about an unrelated key can neither mask nor fake a verdict here. |
| 66 | + */ |
| 67 | +const rootFindings = (stack: AnyRec) => errorsOf(stack).filter((i) => / is not bound in (a flow expression|this flow's expression scope)/.test(i.message)); |
| 68 | +const CURRENT_USER_REMEDY = 'a flow binds the run\'s user as `current_user` only'; |
| 69 | + |
| 70 | +describe('the triage pins (#22565)', () => { |
| 71 | + it('an edge condition `user.id == "u1"` is refused with the `current_user` remedy', () => { |
| 72 | + const found = errorsOf(flowStack({ edgeCondition: 'user.id == "u1"' })); |
| 73 | + expect(found).toHaveLength(1); |
| 74 | + expect(found[0]!.where).toBe("flow 'f' · edge 'e1' (start→done) condition"); |
| 75 | + expect(found[0]!.source).toBe('user.id == "u1"'); |
| 76 | + expect(found[0]!.message).toMatch(/^`user` is not bound in a flow expression, so the run fails with `Unknown variable: user`/); |
| 77 | + expect(found[0]!.message).toContain(CURRENT_USER_REMEDY); |
| 78 | + expect(found[0]!.message).toContain('(`user.id` → `current_user.id`)'); |
| 79 | + }); |
| 80 | + |
| 81 | + it('control: the same condition on a flow that declares a variable `user` passes', () => { |
| 82 | + expect(validateStackExpressions(flowStack({ |
| 83 | + edgeCondition: 'user.id == "u1"', |
| 84 | + variables: [{ name: 'user', type: 'object' }], |
| 85 | + }))).toEqual([]); |
| 86 | + }); |
| 87 | + |
| 88 | + it('control: the remedy itself — `current_user.id == "u1"` — passes', () => { |
| 89 | + expect(validateStackExpressions(flowStack({ edgeCondition: 'current_user.id == "u1"' }))).toEqual([]); |
| 90 | + }); |
| 91 | +}); |
| 92 | + |
| 93 | +describe('every run-user spelling formulas, RLS and the client accept', () => { |
| 94 | + it.each([ |
| 95 | + ['ctx.user.id == "u1"', 'ctx.user', 'ctx'], |
| 96 | + ['os.user.id == "u1"', 'os.user', 'os'], |
| 97 | + ['has(user.id)', 'user', 'user'], |
| 98 | + ['user.?id.orValue("") == "u1"', 'user', 'user'], |
| 99 | + ['"admin" in user.positions', 'user', 'user'], |
| 100 | + ])('`%s` is refused naming `current_user`', (source, spelling, root) => { |
| 101 | + const found = errorsOf(flowStack({ edgeCondition: source })); |
| 102 | + expect(found).toHaveLength(1); |
| 103 | + expect(found[0]!.message.startsWith(`\`${spelling}\` is not bound in a flow expression, so the run fails with \`Unknown variable: ${root}\``)).toBe(true); |
| 104 | + expect(found[0]!.message).toContain(CURRENT_USER_REMEDY); |
| 105 | + expect(found[0]!.message).toContain(`(\`${spelling}.id\` → \`current_user.id\`)`); |
| 106 | + }); |
| 107 | + |
| 108 | + it('`ctx` / `os` read for something other than the user get the general refusal, naming the root', () => { |
| 109 | + const found = errorsOf(flowStack({ edgeCondition: 'os.env == "prod"' })); |
| 110 | + expect(found).toHaveLength(1); |
| 111 | + expect(found[0]!.message).toMatch(/^`os` is not bound in this flow's expression scope/); |
| 112 | + expect(found[0]!.message).not.toContain('current_user` in its place'); |
| 113 | + }); |
| 114 | +}); |
| 115 | + |
| 116 | +describe('every flow CEL site the door visits', () => { |
| 117 | + const USER = 'user.id == "u1"'; |
| 118 | + const env = (source: string) => ({ dialect: 'cel', source }); |
| 119 | + |
| 120 | + it.each([ |
| 121 | + ['the start node\'s trigger gate', flowStack({ startCondition: USER }), "flow 'f' · node 'start' (start) condition"], |
| 122 | + ['an edge', flowStack({ edgeCondition: USER }), "flow 'f' · edge 'e1' (start→done) condition"], |
| 123 | + ['another node\'s `config.condition`', flowStack({ middle: [{ id: 'gate', type: 'decision', config: { condition: USER } }] }), |
| 124 | + "flow 'f' · node 'gate' (decision) condition"], |
| 125 | + ['a decision branch', flowStack({ middle: [{ id: 'pick', type: 'decision', config: { conditions: [{ label: 'y', expression: USER }] } }] }), |
| 126 | + "flow 'f' · node 'pick' (decision) decision branch expression at config.conditions[0].expression"], |
| 127 | + ['a screen field `visibleWhen`', flowStack({ middle: [{ id: 'ask', type: 'screen', config: { fields: [{ name: 'note', type: 'text', visibleWhen: USER }] } }] }), |
| 128 | + "flow 'f' · node 'ask' (screen) screen field visibleWhen at config.fields[0].visibleWhen"], |
| 129 | + ['an assignment value envelope', flowStack({ middle: [{ id: 'set', type: 'assignment', config: { assignments: { who: env('user.id') } } }] }), |
| 130 | + "flow 'f' · node 'set' (assignment) assignment value at config.assignments.who"], |
| 131 | + ['a create_record field envelope', flowStack({ middle: [{ id: 'mk', type: 'create_record', config: { objectName: 'acct', fields: { status: env('user.id') } } }] }), |
| 132 | + "flow 'f' · node 'mk' (create_record) create_record field value at config.fields.status"], |
| 133 | + ['an update_record field envelope', flowStack({ middle: [{ id: 'up', type: 'update_record', config: { objectName: 'acct', filter: { id: '{record.id}' }, fields: { status: env('user.id') } } }] }), |
| 134 | + "flow 'f' · node 'up' (update_record) update_record field value at config.fields.status"], |
| 135 | + ])('%s', (_site, stack, where) => { |
| 136 | + const found = rootFindings(stack); |
| 137 | + expect(found.map((i) => i.where)).toEqual([where]); |
| 138 | + expect(found[0]!.message).toContain(CURRENT_USER_REMEDY); |
| 139 | + }); |
| 140 | + |
| 141 | + it('an expression inside an ADR-0031 region body is judged against the same flow-wide set', () => { |
| 142 | + const found = rootFindings(flowStack({ |
| 143 | + middle: [{ |
| 144 | + id: 'sweep', type: 'loop', |
| 145 | + config: { collection: '{rows}', iteratorVariable: 'row', body: { nodes: [{ id: 'inner', type: 'decision', config: { conditions: [{ label: 'y', expression: 'user.id == row.owner' }] } }], edges: [] } }, |
| 146 | + }], |
| 147 | + variables: [{ name: 'rows', type: 'list' }], |
| 148 | + })); |
| 149 | + expect(found).toHaveLength(1); |
| 150 | + expect(found[0]!.where).toContain("loop 'sweep' body"); |
| 151 | + expect(found[0]!.message).toContain(CURRENT_USER_REMEDY); |
| 152 | + }); |
| 153 | + |
| 154 | + it('a value slot\'s plain string is a literal, so its text is not read for roots', () => { |
| 155 | + expect(rootFindings(flowStack({ middle: [{ id: 'set', type: 'assignment', config: { assignments: { who: 'user.id' } } }] }))).toEqual([]); |
| 156 | + }); |
| 157 | +}); |
| 158 | + |
| 159 | +describe('controls — one per binding kind the run has', () => { |
| 160 | + it.each<[string, Parameters<typeof flowStack>[0]]>([ |
| 161 | + ['a declared variable', { edgeCondition: 'quota > 1', variables: [{ name: 'quota', type: 'number' }] }], |
| 162 | + ['an `outputVariable`', { edgeCondition: 'acc.status == "x"', middle: [{ id: 'read', type: 'get_record', config: { objectName: 'acct', filter: { id: '{record.id}' }, outputVariable: 'acc' } }] }], |
| 163 | + ['a node id (its outputs land under it)', { edgeCondition: 'read.record != null', middle: [{ id: 'read', type: 'get_record', config: { objectName: 'acct', filter: { id: '{record.id}' } } }] }], |
| 164 | + ['an `assignment` target', { edgeCondition: 'flag == true', middle: [{ id: 'set', type: 'assignment', config: { assignments: { flag: true } } }] }], |
| 165 | + ['a `loop` iterator and index', { edgeCondition: 'row != null && i >= 0', variables: [{ name: 'rows', type: 'list' }], middle: [{ id: 'sweep', type: 'loop', config: { collection: '{rows}', iteratorVariable: 'row', indexVariable: 'i', body: { nodes: [], edges: [] } } }] }], |
| 166 | + ['a `loop` with no iterator binds the default `item`', { edgeCondition: 'item != null', variables: [{ name: 'rows', type: 'list' }], middle: [{ id: 'sweep', type: 'loop', config: { collection: '{rows}', body: { nodes: [], edges: [] } } }] }], |
| 167 | + ['a `try_catch` `errorVariable`', { edgeCondition: 'caught != null', middle: [{ id: 'guard', type: 'try_catch', config: { errorVariable: 'caught', try: { nodes: [], edges: [] }, catch: { nodes: [], edges: [] } } }] }], |
| 168 | + ['a screen field (a resume binds it under its name)', { edgeCondition: 'approve == true', middle: [{ id: 'ask', type: 'screen', config: { fields: [{ name: 'approve', type: 'boolean' }] } }] }], |
| 169 | + ['a trigger field, read bare', { edgeCondition: 'status == "open" && amount > 5', objectName: 'acct' }], |
| 170 | + ['a field of another object the stack declares (an action, a subflow parent or a map item can hand its record in)', { edgeCondition: 'region == "eu"', objectName: 'acct', objects: [...OBJECTS, { name: 'branch', fields: { region: { type: 'text' } } }] }], |
| 171 | + ['a registry-injected column', { edgeCondition: 'owner_id != null', objectName: 'acct' }], |
| 172 | + ['the engine roots', { edgeCondition: 'record != null && previous == null && vars.x == null && current_user != null' }], |
| 173 | + ['a `$` variable through `vars` (a `$` name has no CEL spelling)', { edgeCondition: 'vars["$record"] != null' }], |
| 174 | + ['a comprehension macro variable', { edgeCondition: '[1, 2].exists(user, user > 1) && [1].map(x, x > 0, x + 1).size() == 1' }], |
| 175 | + ['a `cel.bind` variable', { edgeCondition: 'cel.bind(user, 2, user > 1)' }], |
| 176 | + ['a CEL type name', { edgeCondition: 'type(record) == map && int("1") == 1' }], |
| 177 | + ])('%s', (_kind, opts) => { |
| 178 | + expect(rootFindings(flowStack(opts))).toEqual([]); |
| 179 | + }); |
| 180 | + |
| 181 | + it('a binding declared by a node AFTER the reading edge still counts — one variable map serves the whole run', () => { |
| 182 | + expect(rootFindings(flowStack({ |
| 183 | + edgeCondition: 'later == 1', |
| 184 | + middle: [{ id: 'tail', type: 'assignment', config: { assignments: { later: 1 } } }], |
| 185 | + }))).toEqual([]); |
| 186 | + }); |
| 187 | + |
| 188 | + it('a `cel.bind` variable is bound in its body only — the initializer reads the flow\'s scope', () => { |
| 189 | + const found = rootFindings(flowStack({ edgeCondition: 'cel.bind(v, v, v > 1)' })); |
| 190 | + expect(found).toHaveLength(1); |
| 191 | + expect(found[0]!.message).toMatch(/^`v` is not bound in this flow's expression scope/); |
| 192 | + }); |
| 193 | +}); |
| 194 | + |
| 195 | +describe('the door stands down where the run can bind a name it cannot see', () => { |
| 196 | + it.each<[string, AnyRec]>([ |
| 197 | + ['a `script` (its function gets the live variable map)', { id: 'fn', type: 'script', config: { function: 'enrich' } }], |
| 198 | + ['a `connector_action` (its handler gets the live variable map)', { id: 'push', type: 'connector_action', config: { connectorId: 'slack', actionId: 'post' } }], |
| 199 | + ['a `wait` (a resume folds the caller\'s bag)', { id: 'hold', type: 'wait', config: { eventType: 'signal', signalName: 'go' } }], |
| 200 | + ['a `subflow` (a resume folds the caller\'s bag)', { id: 'child', type: 'subflow', config: { flowName: 'other' } }], |
| 201 | + ['a `map` (a resume folds the caller\'s bag)', { id: 'each', type: 'map', config: { collection: '{rows}', flowName: 'other' } }], |
| 202 | + ['a plugin node type', { id: 'ai', type: 'ai_classify', config: {} }], |
| 203 | + ['a screen with no fields', { id: 'ok', type: 'screen', config: { waitForInput: true } }], |
| 204 | + ['an object-form screen', { id: 'form', type: 'screen', config: { kind: 'object-form', objectName: 'acct', idVariable: 'saved' } }], |
| 205 | + ])('%s', (_why, node) => { |
| 206 | + expect(rootFindings(flowStack({ edgeCondition: 'user.id == "u1" && foo == 1', middle: [node] }))).toEqual([]); |
| 207 | + }); |
| 208 | + |
| 209 | + it('a trigger object the stack does not declare', () => { |
| 210 | + expect(rootFindings(flowStack({ edgeCondition: 'user.id == "u1"', objectName: 'sys_user' }))).toEqual([]); |
| 211 | + }); |
| 212 | + |
| 213 | + it('RED CONTROL — the same condition on the same flow with no opening node is refused, twice', () => { |
| 214 | + expect(rootFindings(flowStack({ edgeCondition: 'user.id == "u1" && foo == 1' })).map((i) => i.message.slice(0, 6))) |
| 215 | + .toEqual(['`user`', '`foo` ']); |
| 216 | + }); |
| 217 | + |
| 218 | + it('the scope says why', () => { |
| 219 | + const scope = flowCelRootScope({ nodes: [] }, [{ nodes: [{ id: 'fn', type: 'script', config: {} }] }], undefined, new Map()); |
| 220 | + expect(scope.provable).toBe(false); |
| 221 | + expect(scope.openedBy).toContain("node 'fn' (script)"); |
| 222 | + expect(unboundFlowCelRoots('user.id == "u1"', scope)).toEqual([]); |
| 223 | + }); |
| 224 | +}); |
| 225 | + |
| 226 | +describe('the wider family — any root nothing binds (rides the same judge)', () => { |
| 227 | + it('`foo.bar == 1` is refused naming the root and what could have bound it', () => { |
| 228 | + const found = errorsOf(flowStack({ edgeCondition: 'foo.bar == 1' })); |
| 229 | + expect(found).toHaveLength(1); |
| 230 | + expect(found[0]!.message).toBe( |
| 231 | + '`foo` is not bound in this flow\'s expression scope, so the run fails with `Unknown variable: foo`: no variable, ' |
| 232 | + + '`outputVariable`, iterator, index or error variable, `assignment` target, node id or screen field of this flow is ' |
| 233 | + + 'named `foo`, no object in the stack declares a field `foo`, and the engine binds only `record`, `previous`, ' |
| 234 | + + '`vars` and `current_user`. Declare `foo` as a flow variable or bind it with a node, or correct the name.', |
| 235 | + ); |
| 236 | + }); |
| 237 | + |
| 238 | + it('each unbound root is its own finding; a bound one beside it is not', () => { |
| 239 | + const found = errorsOf(flowStack({ edgeCondition: 'alpha == 1 && status == "x" && beta == 2', objectName: 'acct' })); |
| 240 | + expect(found.map((i) => i.message.slice(0, 7))).toEqual(['`alpha`', '`beta` ']); |
| 241 | + }); |
| 242 | + |
| 243 | + it('a near-miss of a trigger field is ONE finding: the refusal carries the did-you-mean, the advisory is withdrawn', () => { |
| 244 | + const issues = validateStackExpressions(flowStack({ edgeCondition: 'stauts == "open"', objectName: 'acct' })); |
| 245 | + expect(issues).toHaveLength(1); |
| 246 | + expect(issues[0]!.severity).toBe('error'); |
| 247 | + expect(issues[0]!.message).toContain('did you mean `status`?'); |
| 248 | + }); |
| 249 | + |
| 250 | + it('a source that does not parse is the syntax check\'s alone — one broken expression, one finding', () => { |
| 251 | + const issues = validateStackExpressions(flowStack({ edgeCondition: 'user.id ==' })); |
| 252 | + expect(issues).toHaveLength(1); |
| 253 | + expect(issues[0]!.message).not.toContain('is not bound'); |
| 254 | + }); |
| 255 | +}); |
0 commit comments