Skip to content

Commit 534ca13

Browse files
test(service-automation): pin the flow runAs and filter-date semantics hotcrm pins (two #15953 rows) (#23728)
Fixes #23604 Clause-②: no ## What this carries The two `domain:services` rows split out of #15953's classification table (comment `6115219467`, rows as of slice 6's landing `6144431383`). Tests only, in `packages/services/service-automation/src/builtin/`. No production file changes. Each row is carried by the existing test file that already pinned part of it (ruling: the missing cases go in that file, not in a second file), and each file's header now names the row, #15953 and the hotcrm file it lets hotcrm retire (objectstack-ai/hotcrm#1583). | row | hotcrm file it lets retire | carried by | |:--|:--|:--| | 8 | `test/readonly-write-semantics.test.ts` (its flow-writer rows: flow node, screen flow, subflow) | `crud-runas.test.ts` | | 11 | `test/flow-filter-today-token.test.ts` | `crud-filter-guard.test.ts` | ### Row 8: a flow's `runAs` decides `isSystem` for its writes, per flow writer kind - **Flow node: already pinned exactly.** `crud-runas.test.ts`'s first describe (`flow.runAs identity enforcement at the data layer (#1888)`) asserts that every data op of a `runAs:'system'`, `'user'` and default flow receives `isSystem` true, false and false. No new case. The header line names the hotcrm file. - **Screen flow: added** (3 cases, one `it.each`). The screen parks the run, and nothing is written before it is answered. The `update_record` on the RESUMED leg receives `isSystem` from the flow's `runAs`: `'system'` gives true, `'user'` and omitted give false. The triggering user and org ride that write. The resume call carries no identity, so the write's context is the one the suspension persisted. - **Subflow: added** (2 cases). The child is its own run, so the CHILD flow's `runAs` decides its writes, whichever way the two flows differ: - a `'system'` child under a default parent writes `isSystem: true` (actor `svc:flow:sys_child`), while the parent's own write stays `false`; - a default child under a `'system'` parent writes `isSystem: false` as the triggering user. The caller's elevation does not carry into the child. - **Not this package's:** the PATCH and `beforeUpdate` writer kinds, and the objectql strip rule (an update strips `readonly` fields iff its context is not system). Those belong to the engine card. - `runas-system-stamping.integration.test.ts`, `runas-attribution-contract.test.ts` and `runas-grant-resolution.integration.test.ts` pin attribution, row stamping and grant resolution. None of them pins the writer-kind switch, so none carries this row. ### Row 11: a flow filter's date value, in the dialect filters use now The row names `interpolateFilter` resolving `TODAY()`. That function no longer exists: S6 (#19939) retired the template dialect from filter operands, the date macros included. This is what is true on `main`, measured and now pinned: - **A CEL date envelope is evaluated before the query: added** (3 cases, one per CRUD verb). With the clock pinned at `2026-03-14T12:00:00Z` (only `Date` is faked), `{ $lt: { dialect: 'cel', source: 'isoDate(today())' } }` reaches the data engine as `{ $lt: '2026-03-14' }`, and `isoDate(daysAgo(30))` reaches it as `'2026-02-12'`. - **`{today}` is passed through untouched: one operand added to the existing placeholder case** (`a query placeholder reaches the engine untouched, beside a literal`). That case already covered `{30_days_ago}`, `{current_year_start}` and `{current_user_id}`. `{today}` is a word of the query engine's vocabulary (`isKnownFilterToken`), which objectql resolves. The executor does not resolve it. - **The retired `{TODAY()}` macro is refused before any query: added** (6 cases). At `registerFlow` the refusal points to the operand (`config.filter.close_date.$lt`) and names the CEL spelling `isoDate(today())`, and the flow is not registered. The executor's contract parse refuses it as a guard (`errorClass: 'guard'`), and the data engine receives nothing. - **Not this package's:** objectql's `resolveFilterTokens`, which resolves `{today}` and refuses `{TODAY()}`. That belongs to the engine card. The difference from the row's wording is the retired mechanism only. The date is still resolved before the query. It is now written as a CEL envelope instead of a `{TODAY()}` token, and `{TODAY()}` is now refused instead of resolved. No spelling was restored. ## Ablations (one per row) Both mutations were temporary and were restored, with the restore proven, before any commit. Each went through `scripts/ablation-replace.mjs`: anchor count 1 to 0, replacement count 0 to 1, blob changed. A trap restored the file, and the restore was proven by the blob hash equalling HEAD's and an empty `git diff HEAD`. Both mutated files are imported by these tests through relative `./` paths, so vitest runs them from `src/` and no `dist/` build sits between the mutation and the run. **Row 8.** `packages/services/service-automation/src/engine.ts:6351`, the run context's construction point: ```text - runAs: flow.runAs ?? 'user', + runAs: 'user', ``` `crud-runas.test.ts`: `Tests 8 failed | 21 passed (29)`. The new cases that went red: - `screen flow, runAs:'system': the write on the resumed leg is isSystem true`: `AssertionError: the screen flow's runAs did not decide the resumed write: expected false to be true` - `subflow: a 'system' child called by a 'user' parent writes elevated; ...`: `AssertionError: the 'system' child's runAs did not decide its write: expected false to be true` - `subflow: a child with no runAs called by a 'system' parent writes as the user ...`: `AssertionError: the 'system' parent's own write was not elevated: expected false to be true` - plus the five existing elevation cases, e.g. `REGRESSION: a runAs:'system' flow must reach the data layer elevated (fails if runAs is ignored)`: `runAs:system did not elevate the data op (#1888 regressed): expected false to be true`. Stayed green, as predicted before the run: the `'user'` and omitted screen cases, because the mutation produces exactly their answer. This mutation reddens the second subflow case at its parent assertion, so it never reaches that case's leak assertion. A mutation that makes the child inherit the caller's `runAs` would redden the leak assertion. It was not run, because the ruling allows one ablation per row. **Row 11.** `packages/services/service-automation/src/builtin/crud-nodes.ts:88`, in `resolveNodeFilter`: ```text - if (!isExpressionEnvelopeShaped(operand.value)) continue; + if (true) continue; ``` `crud-filter-guard.test.ts`: `Tests 20 failed | 17 passed (37)`. The new cases that went red: `get_record`, `update_record` and `delete_record: a CEL date envelope is evaluated before the query — the engine receives the date`, each with: ```text - "$lt": "2026-03-14", + "$lt": { "dialect": "cel", "source": "isoDate(today())" }, - "$gte": "2026-02-12", + "$gte": { "dialect": "cel", "source": "isoDate(daysAgo(30))" }, ``` Stayed green, as predicted: the six `{TODAY()}` refusal cases and the placeholder case, which do not depend on evaluation. The refusal is the spec's judge, `classifyFlowFilterOperandString`, composed into the node contract. That judge lives in `packages/spec` and is not this row's ablation. ## Tests (head `aed1c7c3fd`) - Dependency closure built first: `turbo run build --filter='@objectstack/service-automation^...' --concurrency=2`, 29 of 29 tasks successful. - `pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2`: `Test Files 186 passed (186)`, `Tests 2500 passed (2500)`. - The two edited files alone: `Test Files 2 passed (2)`, `Tests 66 passed (66)`. The slowest new case took 12 ms. - `pnpm --filter @objectstack/service-automation run typecheck` exited 0. The test program (`tsconfig.test.json`) lists both edited files, and `check:test-typecheck` reported 0 errors. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (2 paths, +260/-3) derived 52 commands. 51 exited 0. `pnpm check:dual-build-cjs-loads` exited 3 (`PREREQUISITE NOT MET`: it reads the built output of every workspace package, and only this package's closure is built here), so it is NOT MEASURED locally and CI's run is the measurement. `--ran` reconciliation: `52 derived famil(ies) accounted for — 51 run, 1 NOT-MEASURED`. ## Acceptance notes - The card body's row 11 names a retired mechanism (`interpolateFilter`, `TODAY()`). The semantics it stood for are pinned in today's dialect, described above. The premise survives, with the mechanism updated. - The PM's lead for row 8 named the three `runas-*` files. The flow-node switch is actually pinned in `builtin/crud-runas.test.ts`, which is why the new writer-kind cases live there. - `registerFlow` refuses a value-slot token by throwing a bare `Error` whose message is the located refusal, with no `code`. The existing pins and the new ones therefore assert the message's located prefix (built from the exported `VALUE_SLOT_TEMPLATE_REFUSAL`) and the named CEL spelling. This is an observation about an in-process API, not a finding: carrier none. --- _Generated by [Claude Code](https://claude.ai/code/session_017WLmd5in9RXu7AjyHR8fKQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9bb9370 commit 534ca13

2 files changed

Lines changed: 260 additions & 3 deletions

File tree

‎packages/services/service-automation/src/builtin/crud-filter-guard.test.ts‎

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect, beforeEach } from 'vitest';
3+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
44
import { VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation';
55
import { AutomationEngine } from '../engine.js';
66
import { registerCrudNodes } from './crud-nodes.js';
@@ -28,6 +28,15 @@ import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
2828
* dialect (`interpolateFilter`): a flow token resolved against the run's
2929
* variables, an unresolved one was erased and refused here, and a placeholder
3030
* no flow variable shadowed was handed on.
31+
*
32+
* #15953 row 11 (the service-automation half): a flow filter's date value
33+
* reaches the data engine resolved and the retired `{TODAY()}` macro is
34+
* refused before any query (the last describe), while the query engine's
35+
* `{today}` is handed on for it to resolve (the first describe's placeholder
36+
* case). It lets objectstack-ai/hotcrm retire
37+
* `test/flow-filter-today-token.test.ts` (objectstack-ai/hotcrm#1583), whose
38+
* `interpolateFilter` no longer exists. The objectql half (`resolveFilterTokens`
39+
* resolves `{today}` and refuses `{TODAY()}`) is the engine's.
3140
*/
3241

3342
function createTestLogger(): any {
@@ -126,14 +135,15 @@ describe('a filter operand envelope is evaluated before the query; a query place
126135
it.each(NODES)('%s: a query placeholder reaches the engine untouched, beside a literal', async (nodeType, verb, extra) => {
127136
engine.registerFlow('crud_flow', crudFlow(nodeType, {
128137
objectName: 'deal',
129-
filter: { close_date: { $lt: '{current_year_start}' }, a: '{30_days_ago}', b: '{current_user_id}', status: 'open' },
138+
filter: { close_date: { $lt: '{current_year_start}' }, a: '{30_days_ago}', b: '{current_user_id}', due: { $lte: '{today}' }, status: 'open' },
130139
...extra,
131140
}));
132141
const result = await engine.execute('crud_flow', { record: { id: 'r1' } } as any);
133142

134143
expect(result.success).toBe(true);
144+
// `{today}` included (#15953 row 11): the query engine's word, never the executor's to resolve.
135145
expect(data.seen[verb]).toEqual({
136-
close_date: { $lt: '{current_year_start}' }, a: '{30_days_ago}', b: '{current_user_id}', status: 'open',
146+
close_date: { $lt: '{current_year_start}' }, a: '{30_days_ago}', b: '{current_user_id}', due: { $lte: '{today}' }, status: 'open',
137147
});
138148
});
139149

@@ -326,3 +336,94 @@ describe('a flow `{…}` token at a filter operand is refused at every door (#19
326336
expect(data.seen[verb]).toBeUndefined();
327337
});
328338
});
339+
340+
/**
341+
* #15953 row 11 — a filter's DATE value, in the dialect a filter reads today.
342+
*
343+
* The 17.x executor resolved a `{TODAY()}` macro against the clock before the
344+
* query. The macro is retired with the rest of the template dialect (#19939):
345+
* its spelling is the CEL envelope `isoDate(today())`, which the executor
346+
* evaluates, so the `where` the data engine receives holds the date itself.
347+
* `{today}` is a different word: a query placeholder, the query engine's to
348+
* resolve, so the executor hands it on as written — carried by the first
349+
* describe's placeholder case, which lists it beside the other placeholders.
350+
* The clock is pinned, so each case asserts the exact value the engine received.
351+
*
352+
* Directions decided before running: the envelope reaches the engine as
353+
* `YYYY-MM-DD` text, and `{TODAY()}` reaches nothing — refused at
354+
* `registerFlow` and by the executor's contract parse, naming `isoDate(today())`.
355+
*/
356+
describe("a filter's date value reaches the data engine resolved (#15953 row 11)", () => {
357+
// Midday UTC, so the reference-timezone day is this one wherever the run's
358+
// clock is read; only `Date` is faked, the engine's own timers stay real.
359+
const NOW = new Date('2026-03-14T12:00:00.000Z');
360+
361+
beforeEach(() => {
362+
vi.useFakeTimers({ toFake: ['Date'] });
363+
vi.setSystemTime(NOW);
364+
});
365+
afterEach(() => {
366+
vi.useRealTimers();
367+
});
368+
369+
it.each(NODES)('%s: a CEL date envelope is evaluated before the query — the engine receives the date', async (nodeType, verb, extra) => {
370+
const data = fakeData();
371+
const engine = new AutomationEngine(createTestLogger());
372+
registerCrudNodes(engine, ctxWith(data.service));
373+
engine.registerFlow('crud_flow', crudFlow(nodeType, {
374+
objectName: 'deal',
375+
filter: {
376+
close_date: { $lt: CEL('isoDate(today())') },
377+
created: { $gte: CEL('isoDate(daysAgo(30))') },
378+
},
379+
...extra,
380+
}));
381+
const result = await engine.execute('crud_flow', { record: { id: 'r1' } } as any);
382+
383+
expect(result.success, `run failed: ${JSON.stringify(result)}`).toBe(true);
384+
expect(data.seen[verb]).toEqual({
385+
close_date: { $lt: '2026-03-14' },
386+
created: { $gte: '2026-02-12' },
387+
});
388+
});
389+
390+
it.each(NODES)('%s: `registerFlow` refuses the retired `{TODAY()}` macro, located at the operand, naming its CEL spelling', async (nodeType, _verb, extra) => {
391+
const engine = new AutomationEngine(createTestLogger());
392+
registerCrudNodes(engine, ctxWith(fakeData().service));
393+
let thrown: unknown;
394+
try {
395+
engine.registerFlow('crud_flow', crudFlow(nodeType, {
396+
objectName: 'deal', filter: { close_date: { $lt: '{TODAY()}' } }, ...extra,
397+
}));
398+
} catch (err) {
399+
thrown = err;
400+
}
401+
expect(thrown, 'a flow carrying `{TODAY()}` at a filter operand registered').toBeInstanceOf(Error);
402+
expect((thrown as Error).message).toContain(`${nodeType} filter operand at config.filter.close_date.$lt: ${VALUE_SLOT_TEMPLATE_REFUSAL}`);
403+
expect((thrown as Error).message).toContain('isoDate(today())');
404+
expect(await engine.getFlow('crud_flow'), 'the refused flow must not be held').toBeNull();
405+
});
406+
407+
it.each(NODES)('%s: the executor\'s contract parse refuses `{TODAY()}` as a guard, and nothing is queried', async (nodeType, verb, extra) => {
408+
const data = fakeData();
409+
const executors = new Map<string, { execute: (...args: any[]) => Promise<any> }>();
410+
const recorder = new Proxy(new AutomationEngine(createTestLogger()), {
411+
get(target, key, receiver) {
412+
if (key === 'registerNodeExecutor') return (e: any) => { executors.set(e.type, e); };
413+
return Reflect.get(target, key, receiver);
414+
},
415+
});
416+
registerCrudNodes(recorder as AutomationEngine, ctxWith(data.service));
417+
418+
const result = await executors.get(nodeType)!.execute(
419+
{ id: 'op', type: nodeType, label: 'Op', config: { objectName: 'deal', filter: { close_date: { $lt: '{TODAY()}' } }, ...extra } },
420+
new Map<string, unknown>(),
421+
{},
422+
);
423+
expect(result.success).toBe(false);
424+
expect(result.errorClass).toBe('guard');
425+
expect(result.error).toContain(VALUE_SLOT_TEMPLATE_REFUSAL);
426+
expect(result.error).toContain('isoDate(today())');
427+
expect(data.seen[verb], 'a refused filter must not reach the data engine').toBeUndefined();
428+
});
429+
});

‎packages/services/service-automation/src/builtin/crud-runas.test.ts‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,21 @@
1010
* was skipped), so runAs was inert in both directions. These tests capture the
1111
* exact `context` each data op receives, proving the switch — and the regression
1212
* test fails loudly if the threading is ever removed (so it can't go dead again).
13+
*
14+
* #15953 row 8 (the service-automation half): "a flow's `runAs` decides
15+
* `isSystem` for its writes", per writer kind — a flow node (the first
16+
* describe), a screen flow's resumed leg and a subflow (the last describe).
17+
* Together they carry what objectstack-ai/hotcrm `test/readonly-write-semantics.test.ts`
18+
* pins about flow writers, so hotcrm can retire that file's flow-writer rows
19+
* (objectstack-ai/hotcrm#1583). The objectql half (an update strips `readonly`
20+
* fields iff its context is not system) and the PATCH / `beforeUpdate` writer
21+
* kinds are the engine's, not this package's.
1322
*/
1423
import { describe, it, expect } from 'vitest';
1524
import { AutomationEngine } from '../engine.js';
1625
import { registerCrudNodes } from './crud-nodes.js';
26+
import { registerScreenNodes } from './screen-nodes.js';
27+
import { registerSubflowNode } from './subflow-node.js';
1728
import {
1829
resolveRunDataContext,
1930
runIsUnscopedUserMode,
@@ -499,3 +510,148 @@ describe("runAs:'user' resolves the triggering user's grants at run setup (#3356
499510
}
500511
});
501512
});
513+
514+
/**
515+
* #15953 row 8 — the same switch, for the two flow writer kinds whose write
516+
* does not run on the leg `execute()` started. Each case reads the `isSystem`
517+
* the WRITE received, never the run's own `runAs`.
518+
*
519+
* - A screen flow parks at its screen and writes on the RESUMED leg, from the
520+
* run context the suspension persisted — not from the resume call, which
521+
* carries no identity.
522+
* - A subflow is its own run: the CHILD flow's `runAs` decides its writes, in
523+
* both directions — a caller's elevation does not leak into a `'user'`
524+
* child, and a `'user'` caller does not narrow a `'system'` child.
525+
*
526+
* Directions decided before running: every write follows the `runAs` of the
527+
* flow that owns its node, and the triggering user rides every write (#5494).
528+
*/
529+
describe("flow.runAs decides isSystem for every flow writer kind (#15953 row 8)", () => {
530+
/** An engine with the three node families these flows use, over the recording data engine. */
531+
function writerEngine() {
532+
const engine = new AutomationEngine(makeLogger());
533+
const { data, calls } = fakeData();
534+
const ctx = ctxWith(data);
535+
registerCrudNodes(engine, ctx);
536+
registerScreenNodes(engine, ctx);
537+
registerSubflowNode(engine, ctx);
538+
return { engine, calls };
539+
}
540+
541+
/** start → screen (one field, so it parks) → update_record → end. */
542+
function screenWriteFlow(name: string, runAs?: 'system' | 'user') {
543+
return {
544+
name,
545+
label: name,
546+
type: 'screen',
547+
...(runAs ? { runAs } : {}),
548+
nodes: [
549+
{ id: 'start', type: 'start', label: 'Start' },
550+
{ id: 'ask', type: 'screen', label: 'Ask', config: { fields: [{ name: 'note', label: 'Note', type: 'text' }] } },
551+
{ id: 'up', type: 'update_record', label: 'Update', config: { objectName: 'thing', filter: { id: 'x' }, fields: { a: 2 } } },
552+
{ id: 'end', type: 'end', label: 'End' },
553+
],
554+
edges: [
555+
{ id: 'e1', source: 'start', target: 'ask' },
556+
{ id: 'e2', source: 'ask', target: 'up' },
557+
{ id: 'e3', source: 'up', target: 'end' },
558+
],
559+
} as any;
560+
}
561+
562+
/** start → create_record(`obj`) → end: the child, which writes `obj` so its write is told apart. */
563+
function childFlow(name: string, obj: string, runAs?: 'system' | 'user') {
564+
return {
565+
name,
566+
label: name,
567+
type: 'autolaunched',
568+
...(runAs ? { runAs } : {}),
569+
nodes: [
570+
{ id: 'start', type: 'start', label: 'Start' },
571+
{ id: 'mk', type: 'create_record', label: 'Create', config: { objectName: obj, fields: { a: 1 } } },
572+
{ id: 'end', type: 'end', label: 'End' },
573+
],
574+
edges: [
575+
{ id: 'e1', source: 'start', target: 'mk' },
576+
{ id: 'e2', source: 'mk', target: 'end' },
577+
],
578+
} as any;
579+
}
580+
581+
/** start → update_record(parent_thing) → subflow(child) → end. */
582+
function parentFlow(name: string, child: string, runAs?: 'system' | 'user') {
583+
return {
584+
name,
585+
label: name,
586+
type: 'autolaunched',
587+
...(runAs ? { runAs } : {}),
588+
nodes: [
589+
{ id: 'start', type: 'start', label: 'Start' },
590+
{ id: 'up', type: 'update_record', label: 'Update', config: { objectName: 'parent_thing', filter: { id: 'p' }, fields: { a: 2 } } },
591+
{ id: 'call', type: 'subflow', label: 'Call', config: { flowName: child } },
592+
{ id: 'end', type: 'end', label: 'End' },
593+
],
594+
edges: [
595+
{ id: 'e1', source: 'start', target: 'up' },
596+
{ id: 'e2', source: 'up', target: 'call' },
597+
{ id: 'e3', source: 'call', target: 'end' },
598+
],
599+
} as any;
600+
}
601+
602+
it.each([
603+
["runAs:'system'", true, 'system'],
604+
["runAs:'user'", false, 'user'],
605+
['runAs omitted', false, undefined],
606+
] as const)("screen flow, %s: the write on the resumed leg is isSystem %s", async (_label, elevated, runAs) => {
607+
const { engine, calls } = writerEngine();
608+
engine.registerFlow('screen_write', screenWriteFlow('screen_write', runAs));
609+
610+
const paused = await engine.execute('screen_write', { userId: 'u1', tenantId: 'org1' });
611+
expect(paused.status, 'the screen must park the run, so the write runs on the resumed leg').toBe('paused');
612+
expect(calls, 'nothing is written before the screen is answered').toEqual([]);
613+
614+
// The resume carries no identity: the write's context comes from the run.
615+
const res = await engine.resume(paused.runId!, { variables: { note: 'done' } });
616+
expect(res.success, `resume failed: ${JSON.stringify(res)}`).toBe(true);
617+
618+
const update = calls.find((c) => c.op === 'update' && c.obj === 'thing');
619+
expect(update, 'the resumed leg never reached the data engine').toBeTruthy();
620+
expect(update!.ctx.isSystem, "the screen flow's runAs did not decide the resumed write").toBe(elevated);
621+
expect(update!.ctx.userId, 'the triggering user must ride the resumed write').toBe('u1');
622+
expect(update!.ctx.tenantId).toBe('org1');
623+
});
624+
625+
it("subflow: a 'system' child called by a 'user' parent writes elevated; the parent's own write stays the user's", async () => {
626+
const { engine, calls } = writerEngine();
627+
engine.registerFlow('sys_child', childFlow('sys_child', 'child_thing', 'system'));
628+
engine.registerFlow('user_parent', parentFlow('user_parent', 'sys_child'));
629+
630+
const res = await engine.execute('user_parent', { userId: 'u1', tenantId: 'org1' });
631+
expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true);
632+
633+
const parentWrite = calls.find((c) => c.obj === 'parent_thing');
634+
const childWrite = calls.find((c) => c.obj === 'child_thing');
635+
expect(parentWrite!.ctx.isSystem, "the 'user' parent's own write was elevated").toBe(false);
636+
expect(childWrite, "the child's write never reached the data engine").toBeTruthy();
637+
expect(childWrite!.ctx.isSystem, "the 'system' child's runAs did not decide its write").toBe(true);
638+
expect(childWrite!.ctx.actor, 'the elevated write names the CHILD flow').toBe('svc:flow:sys_child');
639+
expect(childWrite!.ctx.userId, 'the triggering user must ride the child write').toBe('u1');
640+
});
641+
642+
it("subflow: a child with no runAs called by a 'system' parent writes as the user — the caller's elevation does not leak", async () => {
643+
const { engine, calls } = writerEngine();
644+
engine.registerFlow('user_child', childFlow('user_child', 'child_thing'));
645+
engine.registerFlow('sys_parent', parentFlow('sys_parent', 'user_child', 'system'));
646+
647+
const res = await engine.execute('sys_parent', { userId: 'u1', tenantId: 'org1' });
648+
expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true);
649+
650+
const parentWrite = calls.find((c) => c.obj === 'parent_thing');
651+
const childWrite = calls.find((c) => c.obj === 'child_thing');
652+
expect(parentWrite!.ctx.isSystem, "the 'system' parent's own write was not elevated").toBe(true);
653+
expect(childWrite, "the child's write never reached the data engine").toBeTruthy();
654+
expect(childWrite!.ctx.isSystem, "the parent's elevation leaked into a 'user' child's write").toBe(false);
655+
expect(childWrite!.ctx.userId, "the child's write must run as the triggering user").toBe('u1');
656+
});
657+
});

0 commit comments

Comments
 (0)