Skip to content

Commit 7c5cd37

Browse files
os-zhuangclaude
andcommitted
test(trigger-schedule): end-to-end runAs fail-open via the real cron path
Add an e2e test wiring the REAL ScheduleTrigger to the REAL AutomationEngine: a fired scheduled job builds a user-less { event:'schedule' } context, reaches the engine, threads the unscoped (user-mode, user-less) identity to the data node, and trips the [runAs] warning — proving the fail-open via the actual cron path (the unit + dogfood tests call engine.execute() with a hand-made context). Also asserts runAs:'system' propagates elevated with no warning. Adds @objectstack/service-automation as a dev-dependency of trigger-schedule (the first place the two real halves meet; the FlowTrigger contract is declared structurally so there is no runtime/build dependency). Refs #1888, ADR-0049. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 801d1ee commit 7c5cd37

4 files changed

Lines changed: 141 additions & 2 deletions

File tree

.changeset/scheduled-flow-runas-unscoped.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
'@objectstack/service-automation': minor
33
'@objectstack/cli': minor
44
'@objectstack/spec': patch
5+
'@objectstack/trigger-schedule': patch
56
---
67

78
fix(security): surface the schedule/user-less `runAs:'user'` fail-open (#1888 follow-up)
@@ -46,8 +47,11 @@ tracked as M2 follow-up.
4647

4748
Proven by a service-automation unit test (the engine warns once for a user-less
4849
user-mode data run; stays silent for `system`, for an identified user, and for a
49-
data-less flow) and a dogfood gate (`flow-runas-schedule.dogfood.test.ts`) that
50-
drives user-less runs through the real automation + security + data stack: a
50+
data-less flow), an end-to-end test wiring the **real `ScheduleTrigger` to the
51+
real engine** (`@objectstack/trigger-schedule`) that fires a job and asserts the
52+
user-less identity reaches the engine + trips the warning through the actual cron
53+
path, and a dogfood gate (`flow-runas-schedule.dogfood.test.ts`) that drives
54+
user-less runs through the real automation + security + data stack: a
5155
`runAs:'user'` run reads + writes an owner-scoped note a member cannot — audibly —
5256
while `runAs:'system'` is the explicit, warning-free equivalent.
5357

