@open-mercato/scheduler 0.6.8-develop.7066.1.6bfa9ff69f → 0.6.8-develop.7067.1.82e9867ea1
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 +9 -4
- 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-008-safe-targets.spec.js +72 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +7 -0
- 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 +107 -44
- package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js +13 -3
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
- package/dist/modules/scheduler/api/targets/route.js +2 -12
- package/dist/modules/scheduler/api/targets/route.js.map +2 -2
- package/dist/modules/scheduler/cli.js +32 -1
- package/dist/modules/scheduler/cli.js.map +2 -2
- package/dist/modules/scheduler/commands/jobs.js +84 -2
- package/dist/modules/scheduler/commands/jobs.js.map +2 -2
- package/dist/modules/scheduler/data/validators.js +27 -3
- package/dist/modules/scheduler/data/validators.js.map +2 -2
- package/dist/modules/scheduler/i18n/de.json +2 -0
- package/dist/modules/scheduler/i18n/en.json +2 -0
- package/dist/modules/scheduler/i18n/es.json +2 -0
- package/dist/modules/scheduler/i18n/ko.json +2 -0
- package/dist/modules/scheduler/i18n/pl.json +2 -0
- package/dist/modules/scheduler/lib/safeQueueTargets.js +157 -0
- package/dist/modules/scheduler/lib/safeQueueTargets.js.map +7 -0
- package/dist/modules/scheduler/services/localSchedulerService.js +31 -6
- package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
- package/dist/modules/scheduler/workers/execute-schedule.worker.js +64 -6
- package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
- package/dist/modules/scheduler/workers/test-echo-queue.worker.js +22 -0
- package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +7 -0
- package/package.json +5 -5
- package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
- package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +14 -8
- package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
- package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +90 -0
- 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 +154 -66
- package/src/modules/scheduler/__integration__/helpers/scheduler.ts +24 -5
- package/src/modules/scheduler/api/targets/__tests__/route.test.ts +3 -4
- package/src/modules/scheduler/api/targets/route.ts +6 -17
- package/src/modules/scheduler/cli.ts +44 -1
- package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +177 -0
- package/src/modules/scheduler/commands/jobs.ts +137 -3
- package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +110 -0
- package/src/modules/scheduler/data/__tests__/validators.test.ts +26 -4
- package/src/modules/scheduler/data/validators.ts +37 -2
- package/src/modules/scheduler/i18n/de.json +2 -0
- package/src/modules/scheduler/i18n/en.json +2 -0
- package/src/modules/scheduler/i18n/es.json +2 -0
- package/src/modules/scheduler/i18n/ko.json +2 -0
- package/src/modules/scheduler/i18n/pl.json +2 -0
- package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +311 -0
- package/src/modules/scheduler/lib/safeQueueTargets.ts +324 -0
- package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +179 -0
- package/src/modules/scheduler/services/localSchedulerService.ts +39 -6
- package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +25 -3
- package/src/modules/scheduler/workers/execute-schedule.worker.ts +76 -7
- package/src/modules/scheduler/workers/test-echo-queue.worker.ts +25 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/scheduler/workers/execute-schedule.worker.ts"],
|
|
4
|
-
"sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createQueue } from '@open-mercato/queue'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { emitSchedulerEvent } from '../events.js'\nimport { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-commands.js'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'worker' })\n\n// Worker metadata for auto-discovery\nexport const metadata: WorkerMeta = {\n queue: 'scheduler-execution',\n concurrency: 5, // Process up to 5 schedules concurrently\n}\n\nexport type ExecuteSchedulePayload = {\n scheduleId: string\n tenantId?: string | null\n organizationId?: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n triggerType?: 'scheduled' | 'manual'\n triggeredByUserId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\ntype RbacServiceLike = {\n tenantHasFeature(\n tenantId: string | null | undefined,\n feature: string,\n opts?: { organizationId?: string | null },\n ): Promise<boolean>\n userHasAllFeatures(\n userId: string,\n required: readonly string[],\n scope: { tenantId: string | null; organizationId: string | null },\n ): Promise<boolean>\n}\n\n/**\n * Worker that executes scheduled jobs.\n * \n * This worker is triggered by BullMQ repeatable jobs at the scheduled times.\n * It loads the fresh schedule configuration from the database, validates\n * conditions, and enqueues the target job or executes the command.\n * \n * BullMQ handles:\n * - Timing (exact cron/interval execution)\n * - Distributed locking (prevents duplicate execution)\n * - Retries (if worker fails)\n * - Execution history (via job state, logs, timestamps)\n * \n * This worker handles:\n * - Loading fresh schedule config\n * - Checking feature flags and conditions\n * - Enqueuing target job or executing command\n * - Updating last run time\n */\nexport default async function executeScheduleWorker(\n job: QueuedJob<ExecuteSchedulePayload>,\n ctx: JobContext & HandlerContext,\n): Promise<void> {\n logger.debug('Processing job', {\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n })\n \n // Defensive: handle both data and payload for BullMQ compatibility\n const payload = (job.payload || (job as unknown as { data?: ExecuteSchedulePayload }).data) as ExecuteSchedulePayload | undefined\n \n if (!payload || !payload.scheduleId) {\n logger.error('Invalid job payload: scheduleId missing', { jobId: ctx.jobId })\n throw new Error('scheduleId is required in job payload')\n }\n\n const { scheduleId } = payload\n\n const em = ctx.resolve<EntityManager>('em')\n const rbacService = ctx.resolve<RbacServiceLike>('rbacService')\n\n // Load fresh schedule from database\n const schedule = await em.findOne(ScheduledJob, { \n id: scheduleId,\n deletedAt: null,\n })\n\n if (!schedule) {\n logger.info('Schedule not found or deleted', { scheduleId })\n return\n }\n\n // CRITICAL: Verify scope integrity - ensure payload scope matches database\n // This prevents scope tampering and ensures proper multi-tenant isolation\n if (payload.scopeType !== schedule.scopeType) {\n logger.error('Scope type mismatch for schedule', {\n scheduleId,\n payloadScope: payload.scopeType,\n dbScope: schedule.scopeType,\n })\n throw new Error('Schedule scope type mismatch - potential security issue')\n }\n\n if (payload.tenantId !== schedule.tenantId) {\n logger.error('Tenant ID mismatch for schedule', {\n scheduleId,\n payloadTenant: payload.tenantId,\n dbTenant: schedule.tenantId,\n })\n throw new Error('Schedule tenant ID mismatch - potential security issue')\n }\n\n if (payload.organizationId !== schedule.organizationId) {\n logger.error('Organization ID mismatch for schedule', {\n scheduleId,\n payloadOrg: payload.organizationId,\n dbOrg: schedule.organizationId,\n })\n throw new Error('Schedule organization ID mismatch - potential security issue')\n }\n\n // Check if schedule is still enabled\n if (!schedule.isEnabled) {\n logger.debug('Schedule is disabled', { scheduleId })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: 'Schedule is disabled',\n })\n return\n }\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n attemptNumber: ctx.attemptNumber || 1,\n })\n\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n { organizationId: schedule.organizationId },\n )\n \n if (!hasFeature) {\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Feature not enabled: ${schedule.requireFeature}`,\n })\n\n logger.debug('Schedule skipped: feature not enabled', { scheduleId, requireFeature: schedule.requireFeature })\n return\n }\n }\n\n // Enqueue target job or execute command\n if (schedule.targetType === 'queue' && schedule.targetQueue) {\n // Determine queue strategy from environment\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n \n let targetJobId: string | undefined\n try {\n // The execute-schedule job id is stable across BullMQ retries, so if\n // this worker crashes between enqueue and DB flush the retried attempt\n // reuses the same idempotency key and downstream workers can dedupe.\n const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())\n\n targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({\n targetPayload: schedule.targetPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey,\n }))\n } finally {\n // Always close the queue instance to free Redis connections\n await targetQueue.close()\n }\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n queueJobId: targetJobId,\n queueName: schedule.targetQueue,\n })\n\n logger.debug('Successfully enqueued job', {\n scheduleId: schedule.id,\n targetQueue: schedule.targetQueue,\n queueJobId: targetJobId,\n })\n\n } else if (schedule.targetType === 'command' && schedule.targetCommand) {\n const commandBus = new CommandBus()\n const actorUserId = typeof schedule.createdByUserId === 'string' ? schedule.createdByUserId.trim() : ''\n await assertSchedulerSafeCommandAuthorized({\n commandId: schedule.targetCommand,\n actorUserId,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n rbacService,\n })\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n // Build the schedule-scoped command context after the allowlist/RBAC gate.\n const commandCtx = buildScheduledCommandContext(schedule, ctx as unknown as AppContainer)\n \n const commandResult = await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n \n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n commandId: schedule.targetCommand,\n commandResult: commandResult.result,\n })\n \n logger.debug('Successfully executed command', {\n scheduleId: schedule.id,\n commandId: schedule.targetCommand,\n })\n\n } else {\n throw new Error('Invalid target configuration')\n }\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,mBAAmB;AAC5B,SAAS,0BAA0B;AAEnC,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAE3B,SAAS,0BAA0B;AACnC,SAAS,4CAA4C;AACrD,SAAS,oCAAoC;AAC7C,SAAS,yBAAyB,oCAAoC;AACtE,SAAS,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createQueue } from '@open-mercato/queue'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { emitSchedulerEvent } from '../events.js'\nimport { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-commands.js'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n canDispatchScheduleQueueTarget,\n getSchedulerQueueRequiredFeatures,\n sanitizeSchedulerTargetPayload,\n validateSchedulerTargetPayload,\n} from '../lib/safeQueueTargets'\n\nconst logger = createLogger('scheduler').child({ component: 'worker' })\n\n// Worker metadata for auto-discovery\nexport const metadata: WorkerMeta = {\n queue: 'scheduler-execution',\n concurrency: 5, // Process up to 5 schedules concurrently\n}\n\nexport type ExecuteSchedulePayload = {\n scheduleId: string\n tenantId?: string | null\n organizationId?: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n triggerType?: 'scheduled' | 'manual'\n triggeredByUserId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\ntype RbacServiceLike = {\n tenantHasFeature(\n tenantId: string | null | undefined,\n feature: string,\n opts?: { organizationId?: string | null },\n ): Promise<boolean>\n userHasAllFeatures(\n userId: string,\n required: readonly string[],\n scope: { tenantId: string | null; organizationId: string | null },\n ): Promise<boolean>\n}\n\n/**\n * Worker that executes scheduled jobs.\n * \n * This worker is triggered by BullMQ repeatable jobs at the scheduled times.\n * It loads the fresh schedule configuration from the database, validates\n * conditions, and enqueues the target job or executes the command.\n * \n * BullMQ handles:\n * - Timing (exact cron/interval execution)\n * - Distributed locking (prevents duplicate execution)\n * - Retries (if worker fails)\n * - Execution history (via job state, logs, timestamps)\n * \n * This worker handles:\n * - Loading fresh schedule config\n * - Checking feature flags and conditions\n * - Enqueuing target job or executing command\n * - Updating last run time\n */\nexport default async function executeScheduleWorker(\n job: QueuedJob<ExecuteSchedulePayload>,\n ctx: JobContext & HandlerContext,\n): Promise<void> {\n logger.debug('Processing job', {\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n })\n \n // Defensive: handle both data and payload for BullMQ compatibility\n const payload = (job.payload || (job as unknown as { data?: ExecuteSchedulePayload }).data) as ExecuteSchedulePayload | undefined\n \n if (!payload || !payload.scheduleId) {\n logger.error('Invalid job payload: scheduleId missing', { jobId: ctx.jobId })\n throw new Error('scheduleId is required in job payload')\n }\n\n const { scheduleId } = payload\n\n const em = ctx.resolve<EntityManager>('em')\n const rbacService = ctx.resolve<RbacServiceLike>('rbacService')\n\n // Load fresh schedule from database\n const schedule = await em.findOne(ScheduledJob, { \n id: scheduleId,\n deletedAt: null,\n })\n\n if (!schedule) {\n logger.info('Schedule not found or deleted', { scheduleId })\n return\n }\n\n // CRITICAL: Verify scope integrity - ensure payload scope matches database\n // This prevents scope tampering and ensures proper multi-tenant isolation\n if (payload.scopeType !== schedule.scopeType) {\n logger.error('Scope type mismatch for schedule', {\n scheduleId,\n payloadScope: payload.scopeType,\n dbScope: schedule.scopeType,\n })\n throw new Error('Schedule scope type mismatch - potential security issue')\n }\n\n if (payload.tenantId !== schedule.tenantId) {\n logger.error('Tenant ID mismatch for schedule', {\n scheduleId,\n payloadTenant: payload.tenantId,\n dbTenant: schedule.tenantId,\n })\n throw new Error('Schedule tenant ID mismatch - potential security issue')\n }\n\n if (payload.organizationId !== schedule.organizationId) {\n logger.error('Organization ID mismatch for schedule', {\n scheduleId,\n payloadOrg: payload.organizationId,\n dbOrg: schedule.organizationId,\n })\n throw new Error('Schedule organization ID mismatch - potential security issue')\n }\n\n // Check if schedule is still enabled\n if (!schedule.isEnabled) {\n logger.debug('Schedule is disabled', { scheduleId })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: 'Schedule is disabled',\n })\n return\n }\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n attemptNumber: ctx.attemptNumber || 1,\n })\n\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n { organizationId: schedule.organizationId },\n )\n \n if (!hasFeature) {\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Feature not enabled: ${schedule.requireFeature}`,\n })\n\n logger.debug('Schedule skipped: feature not enabled', { scheduleId, requireFeature: schedule.requireFeature })\n return\n }\n }\n\n // Enqueue target job or execute command\n if (schedule.targetType === 'queue' && schedule.targetQueue) {\n // Dispatch-time reauthorization (#5213): provenance is verified, never\n // trusted \u2014 module-authored rows must still be owned by their recorded\n // sourceModule and carry no acting-user stamp; API-authored rows may only\n // target workers that opted into scheduling.\n if (!canDispatchScheduleQueueTarget(schedule)) {\n logger.error('Refusing non-safe queue target for schedule', {\n scheduleId,\n targetQueue: schedule.targetQueue,\n sourceType: schedule.sourceType,\n sourceModule: schedule.sourceType === 'module' ? schedule.sourceModule : undefined,\n })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Queue is not an approved scheduler target: ${schedule.targetQueue}`,\n })\n return\n }\n\n // Re-validate the stored payload against the target's registered schema and\n // the tenant-level features the target requires (#5213, defense in depth).\n const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload)\n if (payloadIssue) {\n logger.error('Refusing schedule with invalid payload for queue target', {\n scheduleId,\n targetQueue: schedule.targetQueue,\n issue: payloadIssue,\n })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Invalid payload for scheduler queue ${schedule.targetQueue}`,\n })\n return\n }\n\n const requiredQueueFeatures = getSchedulerQueueRequiredFeatures(schedule.targetQueue)\n if (schedule.scopeType !== 'system' && requiredQueueFeatures.length > 0) {\n for (const feature of requiredQueueFeatures) {\n if (await rbacService.tenantHasFeature(schedule.tenantId, feature, { organizationId: schedule.organizationId })) continue\n logger.error('Refusing schedule whose tenant lacks a required feature of the queue target', {\n scheduleId,\n targetQueue: schedule.targetQueue,\n feature,\n })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`,\n })\n return\n }\n }\n\n // Determine queue strategy from environment\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n\n let targetJobId: string | undefined\n try {\n // The execute-schedule job id is stable across BullMQ retries, so if\n // this worker crashes between enqueue and DB flush the retried attempt\n // reuses the same idempotency key and downstream workers can dedupe.\n const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())\n\n // Rebuild tenant/org authority context and the trusted dispatch origin\n // server-side; author-supplied scope/envelope keys never survive (#5213).\n const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule)\n targetJobId = await targetQueue.enqueue({\n ...buildQueueTargetPayload({\n targetPayload: sanitizedPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey,\n }),\n _jobOrigin: 'scheduler' as const,\n })\n } finally {\n // Always close the queue instance to free Redis connections\n await targetQueue.close()\n }\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n queueJobId: targetJobId,\n queueName: schedule.targetQueue,\n })\n\n logger.debug('Successfully enqueued job', {\n scheduleId: schedule.id,\n targetQueue: schedule.targetQueue,\n queueJobId: targetJobId,\n })\n\n } else if (schedule.targetType === 'command' && schedule.targetCommand) {\n const commandBus = new CommandBus()\n const actorUserId = typeof schedule.createdByUserId === 'string' ? schedule.createdByUserId.trim() : ''\n await assertSchedulerSafeCommandAuthorized({\n commandId: schedule.targetCommand,\n actorUserId,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n rbacService,\n })\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n // Build the schedule-scoped command context after the allowlist/RBAC gate.\n const commandCtx = buildScheduledCommandContext(schedule, ctx as unknown as AppContainer)\n \n const commandResult = await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n \n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n commandId: schedule.targetCommand,\n commandResult: commandResult.result,\n })\n \n logger.debug('Successfully executed command', {\n scheduleId: schedule.id,\n commandId: schedule.targetCommand,\n })\n\n } else {\n throw new Error('Invalid target configuration')\n }\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,mBAAmB;AAC5B,SAAS,0BAA0B;AAEnC,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAE3B,SAAS,0BAA0B;AACnC,SAAS,4CAA4C;AACrD,SAAS,oCAAoC;AAC7C,SAAS,yBAAyB,oCAAoC;AACtE,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,MAAM,SAAS,aAAa,WAAW,EAAE,MAAM,EAAE,WAAW,SAAS,CAAC;AAG/D,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,aAAa;AAAA;AACf;AA4CA,eAAO,sBACL,KACA,KACe;AACf,SAAO,MAAM,kBAAkB;AAAA,IAC7B,OAAO,IAAI;AAAA,IACX,eAAe,IAAI;AAAA,EACrB,CAAC;AAGD,QAAM,UAAW,IAAI,WAAY,IAAqD;AAEtF,MAAI,CAAC,WAAW,CAAC,QAAQ,YAAY;AACnC,WAAO,MAAM,2CAA2C,EAAE,OAAO,IAAI,MAAM,CAAC;AAC5E,UAAM,IAAI,MAAM,uCAAuC;AAAA,EACzD;AAEA,QAAM,EAAE,WAAW,IAAI;AAEvB,QAAM,KAAK,IAAI,QAAuB,IAAI;AAC1C,QAAM,cAAc,IAAI,QAAyB,aAAa;AAG9D,QAAM,WAAW,MAAM,GAAG,QAAQ,cAAc;AAAA,IAC9C,IAAI;AAAA,IACJ,WAAW;AAAA,EACb,CAAC;AAED,MAAI,CAAC,UAAU;AACb,WAAO,KAAK,iCAAiC,EAAE,WAAW,CAAC;AAC3D;AAAA,EACF;AAIA,MAAI,QAAQ,cAAc,SAAS,WAAW;AAC5C,WAAO,MAAM,oCAAoC;AAAA,MAC/C;AAAA,MACA,cAAc,QAAQ;AAAA,MACtB,SAAS,SAAS;AAAA,IACpB,CAAC;AACD,UAAM,IAAI,MAAM,yDAAyD;AAAA,EAC3E;AAEA,MAAI,QAAQ,aAAa,SAAS,UAAU;AAC1C,WAAO,MAAM,mCAAmC;AAAA,MAC9C;AAAA,MACA,eAAe,QAAQ;AAAA,MACvB,UAAU,SAAS;AAAA,IACrB,CAAC;AACD,UAAM,IAAI,MAAM,wDAAwD;AAAA,EAC1E;AAEA,MAAI,QAAQ,mBAAmB,SAAS,gBAAgB;AACtD,WAAO,MAAM,yCAAyC;AAAA,MACpD;AAAA,MACA,YAAY,QAAQ;AAAA,MACpB,OAAO,SAAS;AAAA,IAClB,CAAC;AACD,UAAM,IAAI,MAAM,8DAA8D;AAAA,EAChF;AAGA,MAAI,CAAC,SAAS,WAAW;AACvB,WAAO,MAAM,wBAAwB,EAAE,WAAW,CAAC;AACnD,UAAM,mBAAmB,yBAAyB;AAAA,MAChD,IAAI,SAAS;AAAA,MACb,UAAU,SAAS;AAAA,MACnB,gBAAgB,SAAS;AAAA,MACzB,QAAQ;AAAA,IACV,CAAC;AACD;AAAA,EACF;AAGA,QAAM,mBAAmB,yBAAyB;AAAA,IAChD,IAAI,SAAS;AAAA,IACb,UAAU,SAAS;AAAA,IACnB,gBAAgB,SAAS;AAAA,IACzB,cAAc,SAAS;AAAA,IACvB,eAAe,IAAI,iBAAiB;AAAA,EACtC,CAAC;AAGD,MAAI,SAAS,gBAAgB;AAC3B,UAAM,aAAa,MAAM,YAAY;AAAA,MACnC,SAAS;AAAA,MACT,SAAS;AAAA,MACT,EAAE,gBAAgB,SAAS,eAAe;AAAA,IAC5C;AAEA,QAAI,CAAC,YAAY;AACf,YAAM,mBAAmB,yBAAyB;AAAA,QAChD,IAAI,SAAS;AAAA,QACb,UAAU,SAAS;AAAA,QACnB,gBAAgB,SAAS;AAAA,QACzB,QAAQ,wBAAwB,SAAS,cAAc;AAAA,MACzD,CAAC;AAED,aAAO,MAAM,yCAAyC,EAAE,YAAY,gBAAgB,SAAS,eAAe,CAAC;AAC7G;AAAA,IACF;AAAA,EACF;AAGA,MAAI,SAAS,eAAe,WAAW,SAAS,aAAa;AAK3D,QAAI,CAAC,+BAA+B,QAAQ,GAAG;AAC7C,aAAO,MAAM,+CAA+C;AAAA,QAC1D;AAAA,QACA,aAAa,SAAS;AAAA,QACtB,YAAY,SAAS;AAAA,QACrB,cAAc,SAAS,eAAe,WAAW,SAAS,eAAe;AAAA,MAC3E,CAAC;AACD,YAAM,mBAAmB,yBAAyB;AAAA,QAChD,IAAI,SAAS;AAAA,QACb,UAAU,SAAS;AAAA,QACnB,gBAAgB,SAAS;AAAA,QACzB,QAAQ,8CAA8C,SAAS,WAAW;AAAA,MAC5E,CAAC;AACD;AAAA,IACF;AAIA,UAAM,eAAe,+BAA+B,SAAS,aAAa,SAAS,aAAa;AAChG,QAAI,cAAc;AAChB,aAAO,MAAM,2DAA2D;AAAA,QACtE;AAAA,QACA,aAAa,SAAS;AAAA,QACtB,OAAO;AAAA,MACT,CAAC;AACD,YAAM,mBAAmB,yBAAyB;AAAA,QAChD,IAAI,SAAS;AAAA,QACb,UAAU,SAAS;AAAA,QACnB,gBAAgB,SAAS;AAAA,QACzB,QAAQ,uCAAuC,SAAS,WAAW;AAAA,MACrE,CAAC;AACD;AAAA,IACF;AAEA,UAAM,wBAAwB,kCAAkC,SAAS,WAAW;AACpF,QAAI,SAAS,cAAc,YAAY,sBAAsB,SAAS,GAAG;AACvE,iBAAW,WAAW,uBAAuB;AAC3C,YAAI,MAAM,YAAY,iBAAiB,SAAS,UAAU,SAAS,EAAE,gBAAgB,SAAS,eAAe,CAAC,EAAG;AACjH,eAAO,MAAM,+EAA+E;AAAA,UAC1F;AAAA,UACA,aAAa,SAAS;AAAA,UACtB;AAAA,QACF,CAAC;AACD,cAAM,mBAAmB,yBAAyB;AAAA,UAChD,IAAI,SAAS;AAAA,UACb,UAAU,SAAS;AAAA,UACnB,gBAAgB,SAAS;AAAA,UACzB,QAAQ,oDAAoD,SAAS,WAAW,KAAK,OAAO;AAAA,QAC9F,CAAC;AACD;AAAA,MACF;AAAA,IACF;AAGA,UAAM,gBAAiB,QAAQ,IAAI,kBAAkB;AACrD,UAAM,cAAc,YAAY,SAAS,aAAa,eAAe;AAAA,MACnE,YAAY,EAAE,KAAK,mBAAmB,OAAO,EAAE;AAAA,IACjD,CAAC;AAED,QAAI;AACJ,QAAI;AAIF,YAAM,iBAAiB,6BAA6B,SAAS,IAAI,IAAI,SAAS,KAAK,IAAI,CAAC;AAIxF,YAAM,mBAAmB,+BAA+B,SAAS,eAAe,QAAQ;AACxF,oBAAc,MAAM,YAAY,QAAQ;AAAA,QACtC,GAAG,wBAAwB;AAAA,UACzB,eAAe;AAAA,UACf,UAAU,SAAS;AAAA,UACnB,gBAAgB,SAAS;AAAA,UACzB;AAAA,QACF,CAAC;AAAA,QACD,YAAY;AAAA,MACd,CAAC;AAAA,IACH,UAAE;AAEA,YAAM,YAAY,MAAM;AAAA,IAC1B;AAGA,aAAS,YAAY,oBAAI,KAAK;AAC9B,UAAM,GAAG,MAAM;AAEf,UAAM,mBAAmB,2BAA2B;AAAA,MAClD,IAAI,SAAS;AAAA,MACb,UAAU,SAAS;AAAA,MACnB,gBAAgB,SAAS;AAAA,MACzB,YAAY;AAAA,MACZ,WAAW,SAAS;AAAA,IACtB,CAAC;AAED,WAAO,MAAM,6BAA6B;AAAA,MACxC,YAAY,SAAS;AAAA,MACrB,aAAa,SAAS;AAAA,MACtB,YAAY;AAAA,IACd,CAAC;AAAA,EAEH,WAAW,SAAS,eAAe,aAAa,SAAS,eAAe;AACtE,UAAM,aAAa,IAAI,WAAW;AAClC,UAAM,cAAc,OAAO,SAAS,oBAAoB,WAAW,SAAS,gBAAgB,KAAK,IAAI;AACrG,UAAM,qCAAqC;AAAA,MACzC,WAAW,SAAS;AAAA,MACpB;AAAA,MACA,UAAU,SAAS;AAAA,MACnB,gBAAgB,SAAS;AAAA,MACzB;AAAA,IACF,CAAC;AAED,UAAM,eAAe;AAAA,MACnB,GAAK,SAAS,iBAA6C,CAAC;AAAA,MAC5D,UAAU,SAAS;AAAA,MACnB,gBAAgB,SAAS;AAAA,IAC3B;AAGA,UAAM,aAAa,6BAA6B,UAAU,GAA8B;AAExF,UAAM,gBAAgB,MAAM,WAAW,QAAQ,SAAS,eAAe;AAAA,MACrE,OAAO;AAAA,MACP,KAAK;AAAA,IACP,CAAC;AAGD,aAAS,YAAY,oBAAI,KAAK;AAC9B,UAAM,GAAG,MAAM;AAEf,UAAM,mBAAmB,2BAA2B;AAAA,MAClD,IAAI,SAAS;AAAA,MACb,UAAU,SAAS;AAAA,MACnB,gBAAgB,SAAS;AAAA,MACzB,WAAW,SAAS;AAAA,MACpB,eAAe,cAAc;AAAA,IAC/B,CAAC;AAED,WAAO,MAAM,iCAAiC;AAAA,MAC5C,YAAY,SAAS;AAAA,MACrB,WAAW,SAAS;AAAA,IACtB,CAAC;AAAA,EAEH,OAAO;AACL,UAAM,IAAI,MAAM,8BAA8B;AAAA,EAChD;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
|
+
const logger = createLogger("scheduler").child({ component: "test-echo-queue" });
|
|
3
|
+
const metadata = {
|
|
4
|
+
queue: "scheduler-test",
|
|
5
|
+
id: "scheduler:test-echo-queue",
|
|
6
|
+
concurrency: 1,
|
|
7
|
+
/**
|
|
8
|
+
* Deliberate scheduler-safe target for QA fixtures and manual experiments,
|
|
9
|
+
* mirroring the `scheduler.test.echo` command. The handler is side-effect
|
|
10
|
+
* free: it only logs that a delivery arrived.
|
|
11
|
+
*/
|
|
12
|
+
schedulerSafe: true
|
|
13
|
+
};
|
|
14
|
+
async function handle(job, _ctx) {
|
|
15
|
+
const message = typeof job.payload?.message === "string" ? job.payload.message : "";
|
|
16
|
+
logger.info("Test queue target received a scheduled delivery", { messageId: message.slice(0, 80) });
|
|
17
|
+
}
|
|
18
|
+
export {
|
|
19
|
+
handle as default,
|
|
20
|
+
metadata
|
|
21
|
+
};
|
|
22
|
+
//# sourceMappingURL=test-echo-queue.worker.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/scheduler/workers/test-echo-queue.worker.ts"],
|
|
4
|
+
"sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'test-echo-queue' })\n\nexport const metadata: WorkerMeta = {\n queue: 'scheduler-test',\n id: 'scheduler:test-echo-queue',\n concurrency: 1,\n /**\n * Deliberate scheduler-safe target for QA fixtures and manual experiments,\n * mirroring the `scheduler.test.echo` command. The handler is side-effect\n * free: it only logs that a delivery arrived.\n */\n schedulerSafe: true,\n}\n\ntype TestEchoPayload = {\n message?: unknown\n}\n\nexport default async function handle(job: QueuedJob<TestEchoPayload>, _ctx: JobContext): Promise<void> {\n const message = typeof job.payload?.message === 'string' ? job.payload.message : ''\n logger.info('Test queue target received a scheduled delivery', { messageId: message.slice(0, 80) })\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,WAAW,EAAE,MAAM,EAAE,WAAW,kBAAkB,CAAC;AAExE,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMb,eAAe;AACjB;AAMA,eAAO,OAA8B,KAAiC,MAAiC;AACrG,QAAM,UAAU,OAAO,IAAI,SAAS,YAAY,WAAW,IAAI,QAAQ,UAAU;AACjF,SAAO,KAAK,mDAAmD,EAAE,WAAW,QAAQ,MAAM,GAAG,EAAE,EAAE,CAAC;AACpG;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/scheduler",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7067.1.82e9867ea1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Database-managed scheduled jobs with admin UI",
|
|
6
6
|
"type": "module",
|
|
@@ -59,13 +59,13 @@
|
|
|
59
59
|
"./*/*/*/*/*/*.json": "./src/*/*/*/*/*/*.json"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@open-mercato/events": "0.6.8-develop.
|
|
63
|
-
"@open-mercato/queue": "0.6.8-develop.
|
|
62
|
+
"@open-mercato/events": "0.6.8-develop.7067.1.82e9867ea1",
|
|
63
|
+
"@open-mercato/queue": "0.6.8-develop.7067.1.82e9867ea1",
|
|
64
64
|
"cron-parser": "^5.6.2"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"@mikro-orm/core": "^7.0.14",
|
|
68
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
68
|
+
"@open-mercato/shared": "0.6.8-develop.7067.1.82e9867ea1",
|
|
69
69
|
"bullmq": "^5.0.0 || ^6.0.0",
|
|
70
70
|
"ioredis": "^5.0.0 || ^6.0.0",
|
|
71
71
|
"zod": ">=3.23.0"
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
82
|
+
"@open-mercato/shared": "0.6.8-develop.7067.1.82e9867ea1",
|
|
83
83
|
"@types/jest": "^30.0.0",
|
|
84
84
|
"@types/node": "^26.1.2",
|
|
85
85
|
"jest": "^30.4.2",
|
|
@@ -17,8 +17,8 @@ async function createSchedule(request: import('@playwright/test').APIRequestCont
|
|
|
17
17
|
scheduleType: 'interval',
|
|
18
18
|
scheduleValue: '15m',
|
|
19
19
|
timezone: 'UTC',
|
|
20
|
-
targetType: '
|
|
21
|
-
|
|
20
|
+
targetType: 'command',
|
|
21
|
+
targetCommand: 'scheduler.test.echo',
|
|
22
22
|
targetPayload: { source: 'integration-test' },
|
|
23
23
|
isEnabled: true,
|
|
24
24
|
sourceType: 'user',
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { expect, test } from '@playwright/test'
|
|
2
2
|
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
3
|
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,
|
|
6
|
+
SCHEDULER_TARGETS_PATH,
|
|
7
|
+
} from './helpers/scheduler'
|
|
5
8
|
|
|
6
9
|
type TargetOption = { value: string; label: string }
|
|
7
10
|
type TargetsResponse = { queues?: TargetOption[]; commands?: TargetOption[] }
|
|
@@ -21,12 +24,13 @@ function expectOptionShape(options: TargetOption[], label: string) {
|
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
|
-
* TC-SCHED-004: GET /api/scheduler/targets returns
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
+
* TC-SCHED-004: GET /api/scheduler/targets returns scheduler-safe queue names
|
|
28
|
+
* and registered command IDs (from the command registry), both sorted
|
|
29
|
+
* alphabetically by value. Internal/system workers must stay undiscoverable.
|
|
30
|
+
* Read-only, no fixtures.
|
|
27
31
|
*/
|
|
28
32
|
test.describe('TC-SCHED-004: GET /api/scheduler/targets lists queues and commands', () => {
|
|
29
|
-
test('returns
|
|
33
|
+
test('returns only scheduler-safe queues plus a non-empty sorted command list', async ({ request }) => {
|
|
30
34
|
const token = await getAuthToken(request, 'admin')
|
|
31
35
|
|
|
32
36
|
const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })
|
|
@@ -36,12 +40,14 @@ test.describe('TC-SCHED-004: GET /api/scheduler/targets lists queues and command
|
|
|
36
40
|
const queues = body?.queues ?? []
|
|
37
41
|
const commands = body?.commands ?? []
|
|
38
42
|
|
|
39
|
-
// Queues:
|
|
43
|
+
// Queues: well-shaped, sorted, and free of internal/system worker queues (#5213).
|
|
40
44
|
expect(Array.isArray(body?.queues)).toBe(true)
|
|
41
|
-
expect(queues.length).toBeGreaterThan(0)
|
|
42
45
|
expectOptionShape(queues, 'queues')
|
|
43
|
-
expect(queues.map((queue) => queue.value)).toContain(SCHEDULER_EXECUTION_QUEUE)
|
|
44
46
|
expectSortedByValue(queues, 'queues')
|
|
47
|
+
const queueValues = queues.map((queue) => queue.value)
|
|
48
|
+
for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
|
|
49
|
+
expect(queueValues, `internal queue ${forbidden} must not be a scheduler target`).not.toContain(forbidden)
|
|
50
|
+
}
|
|
45
51
|
|
|
46
52
|
// Commands: non-empty, well-shaped, and sorted.
|
|
47
53
|
expect(Array.isArray(body?.commands)).toBe(true)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, test } from '@playwright/test'
|
|
2
2
|
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
3
|
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
-
import {
|
|
4
|
+
import { SCHEDULER_JOBS_PATH, SCHEDULER_TEST_COMMAND, uniqueScheduleName } from './helpers/scheduler'
|
|
5
5
|
|
|
6
6
|
type ValidationError = { error?: string; details?: Array<{ path?: Array<string | number> }> }
|
|
7
7
|
|
|
@@ -36,8 +36,8 @@ test.describe('TC-SCHED-007: POST /api/scheduler/jobs validates schedule value f
|
|
|
36
36
|
scheduleType: testCase.scheduleType,
|
|
37
37
|
scheduleValue: testCase.scheduleValue,
|
|
38
38
|
timezone: 'UTC',
|
|
39
|
-
targetType: '
|
|
40
|
-
|
|
39
|
+
targetType: 'command',
|
|
40
|
+
targetCommand: SCHEDULER_TEST_COMMAND,
|
|
41
41
|
isEnabled: true,
|
|
42
42
|
sourceType: 'user',
|
|
43
43
|
},
|
|
@@ -0,0 +1,90 @@
|
|
|
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'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* TC-SCHED-008: Scheduler queue targets are restricted to authorized safe
|
|
16
|
+
* workers (#5213). A principal holding only `scheduler.jobs.manage` must not be
|
|
17
|
+
* able to aim a schedule at an internal/system worker queue with an arbitrary
|
|
18
|
+
* payload — neither at creation, nor by retargeting later.
|
|
19
|
+
*/
|
|
20
|
+
test.describe('TC-SCHED-008: scheduler queue targets restricted to safe workers', () => {
|
|
21
|
+
for (const forbiddenQueue of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
|
|
22
|
+
test(`rejects creating a schedule targeting the ${forbiddenQueue} worker`, async ({ request }) => {
|
|
23
|
+
const token = await getAuthToken(request, 'admin')
|
|
24
|
+
|
|
25
|
+
const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, {
|
|
26
|
+
token,
|
|
27
|
+
data: {
|
|
28
|
+
name: uniqueScheduleName('Forbidden Queue Target'),
|
|
29
|
+
scopeType: 'organization',
|
|
30
|
+
scheduleType: 'interval',
|
|
31
|
+
scheduleValue: '15m',
|
|
32
|
+
timezone: 'UTC',
|
|
33
|
+
targetType: 'queue',
|
|
34
|
+
targetQueue: forbiddenQueue,
|
|
35
|
+
targetPayload: { scope: { tenantId: 'forged', organizationId: 'forged' } },
|
|
36
|
+
isEnabled: true,
|
|
37
|
+
sourceType: 'user',
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(response.status(), `${forbiddenQueue} must not be schedulable`).toBe(400)
|
|
42
|
+
const body = await readJsonSafe<{ error?: string; details?: Array<{ path?: Array<string | number> }> }>(response)
|
|
43
|
+
const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join('.'))
|
|
44
|
+
expect(issuePaths).toContain('targetQueue')
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('rejects retargeting an existing schedule to an internal worker queue', async ({ request }) => {
|
|
49
|
+
const token = await getAuthToken(request, 'admin')
|
|
50
|
+
let scheduleId: string | null = null
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
scheduleId = await createScheduleJob(request, token, { name: uniqueScheduleName('Retarget Guard') })
|
|
54
|
+
|
|
55
|
+
const response = await apiRequest(request, 'PUT', SCHEDULER_JOBS_PATH, {
|
|
56
|
+
token,
|
|
57
|
+
data: {
|
|
58
|
+
id: scheduleId,
|
|
59
|
+
targetType: 'queue',
|
|
60
|
+
targetQueue: 'stripe-webhook',
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Enforcement lives in the update command (change-detection vs the stored
|
|
65
|
+
// row), so an actual retarget lands as 422 with the approval error.
|
|
66
|
+
expect(response.status()).toBe(422)
|
|
67
|
+
const body = await readJsonSafe<{ error?: string }>(response)
|
|
68
|
+
expect(body?.error ?? '').toMatch(/not an approved scheduler target/i)
|
|
69
|
+
|
|
70
|
+
const unchanged = await getScheduleJobById(request, token, scheduleId)
|
|
71
|
+
expect(unchanged).not.toBeNull()
|
|
72
|
+
expect(unchanged!.targetType).toBe('command')
|
|
73
|
+
} finally {
|
|
74
|
+
await deleteScheduleJob(request, token, scheduleId)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('targets endpoint keeps internal worker queues undiscoverable', async ({ request }) => {
|
|
79
|
+
const token = await getAuthToken(request, 'admin')
|
|
80
|
+
|
|
81
|
+
const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })
|
|
82
|
+
expect(response.status()).toBe(200)
|
|
83
|
+
|
|
84
|
+
const body = await readJsonSafe<{ queues?: Array<{ value: string }> }>(response)
|
|
85
|
+
const values = (body?.queues ?? []).map((queue) => queue.value)
|
|
86
|
+
for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
|
|
87
|
+
expect(values).not.toContain(forbidden)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -90,7 +90,7 @@ test.describe('TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, tar
|
|
|
90
90
|
scheduleValue: '15m',
|
|
91
91
|
timezone: 'UTC',
|
|
92
92
|
targetType: 'queue',
|
|
93
|
-
targetQueue: 'scheduler-
|
|
93
|
+
targetQueue: 'scheduler-test',
|
|
94
94
|
targetPayload: createPayload,
|
|
95
95
|
isEnabled: true,
|
|
96
96
|
sourceType: 'user',
|
|
@@ -105,7 +105,7 @@ test.describe('TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, tar
|
|
|
105
105
|
scheduleValue: '15m',
|
|
106
106
|
timezone: 'UTC',
|
|
107
107
|
targetType: 'queue',
|
|
108
|
-
targetQueue: 'scheduler-
|
|
108
|
+
targetQueue: 'scheduler-test',
|
|
109
109
|
targetCommand: null,
|
|
110
110
|
targetPayload: createPayload,
|
|
111
111
|
isEnabled: true,
|
|
@@ -133,7 +133,7 @@ test.describe('TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, tar
|
|
|
133
133
|
scheduleValue: '0 0 * * *',
|
|
134
134
|
timezone: 'Europe/Warsaw',
|
|
135
135
|
targetType: 'queue',
|
|
136
|
-
targetQueue: 'scheduler-
|
|
136
|
+
targetQueue: 'scheduler-test',
|
|
137
137
|
targetCommand: null,
|
|
138
138
|
targetPayload: updatePayload,
|
|
139
139
|
isEnabled: false,
|
|
@@ -1,86 +1,174 @@
|
|
|
1
|
-
import { expect, test } from '@playwright/test'
|
|
1
|
+
import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test'
|
|
2
2
|
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
-
import {
|
|
4
|
-
extractOperation,
|
|
5
|
-
runCrudUndoRoundTrip,
|
|
6
|
-
skipIfUndoTestsDisabled,
|
|
7
|
-
} from '@open-mercato/core/helpers/integration/undoHarness'
|
|
8
|
-
import {
|
|
9
|
-
SCHEDULER_JOBS_PATH,
|
|
10
|
-
SCHEDULER_TRIGGER_PATH,
|
|
11
|
-
createScheduleJob,
|
|
12
|
-
deleteScheduleJob,
|
|
13
|
-
} from './helpers/scheduler'
|
|
14
3
|
|
|
15
4
|
/**
|
|
16
|
-
* TC-UNDO-001
|
|
5
|
+
* TC-UNDO-001: scheduler.jobs undo round-trip (regression for issue #2504).
|
|
17
6
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
7
|
+
* Before the fix, every scheduler.jobs undo handler read `logEntry.payload`
|
|
8
|
+
* (always undefined — the command bus persists the undo snapshot under
|
|
9
|
+
* `commandPayload`), so undo returned `{ ok: true }` while silently changing
|
|
10
|
+
* nothing. This spec exercises the real HTTP path end-to-end:
|
|
11
|
+
* - CREATE -> undo => job is soft-deleted (no longer listed)
|
|
12
|
+
* - UPDATE -> undo => renamed field is restored to its prior value
|
|
13
|
+
* - DELETE -> undo => soft-deleted job is re-materialized
|
|
24
14
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* -
|
|
29
|
-
* -
|
|
30
|
-
* -
|
|
31
|
-
* - I5 a consumed undo token is rejected on a second undo
|
|
32
|
-
* - I6 redo re-applies the command's after-snapshot
|
|
33
|
-
*
|
|
34
|
-
* I4 (custom fields) is N/A — `scheduled_job` declares no `ce.ts`, so it has no `cf_*`.
|
|
15
|
+
* Endpoints covered:
|
|
16
|
+
* - POST /api/scheduler/jobs (create)
|
|
17
|
+
* - GET /api/scheduler/jobs?id= (read)
|
|
18
|
+
* - PUT /api/scheduler/jobs (update)
|
|
19
|
+
* - DELETE /api/scheduler/jobs (delete, cleanup)
|
|
20
|
+
* - POST /api/audit_logs/audit-logs/actions/undo (undo)
|
|
35
21
|
*/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
22
|
+
|
|
23
|
+
type ScheduleRow = {
|
|
24
|
+
id: string
|
|
25
|
+
name: string
|
|
26
|
+
description: string | null
|
|
27
|
+
scheduleValue: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The undo endpoint only honors the *latest* undoable log for a resource, and
|
|
31
|
+
// `latestUndoableForResource` orders by `created_at` alone. Audit `created_at` is
|
|
32
|
+
// millisecond-precision (`new Date()` at log time), so a create + a follow-up
|
|
33
|
+
// mutation issued within the same millisecond tie and the lookup may resolve to
|
|
34
|
+
// the wrong log. A short settle guarantees the operation under undo is strictly
|
|
35
|
+
// the most recent, keeping the round-trip deterministic.
|
|
36
|
+
async function settleAuditClock(): Promise<void> {
|
|
37
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readUndoToken(res: APIResponse): string {
|
|
41
|
+
const header = res.headers()['x-om-operation'] ?? ''
|
|
42
|
+
const enc = header.startsWith('omop:') ? header.slice(5) : ''
|
|
43
|
+
expect(enc, 'x-om-operation header should carry an omop: payload').not.toBe('')
|
|
44
|
+
const payload = JSON.parse(decodeURIComponent(enc)) as { undoToken?: string }
|
|
45
|
+
expect(typeof payload.undoToken, 'undoToken present in operation payload').toBe('string')
|
|
46
|
+
return payload.undoToken as string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getById(
|
|
50
|
+
request: APIRequestContext,
|
|
51
|
+
token: string,
|
|
52
|
+
id: string,
|
|
53
|
+
): Promise<ScheduleRow | undefined> {
|
|
54
|
+
const res = await apiRequest(request, 'GET', `/api/scheduler/jobs?id=${encodeURIComponent(id)}`, { token })
|
|
55
|
+
expect(res.status()).toBe(200)
|
|
56
|
+
const body = (await res.json()) as { items?: ScheduleRow[] }
|
|
57
|
+
return (body.items ?? [])[0]
|
|
49
58
|
}
|
|
50
59
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
async function createJob(request: APIRequestContext, token: string, name: string): Promise<{ id: string; res: APIResponse }> {
|
|
61
|
+
const res = await apiRequest(request, 'POST', '/api/scheduler/jobs', {
|
|
62
|
+
token,
|
|
63
|
+
data: {
|
|
64
|
+
name,
|
|
65
|
+
description: 'TC-UNDO-001 probe',
|
|
66
|
+
scopeType: 'tenant',
|
|
67
|
+
scheduleType: 'cron',
|
|
68
|
+
scheduleValue: '0 0 * * *',
|
|
69
|
+
timezone: 'UTC',
|
|
70
|
+
targetType: 'command',
|
|
71
|
+
targetCommand: 'scheduler.test.echo',
|
|
72
|
+
isEnabled: true,
|
|
73
|
+
sourceType: 'user',
|
|
74
|
+
},
|
|
54
75
|
})
|
|
76
|
+
expect(res.status(), 'create returns 201').toBe(201)
|
|
77
|
+
const id = ((await res.json()) as { id: string }).id
|
|
78
|
+
expect(id, 'create returns an id').toBeTruthy()
|
|
79
|
+
return { id, res }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function undo(request: APIRequestContext, token: string, undoToken: string): Promise<void> {
|
|
83
|
+
const res = await apiRequest(request, 'POST', '/api/audit_logs/audit-logs/actions/undo', {
|
|
84
|
+
token,
|
|
85
|
+
data: { undoToken },
|
|
86
|
+
})
|
|
87
|
+
const raw = await res.text()
|
|
88
|
+
expect(res.status(), `undo returns 200 (body: ${raw})`).toBe(200)
|
|
89
|
+
const body = JSON.parse(raw) as { ok?: boolean }
|
|
90
|
+
expect(body.ok, 'undo body is { ok: true }').toBe(true)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function cleanup(request: APIRequestContext, token: string | null, id: string | null): Promise<void> {
|
|
94
|
+
if (!token || !id) return
|
|
95
|
+
try {
|
|
96
|
+
await apiRequest(request, 'DELETE', '/api/scheduler/jobs', { token, data: { id } })
|
|
97
|
+
} catch {
|
|
98
|
+
/* ignore */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test.describe('TC-UNDO-001: scheduler.jobs undo actually restores state (#2504)', () => {
|
|
103
|
+
test('UPDATE -> undo restores the prior name', async ({ request }) => {
|
|
104
|
+
let token: string | null = null
|
|
105
|
+
let id: string | null = null
|
|
106
|
+
try {
|
|
107
|
+
token = await getAuthToken(request, 'admin')
|
|
108
|
+
const originalName = `TC-UNDO-001 Update ${Date.now()}`
|
|
109
|
+
;({ id } = await createJob(request, token, originalName))
|
|
110
|
+
await settleAuditClock()
|
|
55
111
|
|
|
56
|
-
|
|
57
|
-
|
|
112
|
+
const updateRes = await apiRequest(request, 'PUT', '/api/scheduler/jobs', {
|
|
113
|
+
token,
|
|
114
|
+
data: { id, name: `${originalName} RENAMED` },
|
|
115
|
+
})
|
|
116
|
+
expect(updateRes.status(), 'update returns 200').toBe(200)
|
|
117
|
+
expect((await getById(request, token, id))?.name).toBe(`${originalName} RENAMED`)
|
|
58
118
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
119
|
+
await undo(request, token, readUndoToken(updateRes))
|
|
120
|
+
|
|
121
|
+
const restored = await getById(request, token, id)
|
|
122
|
+
expect(restored, 'job still exists after update-undo').toBeTruthy()
|
|
123
|
+
expect(restored!.name, 'name restored to original by undo').toBe(originalName)
|
|
124
|
+
} finally {
|
|
125
|
+
await cleanup(request, token, id)
|
|
126
|
+
}
|
|
66
127
|
})
|
|
67
128
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// issues an `x-om-operation` undo token.
|
|
72
|
-
test('§4 trigger exposes no undo token', async ({ request }) => {
|
|
73
|
-
const token = await getAuthToken(request, 'admin')
|
|
74
|
-
let jobId: string | null = null
|
|
129
|
+
test('DELETE -> undo re-lists the soft-deleted job', async ({ request }) => {
|
|
130
|
+
let token: string | null = null
|
|
131
|
+
let id: string | null = null
|
|
75
132
|
try {
|
|
76
|
-
|
|
77
|
-
const
|
|
133
|
+
token = await getAuthToken(request, 'admin')
|
|
134
|
+
const name = `TC-UNDO-001 Delete ${Date.now()}`
|
|
135
|
+
;({ id } = await createJob(request, token, name))
|
|
136
|
+
await settleAuditClock()
|
|
137
|
+
|
|
138
|
+
const deleteRes = await apiRequest(request, 'DELETE', '/api/scheduler/jobs', {
|
|
78
139
|
token,
|
|
79
|
-
data: { id
|
|
140
|
+
data: { id },
|
|
80
141
|
})
|
|
81
|
-
expect(
|
|
142
|
+
expect(deleteRes.status(), 'delete returns 200').toBe(200)
|
|
143
|
+
expect(await getById(request, token, id), 'job is gone after delete').toBeFalsy()
|
|
144
|
+
|
|
145
|
+
await undo(request, token, readUndoToken(deleteRes))
|
|
146
|
+
|
|
147
|
+
const restored = await getById(request, token, id)
|
|
148
|
+
expect(restored, 'job re-materialized by delete-undo').toBeTruthy()
|
|
149
|
+
expect(restored!.name).toBe(name)
|
|
150
|
+
} finally {
|
|
151
|
+
await cleanup(request, token, id)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('CREATE -> undo removes the created job', async ({ request }) => {
|
|
156
|
+
let token: string | null = null
|
|
157
|
+
let id: string | null = null
|
|
158
|
+
try {
|
|
159
|
+
token = await getAuthToken(request, 'admin')
|
|
160
|
+
const name = `TC-UNDO-001 Create ${Date.now()}`
|
|
161
|
+
const created = await createJob(request, token, name)
|
|
162
|
+
id = created.id
|
|
163
|
+
|
|
164
|
+
expect(await getById(request, token, id), 'job exists after create').toBeTruthy()
|
|
165
|
+
|
|
166
|
+
await undo(request, token, readUndoToken(created.res))
|
|
167
|
+
|
|
168
|
+
expect(await getById(request, token, id), 'job removed by create-undo').toBeFalsy()
|
|
169
|
+
id = null // already undone; nothing to clean up
|
|
82
170
|
} finally {
|
|
83
|
-
await
|
|
171
|
+
await cleanup(request, token, id)
|
|
84
172
|
}
|
|
85
173
|
})
|
|
86
174
|
})
|