@open-mercato/scheduler 0.6.8-develop.7100.1.fbf66fca35 → 0.7.0
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-001.spec.js +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js +4 -9
- package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js +3 -3
- package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js +3 -3
- package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
- package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +44 -107
- package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js +3 -13
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
- package/dist/modules/scheduler/api/jobs/[id]/executions/route.js +13 -7
- package/dist/modules/scheduler/api/jobs/[id]/executions/route.js.map +2 -2
- package/dist/modules/scheduler/api/targets/route.js +12 -2
- package/dist/modules/scheduler/api/targets/route.js.map +2 -2
- package/dist/modules/scheduler/api/trigger/route.js +13 -7
- package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
- package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js +1 -3
- package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js.map +2 -2
- package/dist/modules/scheduler/cli.js +1 -32
- package/dist/modules/scheduler/cli.js.map +2 -2
- package/dist/modules/scheduler/commands/jobs.js +2 -84
- package/dist/modules/scheduler/commands/jobs.js.map +2 -2
- package/dist/modules/scheduler/data/validators.js +3 -27
- package/dist/modules/scheduler/data/validators.js.map +2 -2
- package/dist/modules/scheduler/i18n/de.json +0 -2
- package/dist/modules/scheduler/i18n/en.json +0 -2
- package/dist/modules/scheduler/i18n/es.json +0 -2
- package/dist/modules/scheduler/i18n/ko.json +0 -2
- package/dist/modules/scheduler/i18n/pl.json +0 -2
- package/dist/modules/scheduler/services/localSchedulerService.js +6 -31
- package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
- package/dist/modules/scheduler/workers/execute-schedule.worker.js +6 -64
- package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
- package/package.json +6 -7
- package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
- package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +8 -14
- package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
- package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
- package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +66 -154
- package/src/modules/scheduler/__integration__/helpers/scheduler.ts +5 -24
- package/src/modules/scheduler/api/jobs/[id]/executions/route.ts +19 -9
- package/src/modules/scheduler/api/targets/__tests__/route.test.ts +4 -3
- package/src/modules/scheduler/api/targets/route.ts +17 -6
- package/src/modules/scheduler/api/trigger/route.ts +19 -9
- package/src/modules/scheduler/backend/config/scheduled-jobs/page.tsx +1 -4
- package/src/modules/scheduler/cli.ts +1 -44
- package/src/modules/scheduler/commands/jobs.ts +3 -137
- package/src/modules/scheduler/data/__tests__/validators.test.ts +4 -26
- package/src/modules/scheduler/data/validators.ts +2 -37
- package/src/modules/scheduler/i18n/de.json +0 -2
- package/src/modules/scheduler/i18n/en.json +0 -2
- package/src/modules/scheduler/i18n/es.json +0 -2
- package/src/modules/scheduler/i18n/ko.json +0 -2
- package/src/modules/scheduler/i18n/pl.json +0 -2
- package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +0 -179
- package/src/modules/scheduler/services/localSchedulerService.ts +6 -39
- package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +3 -25
- package/src/modules/scheduler/workers/execute-schedule.worker.ts +7 -76
- package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +0 -72
- package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +0 -7
- package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js +0 -66
- package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js.map +0 -7
- package/dist/modules/scheduler/lib/safeQueueTargets.js +0 -157
- package/dist/modules/scheduler/lib/safeQueueTargets.js.map +0 -7
- package/dist/modules/scheduler/lib/scheduleAccess.js +0 -22
- package/dist/modules/scheduler/lib/scheduleAccess.js.map +0 -7
- package/dist/modules/scheduler/workers/test-echo-queue.worker.js +0 -22
- package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +0 -7
- package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +0 -90
- package/src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts +0 -94
- package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +0 -177
- package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +0 -110
- package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +0 -311
- package/src/modules/scheduler/lib/__tests__/scheduleAccess.test.ts +0 -164
- package/src/modules/scheduler/lib/safeQueueTargets.ts +0 -324
- package/src/modules/scheduler/lib/scheduleAccess.ts +0 -104
- package/src/modules/scheduler/workers/test-echo-queue.worker.ts +0 -25
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
clearSchedulerSafeCommandsForTests,
|
|
7
7
|
registerSchedulerSafeCommands,
|
|
8
8
|
} from '../../lib/scheduler-safe-commands'
|
|
9
|
-
import { registerModules } from '@open-mercato/shared/lib/modules/registry'
|
|
10
9
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
11
10
|
|
|
12
11
|
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
@@ -62,40 +61,6 @@ describe('LocalSchedulerService', () => {
|
|
|
62
61
|
let mockRbacService: { tenantHasFeature: jest.Mock; userHasAllFeatures: jest.Mock }
|
|
63
62
|
let mockLockStrategy: { runWithLock: jest.Mock }
|
|
64
63
|
|
|
65
|
-
beforeAll(() => {
|
|
66
|
-
// Queue-target dispatch reauthorizes against scheduler-safe queues (#5213);
|
|
67
|
-
// seed one safe queue and one module-owned internal queue for the guards.
|
|
68
|
-
registerModules([
|
|
69
|
-
{
|
|
70
|
-
id: 'test_module',
|
|
71
|
-
workers: [
|
|
72
|
-
{
|
|
73
|
-
id: 'test_module:workers:test-queue',
|
|
74
|
-
queue: 'test-queue',
|
|
75
|
-
concurrency: 1,
|
|
76
|
-
schedulerSafe: true,
|
|
77
|
-
handler: async () => {},
|
|
78
|
-
},
|
|
79
|
-
],
|
|
80
|
-
},
|
|
81
|
-
{
|
|
82
|
-
id: 'legacy_module',
|
|
83
|
-
workers: [
|
|
84
|
-
{
|
|
85
|
-
id: 'legacy_module:workers:internal',
|
|
86
|
-
queue: 'legacy-internal',
|
|
87
|
-
concurrency: 1,
|
|
88
|
-
handler: async () => {},
|
|
89
|
-
},
|
|
90
|
-
],
|
|
91
|
-
},
|
|
92
|
-
] as never)
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
afterAll(() => {
|
|
96
|
-
;(globalThis as Record<string, unknown>).__openMercatoModulesRegistry__ = null
|
|
97
|
-
})
|
|
98
|
-
|
|
99
64
|
beforeEach(() => {
|
|
100
65
|
jest.clearAllMocks()
|
|
101
66
|
jest.useFakeTimers()
|
|
@@ -331,151 +296,8 @@ describe('LocalSchedulerService', () => {
|
|
|
331
296
|
tenantId: null,
|
|
332
297
|
organizationId: null,
|
|
333
298
|
_idempotencyKey: expect.stringMatching(/^scheduler-test-1-/),
|
|
334
|
-
_jobOrigin: 'scheduler',
|
|
335
|
-
})
|
|
336
|
-
)
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
it('should sanitize author-supplied scope and envelope keys from the target payload', async () => {
|
|
340
|
-
const schedule = createSchedule({
|
|
341
|
-
tenantId: 'tenant-1',
|
|
342
|
-
organizationId: 'org-1',
|
|
343
|
-
sourceType: 'module',
|
|
344
|
-
sourceModule: 'test_module',
|
|
345
|
-
targetQueue: 'test-queue',
|
|
346
|
-
targetPayload: {
|
|
347
|
-
scope: { tenantId: 'forged-tenant', organizationId: 'forged-org' },
|
|
348
|
-
tenantId: 'forged-tenant',
|
|
349
|
-
_jobOrigin: 'inbound-webhook',
|
|
350
|
-
payload: { nested: 'data', _idempotencyKey: 'forged' },
|
|
351
|
-
legit: 'kept',
|
|
352
|
-
},
|
|
353
|
-
})
|
|
354
|
-
|
|
355
|
-
mockForkedEm.find.mockResolvedValue([schedule])
|
|
356
|
-
mockForkedEm.findOne.mockResolvedValue({ ...schedule })
|
|
357
|
-
mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
|
|
358
|
-
await fn()
|
|
359
|
-
return { acquired: true }
|
|
360
|
-
})
|
|
361
|
-
mockQueue.enqueue.mockResolvedValue(undefined as any)
|
|
362
|
-
|
|
363
|
-
await service.start()
|
|
364
|
-
|
|
365
|
-
expect(mockQueue.enqueue).toHaveBeenCalledWith(
|
|
366
|
-
expect.objectContaining({
|
|
367
|
-
tenantId: 'tenant-1',
|
|
368
|
-
organizationId: 'org-1',
|
|
369
|
-
payload: { nested: 'data' },
|
|
370
|
-
legit: 'kept',
|
|
371
|
-
scope: { tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
372
|
-
_jobOrigin: 'scheduler',
|
|
373
299
|
})
|
|
374
300
|
)
|
|
375
|
-
const enqueued = mockQueue.enqueue.mock.calls[0][0] as Record<string, unknown>
|
|
376
|
-
expect(enqueued.payload).toEqual({ nested: 'data' })
|
|
377
|
-
})
|
|
378
|
-
|
|
379
|
-
it('should refuse user-authored schedules targeting non-safe internal queues', async () => {
|
|
380
|
-
const schedule = createSchedule({
|
|
381
|
-
targetQueue: 'stripe-webhook',
|
|
382
|
-
sourceType: 'user',
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
mockForkedEm.find.mockResolvedValue([schedule])
|
|
386
|
-
mockForkedEm.findOne.mockResolvedValue({ ...schedule })
|
|
387
|
-
mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
|
|
388
|
-
await fn()
|
|
389
|
-
return { acquired: true }
|
|
390
|
-
})
|
|
391
|
-
|
|
392
|
-
await service.start()
|
|
393
|
-
|
|
394
|
-
expect(mockQueueFactory).not.toHaveBeenCalled()
|
|
395
|
-
expect(loggerError).toHaveBeenCalled()
|
|
396
|
-
expect(mockEmitSchedulerEvent).toHaveBeenCalledWith(
|
|
397
|
-
'scheduler.job.failed',
|
|
398
|
-
expect.objectContaining({
|
|
399
|
-
id: 'test-1',
|
|
400
|
-
error: expect.stringContaining('not an approved scheduler target'),
|
|
401
|
-
})
|
|
402
|
-
)
|
|
403
|
-
})
|
|
404
|
-
|
|
405
|
-
it('should refuse a pre-upgrade module row whose sourceModule MATCHES the owner but carries an actor stamp (#5213 B1)', async () => {
|
|
406
|
-
// forged pre-upgrade row: attacker set sourceType/sourceModule to the real
|
|
407
|
-
// owning module, but API writes always stamp createdByUserId — which
|
|
408
|
-
// schedulerService.register() never does. The row must fail closed.
|
|
409
|
-
const schedule = createSchedule({
|
|
410
|
-
targetQueue: 'legacy-internal',
|
|
411
|
-
sourceType: 'module',
|
|
412
|
-
sourceModule: 'legacy_module',
|
|
413
|
-
createdByUserId: 'attacker-user',
|
|
414
|
-
})
|
|
415
|
-
|
|
416
|
-
mockForkedEm.find.mockResolvedValue([schedule])
|
|
417
|
-
mockForkedEm.findOne.mockResolvedValue({ ...schedule })
|
|
418
|
-
mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
|
|
419
|
-
await fn()
|
|
420
|
-
return { acquired: true }
|
|
421
|
-
})
|
|
422
|
-
|
|
423
|
-
await service.start()
|
|
424
|
-
|
|
425
|
-
expect(mockQueueFactory).not.toHaveBeenCalled()
|
|
426
|
-
expect(mockEmitSchedulerEvent).toHaveBeenCalledWith(
|
|
427
|
-
'scheduler.job.failed',
|
|
428
|
-
expect.objectContaining({
|
|
429
|
-
id: 'test-1',
|
|
430
|
-
error: expect.stringContaining('not an approved scheduler target'),
|
|
431
|
-
})
|
|
432
|
-
)
|
|
433
|
-
})
|
|
434
|
-
|
|
435
|
-
it('should refuse a pre-upgrade module row whose sourceModule does not own its internal queue (#5213 B1)', async () => {
|
|
436
|
-
const schedule = createSchedule({
|
|
437
|
-
targetQueue: 'stripe-webhook',
|
|
438
|
-
sourceType: 'module',
|
|
439
|
-
sourceModule: 'legacy_module',
|
|
440
|
-
})
|
|
441
|
-
|
|
442
|
-
mockForkedEm.find.mockResolvedValue([schedule])
|
|
443
|
-
mockForkedEm.findOne.mockResolvedValue({ ...schedule })
|
|
444
|
-
mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
|
|
445
|
-
await fn()
|
|
446
|
-
return { acquired: true }
|
|
447
|
-
})
|
|
448
|
-
|
|
449
|
-
await service.start()
|
|
450
|
-
|
|
451
|
-
expect(mockQueueFactory).not.toHaveBeenCalled()
|
|
452
|
-
expect(mockEmitSchedulerEvent).toHaveBeenCalledWith(
|
|
453
|
-
'scheduler.job.failed',
|
|
454
|
-
expect.objectContaining({
|
|
455
|
-
id: 'test-1',
|
|
456
|
-
error: expect.stringContaining('not an approved scheduler target'),
|
|
457
|
-
})
|
|
458
|
-
)
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
it('should allow module-authored schedules to keep targeting queues their own module owns (#5213 B1)', async () => {
|
|
462
|
-
const schedule = createSchedule({
|
|
463
|
-
targetQueue: 'legacy-internal',
|
|
464
|
-
sourceType: 'module',
|
|
465
|
-
sourceModule: 'legacy_module',
|
|
466
|
-
})
|
|
467
|
-
|
|
468
|
-
mockForkedEm.find.mockResolvedValue([schedule])
|
|
469
|
-
mockForkedEm.findOne.mockResolvedValue({ ...schedule })
|
|
470
|
-
mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
|
|
471
|
-
await fn()
|
|
472
|
-
return { acquired: true }
|
|
473
|
-
})
|
|
474
|
-
mockQueue.enqueue.mockResolvedValue(undefined as any)
|
|
475
|
-
|
|
476
|
-
await service.start()
|
|
477
|
-
|
|
478
|
-
expect(mockQueue.enqueue).toHaveBeenCalled()
|
|
479
301
|
})
|
|
480
302
|
|
|
481
303
|
it('should execute command target', async () => {
|
|
@@ -848,7 +670,6 @@ describe('LocalSchedulerService', () => {
|
|
|
848
670
|
tenantId: schedule.tenantId,
|
|
849
671
|
organizationId: schedule.organizationId,
|
|
850
672
|
_idempotencyKey: expect.stringMatching(new RegExp(`^scheduler-${schedule.id}-`)),
|
|
851
|
-
_jobOrigin: 'scheduler',
|
|
852
673
|
})
|
|
853
674
|
)
|
|
854
675
|
const enqueued = mockQueue.enqueue.mock.calls[0][0] as Record<string, unknown>
|
|
@@ -11,12 +11,6 @@ import { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-comm
|
|
|
11
11
|
import { buildScheduledCommandContext } from '../lib/commandContext.js'
|
|
12
12
|
import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
|
|
13
13
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
14
|
-
import {
|
|
15
|
-
canDispatchScheduleQueueTarget,
|
|
16
|
-
getSchedulerQueueRequiredFeatures,
|
|
17
|
-
sanitizeSchedulerTargetPayload,
|
|
18
|
-
validateSchedulerTargetPayload,
|
|
19
|
-
} from '../lib/safeQueueTargets'
|
|
20
14
|
|
|
21
15
|
const logger = createLogger('scheduler').child({ component: 'local' })
|
|
22
16
|
|
|
@@ -267,45 +261,18 @@ export class LocalSchedulerService {
|
|
|
267
261
|
throw new Error('Target queue is required for queue target type')
|
|
268
262
|
}
|
|
269
263
|
|
|
270
|
-
// Dispatch-time reauthorization (#5213): provenance is verified against the
|
|
271
|
-
// live registry (module rows must own their queue), API rows must target
|
|
272
|
-
// opted-in queues.
|
|
273
|
-
if (!canDispatchScheduleQueueTarget(schedule)) {
|
|
274
|
-
throw new Error(`Queue is not an approved scheduler target: ${schedule.targetQueue}`)
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload)
|
|
278
|
-
if (payloadIssue) {
|
|
279
|
-
throw new Error(`Invalid payload for scheduler queue ${schedule.targetQueue}: ${payloadIssue}`)
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (schedule.scopeType !== 'system') {
|
|
283
|
-
for (const feature of getSchedulerQueueRequiredFeatures(schedule.targetQueue)) {
|
|
284
|
-
const hasFeature = await this.checkFeature({ ...schedule, requireFeature: feature } as ScheduledJob)
|
|
285
|
-
if (!hasFeature) {
|
|
286
|
-
throw new Error(`Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`)
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
264
|
const queue = this.queueFactory(schedule.targetQueue)
|
|
292
265
|
|
|
293
266
|
// Deliver the same flat contract as the asynchronous execute-schedule
|
|
294
267
|
// worker: targetPayload fields on the root, scheduler-owned scope and
|
|
295
268
|
// idempotency fields applied last. Local mode runs each firing exactly
|
|
296
269
|
// once, so the firing timestamp is a valid logical execution key.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
tenantId: schedule.tenantId,
|
|
304
|
-
organizationId: schedule.organizationId,
|
|
305
|
-
idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),
|
|
306
|
-
}),
|
|
307
|
-
_jobOrigin: 'scheduler' as const,
|
|
308
|
-
})
|
|
270
|
+
await queue.enqueue(buildQueueTargetPayload({
|
|
271
|
+
targetPayload: schedule.targetPayload,
|
|
272
|
+
tenantId: schedule.tenantId,
|
|
273
|
+
organizationId: schedule.organizationId,
|
|
274
|
+
idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),
|
|
275
|
+
}))
|
|
309
276
|
|
|
310
277
|
logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })
|
|
311
278
|
}
|
|
@@ -3,25 +3,8 @@ import { ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'
|
|
|
3
3
|
import { createQueue } from '@open-mercato/queue'
|
|
4
4
|
import { ScheduledJob } from '../../data/entities'
|
|
5
5
|
import { registerSchedulerSafeCommands } from '../../lib/scheduler-safe-commands'
|
|
6
|
-
import { registerModules } from '@open-mercato/shared/lib/modules/registry'
|
|
7
6
|
import executeScheduleWorker from '../execute-schedule.worker'
|
|
8
7
|
|
|
9
|
-
// Queue-target dispatch verifies module provenance against the live registry
|
|
10
|
-
// (#5213 B1): register the module that owns the fixture queue.
|
|
11
|
-
registerModules([
|
|
12
|
-
{
|
|
13
|
-
id: 'worker_test_module',
|
|
14
|
-
workers: [
|
|
15
|
-
{
|
|
16
|
-
id: 'worker_test_module:workers:example',
|
|
17
|
-
queue: 'example',
|
|
18
|
-
concurrency: 1,
|
|
19
|
-
handler: async () => {},
|
|
20
|
-
},
|
|
21
|
-
],
|
|
22
|
-
},
|
|
23
|
-
] as never)
|
|
24
|
-
|
|
25
8
|
const mockCommandExecute = jest.fn()
|
|
26
9
|
|
|
27
10
|
jest.mock('@open-mercato/shared/lib/commands', () => ({
|
|
@@ -144,11 +127,7 @@ describe('executeScheduleWorker queue target payload contract', () => {
|
|
|
144
127
|
targetType: 'queue',
|
|
145
128
|
targetQueue: 'example',
|
|
146
129
|
targetCommand: null,
|
|
147
|
-
|
|
148
|
-
// module registration never stamps an acting user (#5213 B1)
|
|
149
|
-
createdByUserId: null,
|
|
150
|
-
sourceModule: 'worker_test_module',
|
|
151
|
-
targetPayload: { connectionId: 'connection-id' },
|
|
130
|
+
targetPayload: { connectionId: 'connection-id', scope: 'organization' },
|
|
152
131
|
...overrides,
|
|
153
132
|
})
|
|
154
133
|
}
|
|
@@ -178,7 +157,7 @@ describe('executeScheduleWorker queue target payload contract', () => {
|
|
|
178
157
|
;(createQueue as jest.Mock).mockReturnValue({ enqueue, close })
|
|
179
158
|
})
|
|
180
159
|
|
|
181
|
-
it('delivers the flat targetPayload contract with scheduler-owned fields applied last
|
|
160
|
+
it('delivers the flat targetPayload contract with scheduler-owned fields applied last', async () => {
|
|
182
161
|
const schedule = buildQueueSchedule({
|
|
183
162
|
targetPayload: {
|
|
184
163
|
connectionId: 'connection-id',
|
|
@@ -192,12 +171,11 @@ describe('executeScheduleWorker queue target payload contract', () => {
|
|
|
192
171
|
|
|
193
172
|
expect(enqueue).toHaveBeenCalledWith({
|
|
194
173
|
connectionId: 'connection-id',
|
|
195
|
-
scope:
|
|
174
|
+
scope: 'organization',
|
|
196
175
|
payload: { nested: true },
|
|
197
176
|
tenantId: 'tenant-a',
|
|
198
177
|
organizationId: 'org-a',
|
|
199
178
|
_idempotencyKey: `scheduler-${scheduleId}-worker-job-1`,
|
|
200
|
-
_jobOrigin: 'scheduler',
|
|
201
179
|
})
|
|
202
180
|
expect(close).toHaveBeenCalledTimes(1)
|
|
203
181
|
})
|
|
@@ -10,12 +10,6 @@ import { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-comm
|
|
|
10
10
|
import { buildScheduledCommandContext } from '../lib/commandContext.js'
|
|
11
11
|
import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
|
|
12
12
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
13
|
-
import {
|
|
14
|
-
canDispatchScheduleQueueTarget,
|
|
15
|
-
getSchedulerQueueRequiredFeatures,
|
|
16
|
-
sanitizeSchedulerTargetPayload,
|
|
17
|
-
validateSchedulerTargetPayload,
|
|
18
|
-
} from '../lib/safeQueueTargets'
|
|
19
13
|
|
|
20
14
|
const logger = createLogger('scheduler').child({ component: 'worker' })
|
|
21
15
|
|
|
@@ -173,69 +167,12 @@ export default async function executeScheduleWorker(
|
|
|
173
167
|
|
|
174
168
|
// Enqueue target job or execute command
|
|
175
169
|
if (schedule.targetType === 'queue' && schedule.targetQueue) {
|
|
176
|
-
// Dispatch-time reauthorization (#5213): provenance is verified, never
|
|
177
|
-
// trusted — module-authored rows must still be owned by their recorded
|
|
178
|
-
// sourceModule and carry no acting-user stamp; API-authored rows may only
|
|
179
|
-
// target workers that opted into scheduling.
|
|
180
|
-
if (!canDispatchScheduleQueueTarget(schedule)) {
|
|
181
|
-
logger.error('Refusing non-safe queue target for schedule', {
|
|
182
|
-
scheduleId,
|
|
183
|
-
targetQueue: schedule.targetQueue,
|
|
184
|
-
sourceType: schedule.sourceType,
|
|
185
|
-
sourceModule: schedule.sourceType === 'module' ? schedule.sourceModule : undefined,
|
|
186
|
-
})
|
|
187
|
-
await emitSchedulerEvent('scheduler.job.skipped', {
|
|
188
|
-
id: schedule.id,
|
|
189
|
-
tenantId: schedule.tenantId,
|
|
190
|
-
organizationId: schedule.organizationId,
|
|
191
|
-
reason: `Queue is not an approved scheduler target: ${schedule.targetQueue}`,
|
|
192
|
-
})
|
|
193
|
-
return
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Re-validate the stored payload against the target's registered schema and
|
|
197
|
-
// the tenant-level features the target requires (#5213, defense in depth).
|
|
198
|
-
const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload)
|
|
199
|
-
if (payloadIssue) {
|
|
200
|
-
logger.error('Refusing schedule with invalid payload for queue target', {
|
|
201
|
-
scheduleId,
|
|
202
|
-
targetQueue: schedule.targetQueue,
|
|
203
|
-
issue: payloadIssue,
|
|
204
|
-
})
|
|
205
|
-
await emitSchedulerEvent('scheduler.job.skipped', {
|
|
206
|
-
id: schedule.id,
|
|
207
|
-
tenantId: schedule.tenantId,
|
|
208
|
-
organizationId: schedule.organizationId,
|
|
209
|
-
reason: `Invalid payload for scheduler queue ${schedule.targetQueue}`,
|
|
210
|
-
})
|
|
211
|
-
return
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const requiredQueueFeatures = getSchedulerQueueRequiredFeatures(schedule.targetQueue)
|
|
215
|
-
if (schedule.scopeType !== 'system' && requiredQueueFeatures.length > 0) {
|
|
216
|
-
for (const feature of requiredQueueFeatures) {
|
|
217
|
-
if (await rbacService.tenantHasFeature(schedule.tenantId, feature, { organizationId: schedule.organizationId })) continue
|
|
218
|
-
logger.error('Refusing schedule whose tenant lacks a required feature of the queue target', {
|
|
219
|
-
scheduleId,
|
|
220
|
-
targetQueue: schedule.targetQueue,
|
|
221
|
-
feature,
|
|
222
|
-
})
|
|
223
|
-
await emitSchedulerEvent('scheduler.job.skipped', {
|
|
224
|
-
id: schedule.id,
|
|
225
|
-
tenantId: schedule.tenantId,
|
|
226
|
-
organizationId: schedule.organizationId,
|
|
227
|
-
reason: `Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`,
|
|
228
|
-
})
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
170
|
// Determine queue strategy from environment
|
|
234
171
|
const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'
|
|
235
172
|
const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {
|
|
236
173
|
connection: { url: getRedisUrlOrThrow('QUEUE') },
|
|
237
174
|
})
|
|
238
|
-
|
|
175
|
+
|
|
239
176
|
let targetJobId: string | undefined
|
|
240
177
|
try {
|
|
241
178
|
// The execute-schedule job id is stable across BullMQ retries, so if
|
|
@@ -243,18 +180,12 @@ export default async function executeScheduleWorker(
|
|
|
243
180
|
// reuses the same idempotency key and downstream workers can dedupe.
|
|
244
181
|
const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())
|
|
245
182
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
tenantId: schedule.tenantId,
|
|
253
|
-
organizationId: schedule.organizationId,
|
|
254
|
-
idempotencyKey,
|
|
255
|
-
}),
|
|
256
|
-
_jobOrigin: 'scheduler' as const,
|
|
257
|
-
})
|
|
183
|
+
targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({
|
|
184
|
+
targetPayload: schedule.targetPayload,
|
|
185
|
+
tenantId: schedule.tenantId,
|
|
186
|
+
organizationId: schedule.organizationId,
|
|
187
|
+
idempotencyKey,
|
|
188
|
+
}))
|
|
258
189
|
} finally {
|
|
259
190
|
// Always close the queue instance to free Redis connections
|
|
260
191
|
await targetQueue.close()
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "@playwright/test";
|
|
2
|
-
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
-
import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
-
import {
|
|
5
|
-
SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,
|
|
6
|
-
SCHEDULER_JOBS_PATH,
|
|
7
|
-
SCHEDULER_TARGETS_PATH,
|
|
8
|
-
createScheduleJob,
|
|
9
|
-
deleteScheduleJob,
|
|
10
|
-
getScheduleJobById,
|
|
11
|
-
uniqueScheduleName
|
|
12
|
-
} from "./helpers/scheduler.js";
|
|
13
|
-
test.describe("TC-SCHED-008: scheduler queue targets restricted to safe workers", () => {
|
|
14
|
-
for (const forbiddenQueue of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
|
|
15
|
-
test(`rejects creating a schedule targeting the ${forbiddenQueue} worker`, async ({ request }) => {
|
|
16
|
-
const token = await getAuthToken(request, "admin");
|
|
17
|
-
const response = await apiRequest(request, "POST", SCHEDULER_JOBS_PATH, {
|
|
18
|
-
token,
|
|
19
|
-
data: {
|
|
20
|
-
name: uniqueScheduleName("Forbidden Queue Target"),
|
|
21
|
-
scopeType: "organization",
|
|
22
|
-
scheduleType: "interval",
|
|
23
|
-
scheduleValue: "15m",
|
|
24
|
-
timezone: "UTC",
|
|
25
|
-
targetType: "queue",
|
|
26
|
-
targetQueue: forbiddenQueue,
|
|
27
|
-
targetPayload: { scope: { tenantId: "forged", organizationId: "forged" } },
|
|
28
|
-
isEnabled: true,
|
|
29
|
-
sourceType: "user"
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
expect(response.status(), `${forbiddenQueue} must not be schedulable`).toBe(400);
|
|
33
|
-
const body = await readJsonSafe(response);
|
|
34
|
-
const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join("."));
|
|
35
|
-
expect(issuePaths).toContain("targetQueue");
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
test("rejects retargeting an existing schedule to an internal worker queue", async ({ request }) => {
|
|
39
|
-
const token = await getAuthToken(request, "admin");
|
|
40
|
-
let scheduleId = null;
|
|
41
|
-
try {
|
|
42
|
-
scheduleId = await createScheduleJob(request, token, { name: uniqueScheduleName("Retarget Guard") });
|
|
43
|
-
const response = await apiRequest(request, "PUT", SCHEDULER_JOBS_PATH, {
|
|
44
|
-
token,
|
|
45
|
-
data: {
|
|
46
|
-
id: scheduleId,
|
|
47
|
-
targetType: "queue",
|
|
48
|
-
targetQueue: "stripe-webhook"
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
expect(response.status()).toBe(422);
|
|
52
|
-
const body = await readJsonSafe(response);
|
|
53
|
-
expect(body?.error ?? "").toMatch(/not an approved scheduler target/i);
|
|
54
|
-
const unchanged = await getScheduleJobById(request, token, scheduleId);
|
|
55
|
-
expect(unchanged).not.toBeNull();
|
|
56
|
-
expect(unchanged.targetType).toBe("command");
|
|
57
|
-
} finally {
|
|
58
|
-
await deleteScheduleJob(request, token, scheduleId);
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
test("targets endpoint keeps internal worker queues undiscoverable", async ({ request }) => {
|
|
62
|
-
const token = await getAuthToken(request, "admin");
|
|
63
|
-
const response = await apiRequest(request, "GET", SCHEDULER_TARGETS_PATH, { token });
|
|
64
|
-
expect(response.status()).toBe(200);
|
|
65
|
-
const body = await readJsonSafe(response);
|
|
66
|
-
const values = (body?.queues ?? []).map((queue) => queue.value);
|
|
67
|
-
for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
|
|
68
|
-
expect(values).not.toContain(forbidden);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
//# sourceMappingURL=TC-SCHED-008-safe-targets.spec.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts"],
|
|
4
|
-
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,\n SCHEDULER_JOBS_PATH,\n SCHEDULER_TARGETS_PATH,\n createScheduleJob,\n deleteScheduleJob,\n getScheduleJobById,\n uniqueScheduleName,\n} from './helpers/scheduler'\n\n/**\n * TC-SCHED-008: Scheduler queue targets are restricted to authorized safe\n * workers (#5213). A principal holding only `scheduler.jobs.manage` must not be\n * able to aim a schedule at an internal/system worker queue with an arbitrary\n * payload \u2014 neither at creation, nor by retargeting later.\n */\ntest.describe('TC-SCHED-008: scheduler queue targets restricted to safe workers', () => {\n for (const forbiddenQueue of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {\n test(`rejects creating a schedule targeting the ${forbiddenQueue} worker`, async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, {\n token,\n data: {\n name: uniqueScheduleName('Forbidden Queue Target'),\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: forbiddenQueue,\n targetPayload: { scope: { tenantId: 'forged', organizationId: 'forged' } },\n isEnabled: true,\n sourceType: 'user',\n },\n })\n\n expect(response.status(), `${forbiddenQueue} must not be schedulable`).toBe(400)\n const body = await readJsonSafe<{ error?: string; details?: Array<{ path?: Array<string | number> }> }>(response)\n const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join('.'))\n expect(issuePaths).toContain('targetQueue')\n })\n }\n\n test('rejects retargeting an existing schedule to an internal worker queue', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n scheduleId = await createScheduleJob(request, token, { name: uniqueScheduleName('Retarget Guard') })\n\n const response = await apiRequest(request, 'PUT', SCHEDULER_JOBS_PATH, {\n token,\n data: {\n id: scheduleId,\n targetType: 'queue',\n targetQueue: 'stripe-webhook',\n },\n })\n\n // Enforcement lives in the update command (change-detection vs the stored\n // row), so an actual retarget lands as 422 with the approval error.\n expect(response.status()).toBe(422)\n const body = await readJsonSafe<{ error?: string }>(response)\n expect(body?.error ?? '').toMatch(/not an approved scheduler target/i)\n\n const unchanged = await getScheduleJobById(request, token, scheduleId)\n expect(unchanged).not.toBeNull()\n expect(unchanged!.targetType).toBe('command')\n } finally {\n await deleteScheduleJob(request, token, scheduleId)\n }\n })\n\n test('targets endpoint keeps internal worker queues undiscoverable', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })\n expect(response.status()).toBe(200)\n\n const body = await readJsonSafe<{ queues?: Array<{ value: string }> }>(response)\n const values = (body?.queues ?? []).map((queue) => queue.value)\n for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {\n expect(values).not.toContain(forbidden)\n }\n })\n})\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAQP,KAAK,SAAS,oEAAoE,MAAM;AACtF,aAAW,kBAAkB,qCAAqC;AAChE,SAAK,6CAA6C,cAAc,WAAW,OAAO,EAAE,QAAQ,MAAM;AAChG,YAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,YAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,qBAAqB;AAAA,QACtE;AAAA,QACA,MAAM;AAAA,UACJ,MAAM,mBAAmB,wBAAwB;AAAA,UACjD,WAAW;AAAA,UACX,cAAc;AAAA,UACd,eAAe;AAAA,UACf,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,aAAa;AAAA,UACb,eAAe,EAAE,OAAO,EAAE,UAAU,UAAU,gBAAgB,SAAS,EAAE;AAAA,UACzE,WAAW;AAAA,UACX,YAAY;AAAA,QACd;AAAA,MACF,CAAC;AAED,aAAO,SAAS,OAAO,GAAG,GAAG,cAAc,0BAA0B,EAAE,KAAK,GAAG;AAC/E,YAAM,OAAO,MAAM,aAAqF,QAAQ;AAChH,YAAM,cAAc,MAAM,WAAW,CAAC,GAAG,IAAI,CAAC,WAAW,MAAM,QAAQ,CAAC,GAAG,KAAK,GAAG,CAAC;AACpF,aAAO,UAAU,EAAE,UAAU,aAAa;AAAA,IAC5C,CAAC;AAAA,EACH;AAEA,OAAK,wEAAwE,OAAO,EAAE,QAAQ,MAAM;AAClG,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAEhC,QAAI;AACF,mBAAa,MAAM,kBAAkB,SAAS,OAAO,EAAE,MAAM,mBAAmB,gBAAgB,EAAE,CAAC;AAEnG,YAAM,WAAW,MAAM,WAAW,SAAS,OAAO,qBAAqB;AAAA,QACrE;AAAA,QACA,MAAM;AAAA,UACJ,IAAI;AAAA,UACJ,YAAY;AAAA,UACZ,aAAa;AAAA,QACf;AAAA,MACF,CAAC;AAID,aAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,YAAM,OAAO,MAAM,aAAiC,QAAQ;AAC5D,aAAO,MAAM,SAAS,EAAE,EAAE,QAAQ,mCAAmC;AAErE,YAAM,YAAY,MAAM,mBAAmB,SAAS,OAAO,UAAU;AACrE,aAAO,SAAS,EAAE,IAAI,SAAS;AAC/B,aAAO,UAAW,UAAU,EAAE,KAAK,SAAS;AAAA,IAC9C,UAAE;AACA,YAAM,kBAAkB,SAAS,OAAO,UAAU;AAAA,IACpD;AAAA,EACF,CAAC;AAED,OAAK,gEAAgE,OAAO,EAAE,QAAQ,MAAM;AAC1F,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,UAAM,WAAW,MAAM,WAAW,SAAS,OAAO,wBAAwB,EAAE,MAAM,CAAC;AACnF,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAElC,UAAM,OAAO,MAAM,aAAoD,QAAQ;AAC/E,UAAM,UAAU,MAAM,UAAU,CAAC,GAAG,IAAI,CAAC,UAAU,MAAM,KAAK;AAC9D,eAAW,aAAa,qCAAqC;AAC3D,aAAO,MAAM,EAAE,IAAI,UAAU,SAAS;AAAA,IACxC;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "@playwright/test";
|
|
2
|
-
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
-
import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
-
import {
|
|
5
|
-
SCHEDULER_JOBS_PATH,
|
|
6
|
-
SCHEDULER_TRIGGER_PATH,
|
|
7
|
-
createScheduleJob,
|
|
8
|
-
deleteScheduleJob,
|
|
9
|
-
uniqueScheduleName
|
|
10
|
-
} from "./helpers/scheduler.js";
|
|
11
|
-
const isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || "local") === "async";
|
|
12
|
-
test.describe("TC-SCHED-008: system-scoped schedule access on trigger and executions", () => {
|
|
13
|
-
test("super admin reaches the schedule on both routes; a non-super-admin is forbidden", async ({ request }) => {
|
|
14
|
-
const superToken = await getAuthToken(request, "superadmin");
|
|
15
|
-
const adminToken = await getAuthToken(request, "admin");
|
|
16
|
-
let scheduleId = null;
|
|
17
|
-
try {
|
|
18
|
-
scheduleId = await createScheduleJob(request, superToken, {
|
|
19
|
-
name: uniqueScheduleName("System-Trigger-Test"),
|
|
20
|
-
scopeType: "system",
|
|
21
|
-
scheduleType: "interval",
|
|
22
|
-
scheduleValue: "1h"
|
|
23
|
-
});
|
|
24
|
-
const superResponse = await apiRequest(request, "POST", SCHEDULER_TRIGGER_PATH, {
|
|
25
|
-
token: superToken,
|
|
26
|
-
data: { id: scheduleId }
|
|
27
|
-
});
|
|
28
|
-
const superBody = await readJsonSafe(superResponse);
|
|
29
|
-
expect(
|
|
30
|
-
superResponse.status(),
|
|
31
|
-
"a super admin must not be told a system-scoped schedule does not exist"
|
|
32
|
-
).not.toBe(404);
|
|
33
|
-
if (isAsyncQueueStrategy) {
|
|
34
|
-
expect(superResponse.status()).toBe(200);
|
|
35
|
-
expect(superBody?.ok).toBe(true);
|
|
36
|
-
expect(typeof superBody?.jobId === "string" && superBody.jobId.length > 0).toBe(true);
|
|
37
|
-
} else {
|
|
38
|
-
expect(superResponse.status()).toBe(400);
|
|
39
|
-
expect(superBody?.error ?? "").toMatch(/async/i);
|
|
40
|
-
}
|
|
41
|
-
const adminResponse = await apiRequest(request, "POST", SCHEDULER_TRIGGER_PATH, {
|
|
42
|
-
token: adminToken,
|
|
43
|
-
data: { id: scheduleId }
|
|
44
|
-
});
|
|
45
|
-
expect(
|
|
46
|
-
adminResponse.status(),
|
|
47
|
-
"a non-super-admin must be forbidden from triggering a system-scoped schedule"
|
|
48
|
-
).toBe(403);
|
|
49
|
-
const executionsPath = `${SCHEDULER_JOBS_PATH}/${scheduleId}/executions`;
|
|
50
|
-
const superExecutions = await apiRequest(request, "GET", executionsPath, { token: superToken });
|
|
51
|
-
expect(
|
|
52
|
-
superExecutions.status(),
|
|
53
|
-
"a super admin must not be told a system-scoped schedule has no execution history route"
|
|
54
|
-
).not.toBe(404);
|
|
55
|
-
expect(superExecutions.status()).toBe(isAsyncQueueStrategy ? 200 : 400);
|
|
56
|
-
const adminExecutions = await apiRequest(request, "GET", executionsPath, { token: adminToken });
|
|
57
|
-
expect(
|
|
58
|
-
adminExecutions.status(),
|
|
59
|
-
"a non-super-admin must be forbidden from reading a system-scoped execution history"
|
|
60
|
-
).toBe(403);
|
|
61
|
-
} finally {
|
|
62
|
-
await deleteScheduleJob(request, superToken, scheduleId);
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
//# sourceMappingURL=TC-SCHED-008.spec.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts"],
|
|
4
|
-
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n SCHEDULER_JOBS_PATH,\n SCHEDULER_TRIGGER_PATH,\n createScheduleJob,\n deleteScheduleJob,\n uniqueScheduleName,\n} from './helpers/scheduler'\n\nconst isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || 'local') === 'async'\n\ntype TriggerBody = { ok?: boolean; jobId?: string; error?: string; message?: string }\n\n/**\n * TC-SCHED-008: POST /api/scheduler/trigger and GET /api/scheduler/jobs/[id]/executions\n * resolve the scope of a system-scoped schedule on the loaded row, not in the lookup.\n *\n * A system-scoped schedule has `tenantId === null` and `organizationId === null`. When both\n * routes folded the caller's tenant/org into the `where` clause, no such row could ever match\n * and every caller \u2014 super admins included \u2014 got 404, which also masked each route's own\n * super-admin gate. A super admin must now reach the schedule, and a non-super-admin must be\n * told 403 rather than 404.\n *\n * The super-admin assertions hold under either queue strategy: each route's\n * `QUEUE_STRATEGY=async` check sits after the lookup, so `local` yields the pre-existing\n * 400 \u2014 never 404.\n */\ntest.describe('TC-SCHED-008: system-scoped schedule access on trigger and executions', () => {\n test('super admin reaches the schedule on both routes; a non-super-admin is forbidden', async ({ request }) => {\n const superToken = await getAuthToken(request, 'superadmin')\n const adminToken = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n // Creating a system-scoped schedule is itself super-admin only.\n scheduleId = await createScheduleJob(request, superToken, {\n name: uniqueScheduleName('System-Trigger-Test'),\n scopeType: 'system',\n scheduleType: 'interval',\n scheduleValue: '1h',\n })\n\n const superResponse = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {\n token: superToken,\n data: { id: scheduleId },\n })\n const superBody = await readJsonSafe<TriggerBody>(superResponse)\n expect(\n superResponse.status(),\n 'a super admin must not be told a system-scoped schedule does not exist',\n ).not.toBe(404)\n\n if (isAsyncQueueStrategy) {\n expect(superResponse.status()).toBe(200)\n expect(superBody?.ok).toBe(true)\n expect(typeof superBody?.jobId === 'string' && superBody.jobId.length > 0).toBe(true)\n } else {\n // The strategy check runs after the scope check, so this 400 is the response the\n // 404 used to mask.\n expect(superResponse.status()).toBe(400)\n expect(superBody?.error ?? '').toMatch(/async/i)\n }\n\n // The same schedule is visible enough to be refused, not hidden, from a tenant admin.\n const adminResponse = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {\n token: adminToken,\n data: { id: scheduleId },\n })\n expect(\n adminResponse.status(),\n 'a non-super-admin must be forbidden from triggering a system-scoped schedule',\n ).toBe(403)\n\n // The execution-history route carried the same lookup and needs the same guarantees.\n const executionsPath = `${SCHEDULER_JOBS_PATH}/${scheduleId}/executions`\n const superExecutions = await apiRequest(request, 'GET', executionsPath, { token: superToken })\n expect(\n superExecutions.status(),\n 'a super admin must not be told a system-scoped schedule has no execution history route',\n ).not.toBe(404)\n expect(superExecutions.status()).toBe(isAsyncQueueStrategy ? 200 : 400)\n\n const adminExecutions = await apiRequest(request, 'GET', executionsPath, { token: adminToken })\n expect(\n adminExecutions.status(),\n 'a non-super-admin must be forbidden from reading a system-scoped execution history',\n ).toBe(403)\n } finally {\n await deleteScheduleJob(request, superToken, scheduleId)\n }\n })\n})\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,MAAM,wBAAwB,QAAQ,IAAI,kBAAkB,aAAa;AAkBzE,KAAK,SAAS,yEAAyE,MAAM;AAC3F,OAAK,mFAAmF,OAAO,EAAE,QAAQ,MAAM;AAC7G,UAAM,aAAa,MAAM,aAAa,SAAS,YAAY;AAC3D,UAAM,aAAa,MAAM,aAAa,SAAS,OAAO;AACtD,QAAI,aAA4B;AAEhC,QAAI;AAEF,mBAAa,MAAM,kBAAkB,SAAS,YAAY;AAAA,QACxD,MAAM,mBAAmB,qBAAqB;AAAA,QAC9C,WAAW;AAAA,QACX,cAAc;AAAA,QACd,eAAe;AAAA,MACjB,CAAC;AAED,YAAM,gBAAgB,MAAM,WAAW,SAAS,QAAQ,wBAAwB;AAAA,QAC9E,OAAO;AAAA,QACP,MAAM,EAAE,IAAI,WAAW;AAAA,MACzB,CAAC;AACD,YAAM,YAAY,MAAM,aAA0B,aAAa;AAC/D;AAAA,QACE,cAAc,OAAO;AAAA,QACrB;AAAA,MACF,EAAE,IAAI,KAAK,GAAG;AAEd,UAAI,sBAAsB;AACxB,eAAO,cAAc,OAAO,CAAC,EAAE,KAAK,GAAG;AACvC,eAAO,WAAW,EAAE,EAAE,KAAK,IAAI;AAC/B,eAAO,OAAO,WAAW,UAAU,YAAY,UAAU,MAAM,SAAS,CAAC,EAAE,KAAK,IAAI;AAAA,MACtF,OAAO;AAGL,eAAO,cAAc,OAAO,CAAC,EAAE,KAAK,GAAG;AACvC,eAAO,WAAW,SAAS,EAAE,EAAE,QAAQ,QAAQ;AAAA,MACjD;AAGA,YAAM,gBAAgB,MAAM,WAAW,SAAS,QAAQ,wBAAwB;AAAA,QAC9E,OAAO;AAAA,QACP,MAAM,EAAE,IAAI,WAAW;AAAA,MACzB,CAAC;AACD;AAAA,QACE,cAAc,OAAO;AAAA,QACrB;AAAA,MACF,EAAE,KAAK,GAAG;AAGV,YAAM,iBAAiB,GAAG,mBAAmB,IAAI,UAAU;AAC3D,YAAM,kBAAkB,MAAM,WAAW,SAAS,OAAO,gBAAgB,EAAE,OAAO,WAAW,CAAC;AAC9F;AAAA,QACE,gBAAgB,OAAO;AAAA,QACvB;AAAA,MACF,EAAE,IAAI,KAAK,GAAG;AACd,aAAO,gBAAgB,OAAO,CAAC,EAAE,KAAK,uBAAuB,MAAM,GAAG;AAEtE,YAAM,kBAAkB,MAAM,WAAW,SAAS,OAAO,gBAAgB,EAAE,OAAO,WAAW,CAAC;AAC9F;AAAA,QACE,gBAAgB,OAAO;AAAA,QACvB;AAAA,MACF,EAAE,KAAK,GAAG;AAAA,IACZ,UAAE;AACA,YAAM,kBAAkB,SAAS,YAAY,UAAU;AAAA,IACzD;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|