packages/triggers/trigger-schedule/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@objectstack/spec": "workspace:*"
2222
},
2323
"devDependencies": {
24+
"@objectstack/service-automation": "workspace:*",
2425
"@types/node": "^26.0.0",
2526
"typescript": "^6.0.3",
2627
"vitest": "^4.1.9"
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// END-TO-END: the REAL ScheduleTrigger wired to the REAL AutomationEngine, proving
4+
// that a scheduled flow's JOB FIRE produces a USER-LESS context that reaches the
5+
// engine and trips the unscoped-run fail-open warning (#1888 follow-up, ADR-0049).
6+
//
7+
// The unit + dogfood tests for this fix call `engine.execute()` with a hand-made
8+
// `{ event:'schedule' }` context. This test closes that gap by exercising the
9+
// ACTUAL cron path the platform runs:
10+
//
11+
// job fires -> ScheduleTrigger builds { event:'schedule', params } (NO userId)
12+
// -> engine's activateFlowTrigger callback -> engine.execute -> resolveRunContext
13+
// -> warns AND threads the unscoped (user-mode, user-less) identity to the data node.
14+
//
15+
// ScheduleTrigger declares the FlowTrigger contract structurally (no build dep on
16+
// the automation package), so this is the first place the two real halves meet.
17+
18+
import { describe, it, expect } from 'vitest';
19+
import { AutomationEngine } from '@objectstack/service-automation';
20+
import type { AutomationContext, JobSchedule, JobHandler } from '@objectstack/spec/contracts';
21+
import { ScheduleTrigger } from './schedule-trigger.js';
22+
23+
function recordingLogger(): { logger: any; warns: string[] } {
24+
const warns: string[] = [];
25+
const l: any = { info() {}, warn: (m: string) => warns.push(m), error() {}, debug() {} };
26+
l.child = () => l;
27+
return { logger: l, warns };
28+
}
29+
const runAsWarns = (warns: string[]) => warns.filter((w) => w.includes('[runAs]'));
30+
31+
const silent = { info() {}, warn() {}, debug() {} };
32+
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
33+
34+
/** Fake IJobService slice with a deterministic `fire()` standing in for the cron tick. */
35+
function fakeJobService() {
36+
const jobs = new Map<string, { schedule: JobSchedule; handler: JobHandler }>();
37+
return {
38+
service: {
39+
async schedule(name: string, schedule: JobSchedule, handler: JobHandler) {
40+
jobs.set(name, { schedule, handler });
41+
},
42+
async cancel(name: string) {
43+
jobs.delete(name);
44+
},
45+
},
46+
has: (name: string) => jobs.has(name),
47+
fire: async (name: string, jobId = 'tick1') => {
48+
await jobs.get(name)?.handler({ jobId } as never);
49+
},
50+
};
51+
}
52+
53+
/** A schedule-triggered flow that touches data, parameterized by runAs. */
54+
function scheduledDataFlow(name: string, runAs?: 'system' | 'user') {
55+
return {
56+
name,
57+
label: name,
58+
type: 'schedule',
59+
...(runAs ? { runAs } : {}),
60+
nodes: [
61+
{ id: 'start', type: 'start', label: 'Start', config: { schedule: { type: 'interval', intervalMs: 1000 } } },
62+
{ id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'thing', fields: { a: 1 } } },
63+
{ id: 'end', type: 'end', label: 'End' },
64+
],
65+
edges: [
66+
{ id: 'e1', source: 'start', target: 'mk' },
67+
{ id: 'e2', source: 'mk', target: 'end' },
68+
],
69+
} as never;
70+
}
71+
72+
/** Register a stub data executor that captures the context it is handed. */
73+
function captureDataContext(engine: AutomationEngine): AutomationContext[] {
74+
const seen: AutomationContext[] = [];
75+
engine.registerNodeExecutor({
76+
type: 'create_record',
77+
async execute(_node: unknown, _vars: unknown, context: AutomationContext) {
78+
seen.push(context);
79+
return { success: true, output: {} };
80+
},
81+
} as never);
82+
return seen;
83+
}
84+
85+
describe('schedule trigger -> engine: user-less runAs fail-open via the REAL cron path (#1888)', () => {
86+
it('a fired scheduled job runs the flow UNSCOPED (user-less) and the engine warns', async () => {
87+
const { logger, warns } = recordingLogger();
88+
const engine = new AutomationEngine(logger);
89+
const seen = captureDataContext(engine);
90+
91+
engine.registerFlow('nightly_sweep', scheduledDataFlow('nightly_sweep')); // no runAs -> default 'user'
92+
const job = fakeJobService();
93+
engine.registerTrigger(new ScheduleTrigger(() => job.service, silent));
94+
await flush(); // let ScheduleTrigger.start() schedule the job
95+
96+
expect(job.has('flow-schedule:nightly_sweep'), 'flow was not bound to the schedule trigger').toBe(true);
97+
98+
// Simulate the cron tick - this is what the job service does on schedule.
99+
await job.fire('flow-schedule:nightly_sweep', 'tick-42');
100+
101+
// The data node ran with the schedule's USER-LESS, user-mode context.
102+
expect(seen).toHaveLength(1);
103+
expect(seen[0].event).toBe('schedule');
104+
expect(seen[0].params).toMatchObject({ jobId: 'tick-42', flowName: 'nightly_sweep' });
105+
expect(seen[0].userId, 'a scheduled run must carry no trigger user').toBeUndefined();
106+
expect(seen[0].runAs, 'effective identity is user (the default)').toBe('user');
107+
108+
// ...and the engine made the fail-open AUDIBLE.
109+
const w = runAsWarns(warns);
110+
expect(w).toHaveLength(1);
111+
expect(w[0]).toContain("flow 'nightly_sweep'");
112+
expect(w[0]).toMatch(/UNSCOPED/);
113+
expect(w[0]).toMatch(/runAs:'system'/);
114+
});
115+
116+
it("a scheduled runAs:'system' flow reaches the engine elevated, with NO warning", async () => {
117+
const { logger, warns } = recordingLogger();
118+
const engine = new AutomationEngine(logger);
119+
const seen = captureDataContext(engine);
120+
121+
engine.registerFlow('sys_sweep', scheduledDataFlow('sys_sweep', 'system'));
122+
const job = fakeJobService();
123+
engine.registerTrigger(new ScheduleTrigger(() => job.service, silent));
124+
await flush();
125+
await job.fire('flow-schedule:sys_sweep');
126+
127+
expect(seen).toHaveLength(1);
128+
expect(seen[0].runAs, 'explicit elevation propagated through the cron path').toBe('system');
129+
expect(runAsWarns(warns), 'an explicitly-elevated scheduled run must not warn').toHaveLength(0);
130+
});
131+
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)