@open-mercato/scheduler 0.7.1-develop.7113.1.9d83dca10c → 0.7.1-develop.7121.1.734c263bda
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/scheduler/__integration__/TC-SCHED-009.spec.js +61 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-009.spec.js.map +7 -0
- package/dist/modules/scheduler/api/trigger/route.js +37 -37
- package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
- package/dist/modules/scheduler/commands/jobs.js +3 -0
- package/dist/modules/scheduler/commands/jobs.js.map +2 -2
- package/dist/modules/scheduler/commands/trigger.js +146 -0
- package/dist/modules/scheduler/commands/trigger.js.map +7 -0
- package/dist/modules/scheduler/i18n/de.json +1 -0
- package/dist/modules/scheduler/i18n/en.json +1 -0
- package/dist/modules/scheduler/i18n/es.json +1 -0
- package/dist/modules/scheduler/i18n/ko.json +1 -0
- package/dist/modules/scheduler/i18n/pl.json +1 -0
- package/dist/modules/scheduler/lib/commandContext.js +15 -5
- package/dist/modules/scheduler/lib/commandContext.js.map +2 -2
- package/dist/modules/scheduler/lib/scheduler-safe-commands.js +11 -4
- package/dist/modules/scheduler/lib/scheduler-safe-commands.js.map +2 -2
- package/dist/modules/scheduler/services/localSchedulerService.js +2 -2
- package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
- package/dist/modules/scheduler/workers/execute-schedule.worker.js +38 -11
- package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/scheduler/__integration__/TC-SCHED-009.spec.ts +98 -0
- package/src/modules/scheduler/api/trigger/__tests__/route.test.ts +154 -0
- package/src/modules/scheduler/api/trigger/route.ts +63 -47
- package/src/modules/scheduler/commands/__tests__/trigger.test.ts +411 -0
- package/src/modules/scheduler/commands/jobs.ts +1 -1
- package/src/modules/scheduler/commands/trigger.ts +258 -0
- package/src/modules/scheduler/i18n/de.json +1 -0
- package/src/modules/scheduler/i18n/en.json +1 -0
- package/src/modules/scheduler/i18n/es.json +1 -0
- package/src/modules/scheduler/i18n/ko.json +1 -0
- package/src/modules/scheduler/i18n/pl.json +1 -0
- package/src/modules/scheduler/lib/__tests__/commandContext.test.ts +83 -1
- package/src/modules/scheduler/lib/commandContext.ts +39 -3
- package/src/modules/scheduler/lib/scheduler-safe-commands.ts +25 -4
- package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +1 -1
- package/src/modules/scheduler/services/localSchedulerService.ts +2 -2
- package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +132 -1
- package/src/modules/scheduler/workers/execute-schedule.worker.ts +58 -13
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
export {}
|
|
2
|
+
|
|
3
|
+
const registerCommand = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.mock('@open-mercato/shared/lib/commands', () => ({
|
|
6
|
+
registerCommand,
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
10
|
+
resolveTranslations: jest.fn().mockResolvedValue({
|
|
11
|
+
translate: (_key: string, fallback?: string) => fallback ?? _key,
|
|
12
|
+
}),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const enqueue = jest.fn()
|
|
16
|
+
const close = jest.fn()
|
|
17
|
+
const createQueue = jest.fn(() => ({ enqueue, close }))
|
|
18
|
+
|
|
19
|
+
// The bare `@open-mercato/queue` specifier is not resolvable under this package's
|
|
20
|
+
// jest moduleNameMapper (only its subpaths are), matching how the scheduler worker
|
|
21
|
+
// test mocks it.
|
|
22
|
+
jest.mock('@open-mercato/queue', () => ({ createQueue }), { virtual: true })
|
|
23
|
+
|
|
24
|
+
jest.mock('@open-mercato/shared/lib/redis/connection', () => ({
|
|
25
|
+
getRedisUrlOrThrow: jest.fn(() => 'redis://localhost:6379'),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
const SCHEDULE_ID = '11111111-1111-4111-8111-111111111111'
|
|
29
|
+
const ACTOR_ID = '22222222-2222-4222-8222-222222222222'
|
|
30
|
+
|
|
31
|
+
function loadTriggerCommand() {
|
|
32
|
+
let trigger: any
|
|
33
|
+
jest.isolateModules(() => {
|
|
34
|
+
require('../trigger')
|
|
35
|
+
trigger = registerCommand.mock.calls.find(([cmd]) => cmd.id === 'scheduler.jobs.trigger')?.[0]
|
|
36
|
+
})
|
|
37
|
+
return trigger
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeSchedule(overrides: Record<string, unknown> = {}) {
|
|
41
|
+
return {
|
|
42
|
+
id: SCHEDULE_ID,
|
|
43
|
+
name: 'Nightly report',
|
|
44
|
+
scopeType: 'tenant',
|
|
45
|
+
tenantId: 'tenant-a',
|
|
46
|
+
organizationId: null,
|
|
47
|
+
targetType: 'queue',
|
|
48
|
+
targetQueue: 'reports',
|
|
49
|
+
targetCommand: null,
|
|
50
|
+
...overrides,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeEm(schedule: Record<string, unknown> | null) {
|
|
55
|
+
return {
|
|
56
|
+
fork: jest.fn().mockReturnThis(),
|
|
57
|
+
findOne: jest.fn().mockResolvedValue(schedule),
|
|
58
|
+
flush: jest.fn().mockResolvedValue(undefined),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const userHasAllFeatures = jest.fn()
|
|
63
|
+
|
|
64
|
+
function makeCtx(auth: Record<string, unknown> | null, em: any, rbacService: any = { userHasAllFeatures }) {
|
|
65
|
+
return {
|
|
66
|
+
auth,
|
|
67
|
+
container: {
|
|
68
|
+
resolve: jest.fn((name: string) => (name === 'rbacService' ? rbacService : em)),
|
|
69
|
+
},
|
|
70
|
+
selectedOrganizationId: null,
|
|
71
|
+
} as any
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tenantActor = { sub: ACTOR_ID, userId: ACTOR_ID, tenantId: 'tenant-a', orgId: null, isSuperAdmin: false }
|
|
75
|
+
|
|
76
|
+
describe('scheduler.jobs.trigger', () => {
|
|
77
|
+
const previousStrategy = process.env.QUEUE_STRATEGY
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
jest.clearAllMocks()
|
|
81
|
+
jest.resetModules()
|
|
82
|
+
process.env.QUEUE_STRATEGY = 'async'
|
|
83
|
+
enqueue.mockResolvedValue('queue-job-1')
|
|
84
|
+
close.mockResolvedValue(undefined)
|
|
85
|
+
userHasAllFeatures.mockResolvedValue(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
afterAll(() => {
|
|
89
|
+
if (previousStrategy === undefined) delete process.env.QUEUE_STRATEGY
|
|
90
|
+
else process.env.QUEUE_STRATEGY = previousStrategy
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('is not undoable, so a manual trigger mints no undo token', () => {
|
|
94
|
+
const trigger = loadTriggerCommand()
|
|
95
|
+
expect(trigger).toBeDefined()
|
|
96
|
+
expect(trigger.isUndoable).toBe(false)
|
|
97
|
+
expect(trigger.undo).toBeUndefined()
|
|
98
|
+
expect(trigger.redo).toBeUndefined()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('enqueues the execution job and reports the queue job id', async () => {
|
|
102
|
+
const trigger = loadTriggerCommand()
|
|
103
|
+
const em = makeEm(makeSchedule())
|
|
104
|
+
const ctx = makeCtx(tenantActor, em)
|
|
105
|
+
|
|
106
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, ctx)
|
|
107
|
+
|
|
108
|
+
expect(result).toMatchObject({
|
|
109
|
+
scheduleId: SCHEDULE_ID,
|
|
110
|
+
scheduleName: 'Nightly report',
|
|
111
|
+
targetType: 'queue',
|
|
112
|
+
target: 'reports',
|
|
113
|
+
outcome: 'enqueued',
|
|
114
|
+
queueJobId: 'queue-job-1',
|
|
115
|
+
error: null,
|
|
116
|
+
})
|
|
117
|
+
expect(enqueue).toHaveBeenCalledWith(
|
|
118
|
+
expect.objectContaining({
|
|
119
|
+
scheduleId: SCHEDULE_ID,
|
|
120
|
+
triggerType: 'manual',
|
|
121
|
+
triggeredByUserId: ACTOR_ID,
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
expect(close).toHaveBeenCalledTimes(1)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('reports a command schedule target by its command id', async () => {
|
|
128
|
+
const trigger = loadTriggerCommand()
|
|
129
|
+
const em = makeEm(makeSchedule({ targetType: 'command', targetQueue: null, targetCommand: 'scheduler.test.echo' }))
|
|
130
|
+
|
|
131
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
|
|
132
|
+
|
|
133
|
+
expect(result).toMatchObject({ targetType: 'command', target: 'scheduler.test.echo', outcome: 'enqueued' })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('leaves triggeredByUserId null for a key-only caller so the run keeps acting as the creator', async () => {
|
|
137
|
+
const trigger = loadTriggerCommand()
|
|
138
|
+
const em = makeEm(makeSchedule())
|
|
139
|
+
// No bound user: a bare key id is not a user id, so it could never satisfy the
|
|
140
|
+
// worker's RBAC lookup and must fall back to the schedule's creator.
|
|
141
|
+
const ctx = makeCtx({ sub: 'api-key-1', tenantId: 'tenant-a', orgId: null, isApiKey: true }, em)
|
|
142
|
+
|
|
143
|
+
await trigger.execute({ id: SCHEDULE_ID }, ctx)
|
|
144
|
+
|
|
145
|
+
expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ triggeredByUserId: null }))
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('attributes an API key issued on a user behalf to that user', async () => {
|
|
149
|
+
const trigger = loadTriggerCommand()
|
|
150
|
+
const em = makeEm(makeSchedule())
|
|
151
|
+
// `resolveCommandActorUserId` reads `userId` before it considers `isApiKey`, so
|
|
152
|
+
// a user-bound key acts as its real user rather than falling back to the
|
|
153
|
+
// creator — the run is attributed to whoever it was actually made on behalf of.
|
|
154
|
+
const ctx = makeCtx(
|
|
155
|
+
{ sub: 'api-key-1', userId: ACTOR_ID, tenantId: 'tenant-a', orgId: null, isApiKey: true },
|
|
156
|
+
em,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
await trigger.execute({ id: SCHEDULE_ID }, ctx)
|
|
160
|
+
|
|
161
|
+
expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ triggeredByUserId: ACTOR_ID }))
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('returns not_found for a missing schedule without throwing', async () => {
|
|
165
|
+
const trigger = loadTriggerCommand()
|
|
166
|
+
const em = makeEm(null)
|
|
167
|
+
|
|
168
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
|
|
169
|
+
|
|
170
|
+
expect(result.outcome).toBe('not_found')
|
|
171
|
+
expect(enqueue).not.toHaveBeenCalled()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('returns not_found for another tenant schedule, never confirming the id exists', async () => {
|
|
175
|
+
const trigger = loadTriggerCommand()
|
|
176
|
+
const em = makeEm(makeSchedule({ tenantId: 'tenant-b' }))
|
|
177
|
+
|
|
178
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
|
|
179
|
+
|
|
180
|
+
expect(result.outcome).toBe('not_found')
|
|
181
|
+
expect(result.scheduleName).toBeNull()
|
|
182
|
+
expect(result.target).toBeNull()
|
|
183
|
+
expect(enqueue).not.toHaveBeenCalled()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('returns forbidden for a system-scoped schedule without super-admin', async () => {
|
|
187
|
+
const trigger = loadTriggerCommand()
|
|
188
|
+
const em = makeEm(makeSchedule({ scopeType: 'system', tenantId: null }))
|
|
189
|
+
|
|
190
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
|
|
191
|
+
|
|
192
|
+
expect(result.outcome).toBe('forbidden')
|
|
193
|
+
expect(enqueue).not.toHaveBeenCalled()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('allows a super admin to trigger a system-scoped schedule', async () => {
|
|
197
|
+
const trigger = loadTriggerCommand()
|
|
198
|
+
const em = makeEm(makeSchedule({ scopeType: 'system', tenantId: null }))
|
|
199
|
+
const ctx = makeCtx({ ...tenantActor, isSuperAdmin: true }, em)
|
|
200
|
+
|
|
201
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, ctx)
|
|
202
|
+
|
|
203
|
+
expect(result.outcome).toBe('enqueued')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('refuses a command schedule the triggering user is not authorized to run, instead of enqueueing it', async () => {
|
|
207
|
+
// The worker gates the run with this same assertion and can only throw when it
|
|
208
|
+
// refuses, which BullMQ retries forever while the caller has already been told
|
|
209
|
+
// the run was enqueued. Deciding here turns that into a 403 and an audit row.
|
|
210
|
+
userHasAllFeatures.mockResolvedValue(false)
|
|
211
|
+
const trigger = loadTriggerCommand()
|
|
212
|
+
const em = makeEm(makeSchedule({ targetType: 'command', targetQueue: null, targetCommand: 'scheduler.test.echo' }))
|
|
213
|
+
|
|
214
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
|
|
215
|
+
|
|
216
|
+
expect(result).toMatchObject({
|
|
217
|
+
outcome: 'forbidden',
|
|
218
|
+
queueJobId: null,
|
|
219
|
+
error: 'Scheduled command actor is not authorized',
|
|
220
|
+
})
|
|
221
|
+
// Access to the row was granted, so naming it discloses nothing the refusal withholds.
|
|
222
|
+
expect(result).toMatchObject({ scheduleName: 'Nightly report', target: 'scheduler.test.echo' })
|
|
223
|
+
expect(enqueue).not.toHaveBeenCalled()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('authorizes the actor the worker will run as, not the caller when there is no bound user', async () => {
|
|
227
|
+
const trigger = loadTriggerCommand()
|
|
228
|
+
const em = makeEm(
|
|
229
|
+
makeSchedule({
|
|
230
|
+
targetType: 'command',
|
|
231
|
+
targetQueue: null,
|
|
232
|
+
targetCommand: 'scheduler.test.echo',
|
|
233
|
+
createdByUserId: 'creator-1',
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
// A key-only caller contributes no user id, so the run falls back to the
|
|
237
|
+
// creator — and the creator is who this pre-check must authorize.
|
|
238
|
+
const ctx = makeCtx({ sub: 'api-key-1', tenantId: 'tenant-a', orgId: null, isApiKey: true }, em)
|
|
239
|
+
|
|
240
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, ctx)
|
|
241
|
+
|
|
242
|
+
expect(result.outcome).toBe('enqueued')
|
|
243
|
+
expect(userHasAllFeatures).toHaveBeenCalledWith('creator-1', ['scheduler.jobs.manage'], {
|
|
244
|
+
tenantId: 'tenant-a',
|
|
245
|
+
organizationId: null,
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('refuses a command schedule with no actor at all rather than enqueueing an unrunnable job', async () => {
|
|
250
|
+
const trigger = loadTriggerCommand()
|
|
251
|
+
const em = makeEm(
|
|
252
|
+
makeSchedule({ targetType: 'command', targetQueue: null, targetCommand: 'scheduler.test.echo' }),
|
|
253
|
+
)
|
|
254
|
+
const ctx = makeCtx({ sub: 'api-key-1', tenantId: 'tenant-a', orgId: null, isApiKey: true }, em)
|
|
255
|
+
|
|
256
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, ctx)
|
|
257
|
+
|
|
258
|
+
expect(result).toMatchObject({
|
|
259
|
+
outcome: 'forbidden',
|
|
260
|
+
error: 'Scheduled command requires an authenticated actor',
|
|
261
|
+
})
|
|
262
|
+
expect(enqueue).not.toHaveBeenCalled()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('reports a failing authorization lookup as failed, not as a refusal', async () => {
|
|
266
|
+
// An RBAC store outage is not a decision about the actor. Calling it `forbidden`
|
|
267
|
+
// would tell the caller they lack access they may well hold, and file an audit
|
|
268
|
+
// row claiming a refusal that was never made.
|
|
269
|
+
userHasAllFeatures.mockRejectedValue(new Error('rbac store unavailable'))
|
|
270
|
+
const trigger = loadTriggerCommand()
|
|
271
|
+
const em = makeEm(makeSchedule({ targetType: 'command', targetQueue: null, targetCommand: 'scheduler.test.echo' }))
|
|
272
|
+
|
|
273
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
|
|
274
|
+
|
|
275
|
+
expect(result).toMatchObject({ outcome: 'failed', error: 'rbac store unavailable' })
|
|
276
|
+
expect(enqueue).not.toHaveBeenCalled()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('reports an unresolvable rbacService as failed rather than throwing away the audit row', async () => {
|
|
280
|
+
const trigger = loadTriggerCommand()
|
|
281
|
+
const em = makeEm(makeSchedule({ targetType: 'command', targetQueue: null, targetCommand: 'scheduler.test.echo' }))
|
|
282
|
+
const ctx = makeCtx(tenantActor, em)
|
|
283
|
+
ctx.container.resolve = jest.fn((name: string) => {
|
|
284
|
+
if (name === 'rbacService') throw new Error('Could not resolve rbacService')
|
|
285
|
+
return em
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, ctx)
|
|
289
|
+
|
|
290
|
+
expect(result).toMatchObject({ outcome: 'failed', error: 'Could not resolve rbacService' })
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('leaves a queue schedule untouched by the scheduled-command gate', async () => {
|
|
294
|
+
userHasAllFeatures.mockResolvedValue(false)
|
|
295
|
+
const trigger = loadTriggerCommand()
|
|
296
|
+
const em = makeEm(makeSchedule())
|
|
297
|
+
|
|
298
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
|
|
299
|
+
|
|
300
|
+
expect(result.outcome).toBe('enqueued')
|
|
301
|
+
expect(userHasAllFeatures).not.toHaveBeenCalled()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('returns strategy_unsupported when the queue strategy is not async', async () => {
|
|
305
|
+
delete process.env.QUEUE_STRATEGY
|
|
306
|
+
const trigger = loadTriggerCommand()
|
|
307
|
+
const em = makeEm(makeSchedule())
|
|
308
|
+
|
|
309
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
|
|
310
|
+
|
|
311
|
+
expect(result).toMatchObject({ outcome: 'strategy_unsupported', queueJobId: null, error: null })
|
|
312
|
+
// Access was granted, so the schedule's details are safe to record.
|
|
313
|
+
expect(result.scheduleName).toBe('Nightly report')
|
|
314
|
+
expect(createQueue).not.toHaveBeenCalled()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('returns failed and still closes the queue when the enqueue rejects', async () => {
|
|
318
|
+
enqueue.mockRejectedValue(new Error('redis unavailable'))
|
|
319
|
+
const trigger = loadTriggerCommand()
|
|
320
|
+
const em = makeEm(makeSchedule())
|
|
321
|
+
|
|
322
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
|
|
323
|
+
|
|
324
|
+
expect(result).toMatchObject({ outcome: 'failed', queueJobId: null, error: 'redis unavailable' })
|
|
325
|
+
expect(close).toHaveBeenCalledTimes(1)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('does not fail a completed enqueue when closing the queue throws', async () => {
|
|
329
|
+
close.mockRejectedValue(new Error('close failed'))
|
|
330
|
+
const trigger = loadTriggerCommand()
|
|
331
|
+
const em = makeEm(makeSchedule())
|
|
332
|
+
|
|
333
|
+
const result = await trigger.execute({ id: SCHEDULE_ID }, makeCtx(tenantActor, em))
|
|
334
|
+
|
|
335
|
+
expect(result.outcome).toBe('enqueued')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
describe('buildLog', () => {
|
|
339
|
+
it('records a successful trigger against the actor', async () => {
|
|
340
|
+
const trigger = loadTriggerCommand()
|
|
341
|
+
const ctx = makeCtx({ ...tenantActor, orgId: 'org-a' }, makeEm(makeSchedule()))
|
|
342
|
+
const result = {
|
|
343
|
+
scheduleId: SCHEDULE_ID,
|
|
344
|
+
scheduleName: 'Nightly report',
|
|
345
|
+
targetType: 'queue',
|
|
346
|
+
target: 'reports',
|
|
347
|
+
outcome: 'enqueued',
|
|
348
|
+
queueJobId: 'queue-job-1',
|
|
349
|
+
error: null,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const log = await trigger.buildLog({ input: { id: SCHEDULE_ID }, result, ctx, snapshots: {} })
|
|
353
|
+
|
|
354
|
+
expect(log).toMatchObject({
|
|
355
|
+
actionLabel: 'Trigger schedule',
|
|
356
|
+
resourceKind: 'scheduler.job',
|
|
357
|
+
resourceId: SCHEDULE_ID,
|
|
358
|
+
tenantId: 'tenant-a',
|
|
359
|
+
organizationId: 'org-a',
|
|
360
|
+
})
|
|
361
|
+
expect(log.payload).toEqual({
|
|
362
|
+
scheduleName: 'Nightly report',
|
|
363
|
+
targetType: 'queue',
|
|
364
|
+
target: 'reports',
|
|
365
|
+
outcome: 'enqueued',
|
|
366
|
+
queueJobId: 'queue-job-1',
|
|
367
|
+
error: null,
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('records a refusal against the actor and discloses nothing about the row', async () => {
|
|
372
|
+
const trigger = loadTriggerCommand()
|
|
373
|
+
const ctx = makeCtx(tenantActor, makeEm(null))
|
|
374
|
+
const result = {
|
|
375
|
+
scheduleId: SCHEDULE_ID,
|
|
376
|
+
scheduleName: null,
|
|
377
|
+
targetType: null,
|
|
378
|
+
target: null,
|
|
379
|
+
outcome: 'not_found',
|
|
380
|
+
queueJobId: null,
|
|
381
|
+
error: null,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const log = await trigger.buildLog({ input: { id: SCHEDULE_ID }, result, ctx, snapshots: {} })
|
|
385
|
+
|
|
386
|
+
// The requested id is recorded so that probing is traceable...
|
|
387
|
+
expect(log.resourceId).toBe(SCHEDULE_ID)
|
|
388
|
+
// ...but the scope is the caller's, never the target's, and no detail of the
|
|
389
|
+
// row leaks into a trail the caller can read.
|
|
390
|
+
expect(log.tenantId).toBe('tenant-a')
|
|
391
|
+
expect(log.payload).toMatchObject({ outcome: 'not_found', scheduleName: null, target: null })
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('always returns metadata, so no outcome can silently skip the audit row', async () => {
|
|
395
|
+
const trigger = loadTriggerCommand()
|
|
396
|
+
const ctx = makeCtx(tenantActor, makeEm(null))
|
|
397
|
+
const outcomes = ['enqueued', 'not_found', 'forbidden', 'strategy_unsupported', 'failed']
|
|
398
|
+
|
|
399
|
+
for (const outcome of outcomes) {
|
|
400
|
+
const log = await trigger.buildLog({
|
|
401
|
+
input: { id: SCHEDULE_ID },
|
|
402
|
+
result: { scheduleId: SCHEDULE_ID, scheduleName: null, targetType: null, target: null, outcome, queueJobId: null, error: null },
|
|
403
|
+
ctx,
|
|
404
|
+
snapshots: {},
|
|
405
|
+
})
|
|
406
|
+
expect(log?.skipLog).toBeUndefined()
|
|
407
|
+
expect(log?.resourceKind).toBe('scheduler.job')
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
})
|
|
@@ -146,7 +146,7 @@ async function assertQueueTargetPayloadAllowed(queue: string, targetPayload: Rec
|
|
|
146
146
|
if (reason) throw new CrudHttpError(422, { error: `Invalid target payload for queue ${queue}: ${reason}` })
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
function resolveCommandActorUserId(ctx: CommandRuntimeContext): string | null {
|
|
149
|
+
export function resolveCommandActorUserId(ctx: CommandRuntimeContext): string | null {
|
|
150
150
|
const auth = ctx.auth
|
|
151
151
|
if (!auth) return null
|
|
152
152
|
if (typeof auth.userId === 'string' && auth.userId.trim().length > 0) return auth.userId.trim()
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Manual Schedule Trigger Command — Undo Policy
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// `scheduler.jobs.trigger` is registered with `isUndoable: false` and defines no
|
|
6
|
+
// `undo`/`redo`. It mutates no domain state itself: it enqueues an execution job,
|
|
7
|
+
// and a worker picks that job up as soon as it is free. By the time an operator
|
|
8
|
+
// could reach for Undo the run has already started, and whatever it did is
|
|
9
|
+
// reversed through the action-log entries the run itself writes — the target
|
|
10
|
+
// command's own `undo`, or the target queue's domain-level counter-action.
|
|
11
|
+
//
|
|
12
|
+
// Minting an undo token here would therefore offer to reverse an enqueue that no
|
|
13
|
+
// longer exists, while leaving the run's real effects untouched. No `undo`
|
|
14
|
+
// handler also means no `undoToken`, hence no `x-om-operation` header — which is
|
|
15
|
+
// what `TC-UNDO-001 §4` pins.
|
|
16
|
+
//
|
|
17
|
+
// The command exists for the audit trail: manual triggering is an
|
|
18
|
+
// authorised-looking way to make an automated job run at a moment of the actor's
|
|
19
|
+
// choosing, so every authenticated attempt — refusals included — must leave an
|
|
20
|
+
// `ActionLog` row naming the caller. The one exception is bus-wide rather than
|
|
21
|
+
// local: `CommandBus.execute` throws `CommandInterceptorError` before `buildLog`
|
|
22
|
+
// and `persistLog` run, so an attempt a `before` interceptor blocks is refused
|
|
23
|
+
// without a row, exactly as it is for `scheduler.jobs.create`/`.update`/`.delete`.
|
|
24
|
+
// =============================================================================
|
|
25
|
+
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
26
|
+
import type { CommandHandler } from '@open-mercato/shared/lib/commands'
|
|
27
|
+
import { createQueue } from '@open-mercato/queue'
|
|
28
|
+
import type { Queue } from '@open-mercato/queue'
|
|
29
|
+
import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
|
|
30
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
31
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
32
|
+
import type { EntityManager } from '@mikro-orm/core'
|
|
33
|
+
import { ScheduledJob } from '../data/entities.js'
|
|
34
|
+
import { resolveScheduleAccess } from '../lib/scheduleAccess.js'
|
|
35
|
+
import {
|
|
36
|
+
assertSchedulerSafeCommandAuthorized,
|
|
37
|
+
SchedulerCommandAuthorizationError,
|
|
38
|
+
type SchedulerCommandRbacService,
|
|
39
|
+
} from '../lib/scheduler-safe-commands.js'
|
|
40
|
+
import { resolveScheduledCommandActorUserId } from '../lib/commandContext.js'
|
|
41
|
+
import { resolveCommandActorUserId } from './jobs.js'
|
|
42
|
+
import type { ScheduleTriggerInput } from '../data/validators.js'
|
|
43
|
+
import type { ExecuteSchedulePayload } from '../workers/execute-schedule.worker.js'
|
|
44
|
+
|
|
45
|
+
const logger = createLogger('scheduler').child({ component: 'trigger' })
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Outcome of a manual trigger attempt.
|
|
49
|
+
*
|
|
50
|
+
* `execute()` returns this for every *expected* refusal instead of throwing.
|
|
51
|
+
* That is a hard requirement, not a style choice: `CommandBus.execute` has no
|
|
52
|
+
* try/catch, so a handler that throws skips `captureAfter`, `buildLog` and
|
|
53
|
+
* `persistLog` entirely and no `ActionLog` row is written — losing exactly the
|
|
54
|
+
* entries this command exists to create. The route maps `outcome` to HTTP.
|
|
55
|
+
*/
|
|
56
|
+
export type TriggerScheduleResult = {
|
|
57
|
+
scheduleId: string
|
|
58
|
+
scheduleName: string | null
|
|
59
|
+
targetType: 'queue' | 'command' | null
|
|
60
|
+
target: string | null
|
|
61
|
+
outcome: 'enqueued' | 'not_found' | 'forbidden' | 'strategy_unsupported' | 'failed'
|
|
62
|
+
queueJobId: string | null
|
|
63
|
+
error: string | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A refused attempt records the requested id and nothing else about the row.
|
|
68
|
+
*
|
|
69
|
+
* `resolveScheduleAccess` deliberately answers `not_found` rather than 403 for
|
|
70
|
+
* another tenant's schedule, so that a caller cannot confirm an id exists.
|
|
71
|
+
* Echoing that row's name or target into the audit log would hand back exactly
|
|
72
|
+
* the fact the decision withholds, so schedule details are populated only once
|
|
73
|
+
* access has been granted.
|
|
74
|
+
*/
|
|
75
|
+
function refusedTrigger(
|
|
76
|
+
scheduleId: string,
|
|
77
|
+
outcome: 'not_found' | 'forbidden',
|
|
78
|
+
): TriggerScheduleResult {
|
|
79
|
+
return {
|
|
80
|
+
scheduleId,
|
|
81
|
+
scheduleName: null,
|
|
82
|
+
targetType: null,
|
|
83
|
+
target: null,
|
|
84
|
+
outcome,
|
|
85
|
+
queueJobId: null,
|
|
86
|
+
error: null,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const triggerScheduleCommand: CommandHandler<ScheduleTriggerInput, TriggerScheduleResult> = {
|
|
91
|
+
id: 'scheduler.jobs.trigger',
|
|
92
|
+
isUndoable: false,
|
|
93
|
+
|
|
94
|
+
async execute(input, ctx) {
|
|
95
|
+
const em = ctx.container.resolve<EntityManager>('em')
|
|
96
|
+
|
|
97
|
+
// Load by id alone, then apply isolation to the loaded row. Folding the actor's
|
|
98
|
+
// tenant/org into the lookup would make every system-scoped schedule unmatchable.
|
|
99
|
+
const schedule = await em.findOne(ScheduledJob, { id: input.id, deletedAt: null })
|
|
100
|
+
if (!schedule) return refusedTrigger(input.id, 'not_found')
|
|
101
|
+
|
|
102
|
+
// Single source of truth for schedule visibility, shared with update, delete,
|
|
103
|
+
// list and executions. Its `not_found` vs `forbidden` split is deliberate.
|
|
104
|
+
const access = resolveScheduleAccess(schedule, ctx.auth)
|
|
105
|
+
if (access === 'not_found') return refusedTrigger(input.id, 'not_found')
|
|
106
|
+
if (access === 'forbidden') return refusedTrigger(input.id, 'forbidden')
|
|
107
|
+
|
|
108
|
+
const details = {
|
|
109
|
+
scheduleId: schedule.id,
|
|
110
|
+
scheduleName: schedule.name ?? null,
|
|
111
|
+
targetType: schedule.targetType ?? null,
|
|
112
|
+
target:
|
|
113
|
+
(schedule.targetType === 'command' ? schedule.targetCommand : schedule.targetQueue) ?? null,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'
|
|
117
|
+
if (queueStrategy !== 'async') {
|
|
118
|
+
return { ...details, outcome: 'strategy_unsupported', queueJobId: null, error: null }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// `resolveCommandActorUserId` resolves the bound user whenever the auth
|
|
122
|
+
// context carries one — including an API key issued on a user's behalf — and
|
|
123
|
+
// yields null only for a key-only context. That matters now that the worker
|
|
124
|
+
// authorizes and runs a manual command schedule as this identity: a bare key
|
|
125
|
+
// id is not a user id, so it falls back to the schedule's creator rather than
|
|
126
|
+
// failing an RBAC lookup that could never succeed.
|
|
127
|
+
const triggeredByUserId = resolveCommandActorUserId(ctx)
|
|
128
|
+
|
|
129
|
+
// Decide a command schedule's authorization here, where the audit row is
|
|
130
|
+
// written, rather than leaving it to the worker.
|
|
131
|
+
//
|
|
132
|
+
// The worker gates the run with the same assertion and throws when it refuses,
|
|
133
|
+
// and a throw inside a retrying queue is invisible: the caller has already been
|
|
134
|
+
// told the run was enqueued, the audit row already says `enqueued`, and BullMQ
|
|
135
|
+
// keeps retrying a refusal that no attempt can turn into a success. Answering
|
|
136
|
+
// `forbidden` now makes the refusal the caller's HTTP response and the audit
|
|
137
|
+
// row's outcome. The worker keeps its own gate as the authority — this decision
|
|
138
|
+
// can go stale between enqueue and execution — but the common case is decided
|
|
139
|
+
// where it can be seen.
|
|
140
|
+
//
|
|
141
|
+
// Unlike `refusedTrigger`, this refusal carries the schedule's details: access
|
|
142
|
+
// to the row has already been granted, so naming it discloses nothing new.
|
|
143
|
+
if (schedule.targetType === 'command') {
|
|
144
|
+
const actorUserId = resolveScheduledCommandActorUserId(schedule, { triggeredByUserId })
|
|
145
|
+
try {
|
|
146
|
+
await assertSchedulerSafeCommandAuthorized({
|
|
147
|
+
commandId: schedule.targetCommand,
|
|
148
|
+
actorUserId,
|
|
149
|
+
tenantId: schedule.tenantId,
|
|
150
|
+
organizationId: schedule.organizationId,
|
|
151
|
+
rbacService: ctx.container.resolve<SchedulerCommandRbacService>('rbacService'),
|
|
152
|
+
})
|
|
153
|
+
} catch (error) {
|
|
154
|
+
// Only an authorization decision is a refusal. `userHasAllFeatures` can
|
|
155
|
+
// also fail because its store is down, and answering `403` to an outage
|
|
156
|
+
// would tell the caller they lack access they may well hold, and file an
|
|
157
|
+
// audit row claiming a refusal that was never decided. Those become
|
|
158
|
+
// `failed`, the same outcome an unreachable queue produces — still audited,
|
|
159
|
+
// because a handler that throws writes no row at all.
|
|
160
|
+
const refused = error instanceof SchedulerCommandAuthorizationError
|
|
161
|
+
const message = error instanceof Error ? error.message : null
|
|
162
|
+
if (refused) {
|
|
163
|
+
logger.info('Manual trigger refused by scheduled-command authorization', {
|
|
164
|
+
scheduleId: schedule.id,
|
|
165
|
+
commandId: schedule.targetCommand,
|
|
166
|
+
reason: message,
|
|
167
|
+
})
|
|
168
|
+
} else {
|
|
169
|
+
logger.error('Scheduled-command authorization check failed', {
|
|
170
|
+
err: error,
|
|
171
|
+
scheduleId: schedule.id,
|
|
172
|
+
commandId: schedule.targetCommand,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
...details,
|
|
177
|
+
outcome: refused ? 'forbidden' : 'failed',
|
|
178
|
+
queueJobId: null,
|
|
179
|
+
error: message,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const payload: ExecuteSchedulePayload = {
|
|
185
|
+
scheduleId: schedule.id,
|
|
186
|
+
tenantId: schedule.tenantId,
|
|
187
|
+
organizationId: schedule.organizationId,
|
|
188
|
+
scopeType: schedule.scopeType,
|
|
189
|
+
triggerType: 'manual',
|
|
190
|
+
triggeredByUserId,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let executionQueue: Queue<ExecuteSchedulePayload> | null = null
|
|
194
|
+
try {
|
|
195
|
+
executionQueue = createQueue<ExecuteSchedulePayload>('scheduler-execution', queueStrategy, {
|
|
196
|
+
connection: { url: getRedisUrlOrThrow('QUEUE') },
|
|
197
|
+
})
|
|
198
|
+
const queueJobId = await executionQueue.enqueue(payload)
|
|
199
|
+
|
|
200
|
+
logger.info('Manually triggered schedule', {
|
|
201
|
+
scheduleId: schedule.id,
|
|
202
|
+
scheduleName: schedule.name,
|
|
203
|
+
jobId: queueJobId,
|
|
204
|
+
triggeredBy: ctx.auth?.sub ?? null,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
return { ...details, outcome: 'enqueued', queueJobId, error: null }
|
|
208
|
+
} catch (error) {
|
|
209
|
+
logger.error('Manual trigger failed', { err: error, scheduleId: schedule.id })
|
|
210
|
+
return {
|
|
211
|
+
...details,
|
|
212
|
+
outcome: 'failed',
|
|
213
|
+
queueJobId: null,
|
|
214
|
+
error: error instanceof Error ? error.message : null,
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
// Cleanup runs on the failure path too — the previous route-level code closed
|
|
218
|
+
// the queue only after a successful enqueue, leaking a Redis connection
|
|
219
|
+
// otherwise. A failure to close must not turn a completed enqueue into a
|
|
220
|
+
// failed request, so it is reported rather than thrown.
|
|
221
|
+
try {
|
|
222
|
+
await executionQueue?.close()
|
|
223
|
+
} catch (closeError) {
|
|
224
|
+
logger.warn('Failed to close scheduler execution queue', { err: closeError })
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
async buildLog({ result, ctx }) {
|
|
230
|
+
const { translate } = await resolveTranslations()
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
// Keep the label free of create/update/delete/assign verbs: the action-log
|
|
234
|
+
// projection derives `actionType` by string-matching it.
|
|
235
|
+
actionLabel: translate('scheduler.audit.trigger', 'Trigger schedule'),
|
|
236
|
+
resourceKind: 'scheduler.job',
|
|
237
|
+
// The requested id, so that probing for ids that do not exist is traceable.
|
|
238
|
+
resourceId: result.scheduleId,
|
|
239
|
+
// The actor's scope on every outcome, unlike the sibling create/update/delete
|
|
240
|
+
// handlers which log the schedule's. A refusal must never carry the target's
|
|
241
|
+
// tenant: that would leak cross-tenant existence into the caller's trail and,
|
|
242
|
+
// because the action-log reader filters organization_id by strict equality,
|
|
243
|
+
// file the row where the caller's own auditors could never read it.
|
|
244
|
+
tenantId: ctx.auth?.tenantId ?? null,
|
|
245
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
|
|
246
|
+
payload: {
|
|
247
|
+
scheduleName: result.scheduleName,
|
|
248
|
+
targetType: result.targetType,
|
|
249
|
+
target: result.target,
|
|
250
|
+
outcome: result.outcome,
|
|
251
|
+
queueJobId: result.queueJobId,
|
|
252
|
+
error: result.error,
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
registerCommand(triggerScheduleCommand)
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"scheduler.action.view": "Anzeigen",
|
|
8
8
|
"scheduler.audit.create": "Zeitplan erstellen",
|
|
9
9
|
"scheduler.audit.delete": "Zeitplan löschen",
|
|
10
|
+
"scheduler.audit.trigger": "Zeitplan ausführen",
|
|
10
11
|
"scheduler.audit.update": "Zeitplan aktualisieren",
|
|
11
12
|
"scheduler.confirm.delete": "Sind Sie sicher, dass Sie diesen Zeitplan löschen möchten?",
|
|
12
13
|
"scheduler.confirm.trigger": "Sind Sie sicher, dass Sie \"{name}\" jetzt ausführen möchten?\n\nDies wird {targetType} sofort ausführen.",
|