@open-mercato/scheduler 0.7.0 → 0.7.1-develop.7103.1.41ff100d93

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.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js +2 -2
  3. package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js.map +2 -2
  4. package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js +9 -4
  5. package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js.map +2 -2
  6. package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js +3 -3
  7. package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js.map +2 -2
  8. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +72 -0
  9. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +7 -0
  10. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js +66 -0
  11. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js.map +7 -0
  12. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js +3 -3
  13. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
  14. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +107 -44
  15. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
  16. package/dist/modules/scheduler/__integration__/helpers/scheduler.js +13 -3
  17. package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
  18. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js +7 -13
  19. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js.map +2 -2
  20. package/dist/modules/scheduler/api/targets/route.js +2 -12
  21. package/dist/modules/scheduler/api/targets/route.js.map +2 -2
  22. package/dist/modules/scheduler/api/trigger/route.js +7 -13
  23. package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
  24. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js +3 -1
  25. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js.map +2 -2
  26. package/dist/modules/scheduler/cli.js +32 -1
  27. package/dist/modules/scheduler/cli.js.map +2 -2
  28. package/dist/modules/scheduler/commands/jobs.js +84 -2
  29. package/dist/modules/scheduler/commands/jobs.js.map +2 -2
  30. package/dist/modules/scheduler/data/validators.js +27 -3
  31. package/dist/modules/scheduler/data/validators.js.map +2 -2
  32. package/dist/modules/scheduler/i18n/de.json +2 -0
  33. package/dist/modules/scheduler/i18n/en.json +2 -0
  34. package/dist/modules/scheduler/i18n/es.json +2 -0
  35. package/dist/modules/scheduler/i18n/ko.json +2 -0
  36. package/dist/modules/scheduler/i18n/pl.json +2 -0
  37. package/dist/modules/scheduler/lib/safeQueueTargets.js +157 -0
  38. package/dist/modules/scheduler/lib/safeQueueTargets.js.map +7 -0
  39. package/dist/modules/scheduler/lib/scheduleAccess.js +22 -0
  40. package/dist/modules/scheduler/lib/scheduleAccess.js.map +7 -0
  41. package/dist/modules/scheduler/services/localSchedulerService.js +31 -6
  42. package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
  43. package/dist/modules/scheduler/workers/execute-schedule.worker.js +64 -6
  44. package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
  45. package/dist/modules/scheduler/workers/test-echo-queue.worker.js +22 -0
  46. package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +7 -0
  47. package/package.json +7 -6
  48. package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
  49. package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +14 -8
  50. package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
  51. package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +90 -0
  52. package/src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts +94 -0
  53. package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
  54. package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +154 -66
  55. package/src/modules/scheduler/__integration__/helpers/scheduler.ts +24 -5
  56. package/src/modules/scheduler/api/jobs/[id]/executions/route.ts +9 -19
  57. package/src/modules/scheduler/api/targets/__tests__/route.test.ts +3 -4
  58. package/src/modules/scheduler/api/targets/route.ts +6 -17
  59. package/src/modules/scheduler/api/trigger/route.ts +9 -19
  60. package/src/modules/scheduler/backend/config/scheduled-jobs/page.tsx +4 -1
  61. package/src/modules/scheduler/cli.ts +44 -1
  62. package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +177 -0
  63. package/src/modules/scheduler/commands/jobs.ts +137 -3
  64. package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +110 -0
  65. package/src/modules/scheduler/data/__tests__/validators.test.ts +26 -4
  66. package/src/modules/scheduler/data/validators.ts +37 -2
  67. package/src/modules/scheduler/i18n/de.json +2 -0
  68. package/src/modules/scheduler/i18n/en.json +2 -0
  69. package/src/modules/scheduler/i18n/es.json +2 -0
  70. package/src/modules/scheduler/i18n/ko.json +2 -0
  71. package/src/modules/scheduler/i18n/pl.json +2 -0
  72. package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +311 -0
  73. package/src/modules/scheduler/lib/__tests__/scheduleAccess.test.ts +164 -0
  74. package/src/modules/scheduler/lib/safeQueueTargets.ts +324 -0
  75. package/src/modules/scheduler/lib/scheduleAccess.ts +104 -0
  76. package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +179 -0
  77. package/src/modules/scheduler/services/localSchedulerService.ts +39 -6
  78. package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +25 -3
  79. package/src/modules/scheduler/workers/execute-schedule.worker.ts +76 -7
  80. package/src/modules/scheduler/workers/test-echo-queue.worker.ts +25 -0
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'
2
+ import {
3
+ scheduleCreateSchema,
4
+ scheduleUpdateSchema,
5
+ } from '../validators'
6
+ import { registerModules } from '@open-mercato/shared/lib/modules/registry'
7
+
8
+ const tenantId = '123e4567-e89b-12d3-a456-426614174000'
9
+ const organizationId = '123e4567-e89b-12d3-a456-426614174001'
10
+ const scheduleId = '123e4567-e89b-12d3-a456-426614174002'
11
+
12
+ const REGISTRY_KEY = '__openMercatoModulesRegistry__'
13
+
14
+ beforeAll(() => {
15
+ registerModules([
16
+ {
17
+ id: 'test_module',
18
+ workers: [
19
+ {
20
+ id: 'test_module:workers:safe',
21
+ queue: 'test-module-safe-queue',
22
+ concurrency: 1,
23
+ schedulerSafe: true,
24
+ handler: async () => {},
25
+ },
26
+ {
27
+ id: 'test_module:workers:internal',
28
+ queue: 'stripe-webhook',
29
+ concurrency: 1,
30
+ handler: async () => {},
31
+ },
32
+ ],
33
+ },
34
+ ] as never)
35
+ })
36
+
37
+ afterAll(() => {
38
+ ;(globalThis as Record<string, unknown>)[REGISTRY_KEY] = null
39
+ })
40
+
41
+ describe('scheduler queue target authorization (#5213)', () => {
42
+ describe('scheduleCreateSchema', () => {
43
+ it('accepts a queue target declared scheduler-safe by its worker', () => {
44
+ const result = scheduleCreateSchema.parse({
45
+ name: 'Safe queue schedule',
46
+ scopeType: 'organization',
47
+ organizationId,
48
+ tenantId,
49
+ scheduleType: 'interval',
50
+ scheduleValue: '15m',
51
+ targetType: 'queue',
52
+ targetQueue: 'test-module-safe-queue',
53
+ })
54
+
55
+ expect(result.targetQueue).toBe('test-module-safe-queue')
56
+ })
57
+
58
+ it('rejects a queue target the worker did not declare scheduler-safe', () => {
59
+ expect(() =>
60
+ scheduleCreateSchema.parse({
61
+ name: 'Internal sink schedule',
62
+ scopeType: 'organization',
63
+ organizationId,
64
+ tenantId,
65
+ scheduleType: 'interval',
66
+ scheduleValue: '15m',
67
+ targetType: 'queue',
68
+ targetQueue: 'stripe-webhook',
69
+ })
70
+ ).toThrow(/not an approved scheduler target/)
71
+ })
72
+
73
+ it('rejects an unknown queue target', () => {
74
+ expect(() =>
75
+ scheduleCreateSchema.parse({
76
+ name: 'Unknown queue schedule',
77
+ scopeType: 'organization',
78
+ organizationId,
79
+ tenantId,
80
+ scheduleType: 'interval',
81
+ scheduleValue: '15m',
82
+ targetType: 'queue',
83
+ targetQueue: 'no-such-queue-anywhere',
84
+ })
85
+ ).toThrow(/not an approved scheduler target/)
86
+ })
87
+ })
88
+
89
+ describe('scheduleUpdateSchema', () => {
90
+ // Queue-safety rules for updates live in the scheduler.jobs.update command
91
+ // (change-detection against the stored row), so unchanged targets resent by
92
+ // the edit form and legacy remediation stay possible (#5213 N1).
93
+ // See commands/__tests__/jobs.target-guard.test.ts for that coverage.
94
+ it('accepts the request shape the edit form sends for an unchanged target', () => {
95
+ const result = scheduleUpdateSchema.parse({
96
+ id: scheduleId,
97
+ targetType: 'queue',
98
+ targetQueue: 'stripe-webhook',
99
+ isEnabled: false,
100
+ })
101
+ expect(result.isEnabled).toBe(false)
102
+ })
103
+
104
+ it('still rejects changing to a queue without providing the queue value', () => {
105
+ expect(() =>
106
+ scheduleUpdateSchema.parse({ id: scheduleId, targetType: 'queue' })
107
+ ).toThrow(/corresponding targetQueue/)
108
+ })
109
+ })
110
+ })
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeAll } from '@jest/globals'
1
+ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'
2
2
  import {
3
3
  scheduleCreateSchema,
4
4
  scheduleUpdateSchema,
@@ -13,6 +13,7 @@ import {
13
13
  clearSchedulerSafeCommandsForTests,
14
14
  registerSchedulerSafeCommands,
15
15
  } from '../../lib/scheduler-safe-commands'
16
+ import { registerModules } from '@open-mercato/shared/lib/modules/registry'
16
17
 
17
18
  // Register test commands for validation tests
18
19
  const testCommand: CommandHandler<unknown, { ok: boolean }> = {
@@ -39,6 +40,27 @@ beforeAll(() => {
39
40
  requiredFeatures: ['scheduler.jobs.manage'],
40
41
  },
41
42
  ])
43
+ // Queue targets are only valid when their worker opted into scheduling
44
+ // (`schedulerSafe: true`). Seed one safe queue so schema tests can exercise
45
+ // queue-target validation (#5213).
46
+ registerModules([
47
+ {
48
+ id: 'test_module',
49
+ workers: [
50
+ {
51
+ id: 'test_module:workers:safe',
52
+ queue: 'test',
53
+ concurrency: 1,
54
+ schedulerSafe: true,
55
+ handler: async () => {},
56
+ },
57
+ ],
58
+ },
59
+ ] as never)
60
+ })
61
+
62
+ afterAll(() => {
63
+ ;(globalThis as Record<string, unknown>).__openMercatoModulesRegistry__ = null
42
64
  })
43
65
 
44
66
  const tenantId = '123e4567-e89b-12d3-a456-426614174000'
@@ -54,7 +76,7 @@ describe('scheduleCreateSchema', () => {
54
76
  scheduleValue: '0 0 * * *',
55
77
  timezone: 'UTC',
56
78
  targetType: 'queue',
57
- targetQueue: 'backup-queue',
79
+ targetQueue: 'test',
58
80
  isEnabled: true,
59
81
  })
60
82
 
@@ -62,7 +84,7 @@ describe('scheduleCreateSchema', () => {
62
84
  expect(result.scopeType).toBe('system')
63
85
  expect(result.scheduleType).toBe('cron')
64
86
  expect(result.targetType).toBe('queue')
65
- expect(result.targetQueue).toBe('backup-queue')
87
+ expect(result.targetQueue).toBe('test')
66
88
  })
67
89
 
68
90
  it('should accept valid organization-scoped interval schedule with command target', () => {
@@ -98,7 +120,7 @@ describe('scheduleCreateSchema', () => {
98
120
  scheduleType: 'interval',
99
121
  scheduleValue: '15m',
100
122
  targetType: 'queue',
101
- targetQueue: 'cleanup',
123
+ targetQueue: 'test',
102
124
  })
103
125
 
104
126
  expect(result.scopeType).toBe('tenant')
@@ -4,6 +4,7 @@ import { validateInterval } from '../lib/intervalParser'
4
4
  import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
5
5
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
6
6
  import { isSchedulerSafeCommandId } from '../lib/scheduler-safe-commands'
7
+ import { isSchedulerSafeQueue, validateSchedulerTargetPayload } from '../lib/safeQueueTargets'
7
8
 
8
9
  /**
9
10
  * Validate that a command exists in the command registry
@@ -16,6 +17,15 @@ function validateCommandIsSchedulable(commandId: string): boolean {
16
17
  return isSchedulerSafeCommandId(commandId)
17
18
  }
18
19
 
20
+ /**
21
+ * Validate that a queue was declared scheduler-safe by its owning worker.
22
+ * Internal and system-only workers (webhook processors, indexers, …) stay
23
+ * undiscoverable as schedule targets — see issue #5213.
24
+ */
25
+ function validateSchedulerSafeQueue(queue: string | null | undefined): boolean {
26
+ return isSchedulerSafeQueue(queue)
27
+ }
28
+
19
29
  /**
20
30
  * Base schedule fields
21
31
  */
@@ -39,8 +49,6 @@ const scheduleBaseSchema = z.object({
39
49
  requireFeature: z.string().optional().nullable(),
40
50
 
41
51
  isEnabled: z.boolean().default(true),
42
- sourceType: z.enum(['user', 'module']).default('user'),
43
- sourceModule: z.string().optional().nullable(),
44
52
  })
45
53
 
46
54
  /**
@@ -120,6 +128,30 @@ export const scheduleCreateSchema = scheduleBaseSchema
120
128
  path: ['targetCommand'],
121
129
  }
122
130
  )
131
+ .refine(
132
+ (data) => {
133
+ if (data.targetType === 'queue' && data.targetQueue) {
134
+ return validateSchedulerSafeQueue(data.targetQueue)
135
+ }
136
+ return true
137
+ },
138
+ {
139
+ message: 'Target queue is not an approved scheduler target. Only workers that opted into scheduling can be selected.',
140
+ path: ['targetQueue'],
141
+ }
142
+ )
143
+ .refine(
144
+ (data) => {
145
+ if (data.targetType === 'queue' && data.targetQueue) {
146
+ return validateSchedulerTargetPayload(data.targetQueue, data.targetPayload) === null
147
+ }
148
+ return true
149
+ },
150
+ {
151
+ message: 'Invalid target payload for the selected scheduler queue.',
152
+ path: ['targetPayload'],
153
+ }
154
+ )
123
155
 
124
156
  /**
125
157
  * Update schedule schema (all fields optional except id)
@@ -214,6 +246,9 @@ export const scheduleUpdateSchema = z.object({
214
246
  path: ['targetCommand'],
215
247
  }
216
248
  )
249
+ // Queue-target safety and payload-shape rules for updates are enforced in the
250
+ // scheduler.jobs.update command against the stored row, so unchanged targets
251
+ // (always resent by the edit form) and legacy remediation stays possible (#5213).
217
252
 
218
253
  /**
219
254
  * Delete schedule schema
@@ -48,6 +48,8 @@
48
48
  "scheduler.error.unauthorized": "Nicht autorisiert",
49
49
  "scheduler.error.update_failed": "Fehler beim Aktualisieren des Zeitplans",
50
50
  "scheduler.errors.id_required": "Zeitplankennung ist erforderlich.",
51
+ "scheduler.errors.module_target_immutable": "Von Modulen erstellte Zeitpläne besitzen ihr Ziel; nur operative Felder können hier geändert werden.",
52
+ "scheduler.errors.queue_not_approved": "Ziel-Queue ist kein genehmigtes Scheduler-Ziel. Nur Worker, die sich angemeldet haben, können ausgewählt werden.",
51
53
  "scheduler.execution.actions": "Aktionen",
52
54
  "scheduler.execution.completed_message": "Diese Ausführung wurde erfolgreich ohne Fehler abgeschlossen.",
53
55
  "scheduler.execution.details_title": "Ausführungsdetails",
@@ -48,6 +48,8 @@
48
48
  "scheduler.error.unauthorized": "Unauthorized",
49
49
  "scheduler.error.update_failed": "Failed to update schedule",
50
50
  "scheduler.errors.id_required": "Schedule identifier is required.",
51
+ "scheduler.errors.module_target_immutable": "Module-authored schedules own their target; only operational fields can be updated here.",
52
+ "scheduler.errors.queue_not_approved": "Target queue is not an approved scheduler target. Only workers that opted into scheduling can be selected.",
51
53
  "scheduler.execution.actions": "Actions",
52
54
  "scheduler.execution.completed_message": "This execution completed successfully without errors.",
53
55
  "scheduler.execution.details_title": "Execution Details",
@@ -48,6 +48,8 @@
48
48
  "scheduler.error.unauthorized": "No autorizado",
49
49
  "scheduler.error.update_failed": "Error al actualizar la programación",
50
50
  "scheduler.errors.id_required": "El identificador de programación es obligatorio.",
51
+ "scheduler.errors.module_target_immutable": "Los programados creados por módulos poseen su destino; solo los campos operativos pueden modificarse aquí.",
52
+ "scheduler.errors.queue_not_approved": "La cola de destino no es un destino aprobado del planificador. Solo se pueden seleccionar workers que hayan optado.",
51
53
  "scheduler.execution.actions": "Acciones",
52
54
  "scheduler.execution.completed_message": "Esta ejecución se completó con éxito sin errores.",
53
55
  "scheduler.execution.details_title": "Detalles de ejecución",
@@ -48,6 +48,8 @@
48
48
  "scheduler.error.unauthorized": "권한이 없습니다",
49
49
  "scheduler.error.update_failed": "일정 수정에 실패했습니다",
50
50
  "scheduler.errors.id_required": "일정 식별자는 필수입니다.",
51
+ "scheduler.errors.module_target_immutable": "모듈이 생성한 일정은 대상을 소유하므로 여기서는 운영 필드만 수정할 수 있습니다.",
52
+ "scheduler.errors.queue_not_approved": "대상 큐는 승인된 스케줄러 대상이 아닙니다. 옵트인한 워커만 선택할 수 있습니다.",
51
53
  "scheduler.execution.actions": "작업",
52
54
  "scheduler.execution.completed_message": "이 실행은 오류 없이 성공적으로 완료되었습니다.",
53
55
  "scheduler.execution.details_title": "실행 상세 정보",
@@ -48,6 +48,8 @@
48
48
  "scheduler.error.unauthorized": "Brak autoryzacji",
49
49
  "scheduler.error.update_failed": "Nie udało się zaktualizować harmonogramu",
50
50
  "scheduler.errors.id_required": "Identyfikator harmonogramu jest wymagany.",
51
+ "scheduler.errors.module_target_immutable": "Harmonogramy utworzone przez moduły są właścicielami swojego celu; tutaj można zmieniać wyłącznie pola operacyjne.",
52
+ "scheduler.errors.queue_not_approved": "Kolejka docelowa nie jest zatwierdzonym celem harmonogramu. Można wybrać tylko workery, które się zadeklarowały.",
51
53
  "scheduler.execution.actions": "Akcje",
52
54
  "scheduler.execution.completed_message": "To wykonanie zakończyło się pomyślnie bez błędów.",
53
55
  "scheduler.execution.details_title": "Szczegóły wykonania",
@@ -0,0 +1,311 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'
2
+ import {
3
+ buildTrustedScheduleScope,
4
+ sanitizeSchedulerTargetPayload,
5
+ listSchedulerSafeQueueTargets,
6
+ isSchedulerSafeQueue,
7
+ canDispatchScheduleQueueTarget,
8
+ getSchedulerQueueRequiredFeatures,
9
+ assertSchedulerQueueTargetAuthorized,
10
+ validateSchedulerTargetPayload,
11
+ auditSchedulerModuleQueueRows,
12
+ } from '../safeQueueTargets'
13
+ import { registerModules } from '@open-mercato/shared/lib/modules/registry'
14
+
15
+ const REGISTRY_KEY = '__openMercatoModulesRegistry__'
16
+
17
+ beforeAll(() => {
18
+ registerModules([
19
+ {
20
+ id: 'alpha',
21
+ workers: [
22
+ {
23
+ id: 'alpha:workers:safe',
24
+ queue: 'alpha-safe',
25
+ concurrency: 1,
26
+ schedulerSafe: true,
27
+ handler: async () => {},
28
+ },
29
+ {
30
+ id: 'alpha:workers:internal',
31
+ queue: 'alpha-internal',
32
+ concurrency: 1,
33
+ handler: async () => {},
34
+ },
35
+ {
36
+ id: 'alpha:workers:mixed-safe',
37
+ queue: 'mixed-queue',
38
+ concurrency: 1,
39
+ schedulerSafe: true,
40
+ handler: async () => {},
41
+ },
42
+ {
43
+ id: 'alpha:workers:gated',
44
+ queue: 'gated-queue',
45
+ concurrency: 1,
46
+ schedulerSafe: true,
47
+ schedulerRequiredFeatures: ['orders.export'],
48
+ handler: async () => {},
49
+ },
50
+ ],
51
+ },
52
+ {
53
+ id: 'beta',
54
+ workers: [
55
+ {
56
+ id: 'beta:workers:safe',
57
+ queue: 'alpha-safe',
58
+ concurrency: 1,
59
+ schedulerSafe: true,
60
+ handler: async () => {},
61
+ },
62
+ {
63
+ id: 'beta:workers:freeloader',
64
+ queue: 'mixed-queue',
65
+ concurrency: 1,
66
+ handler: async () => {},
67
+ },
68
+ ],
69
+ },
70
+ ] as never)
71
+ })
72
+
73
+ afterAll(() => {
74
+ ;(globalThis as Record<string, unknown>)[REGISTRY_KEY] = null
75
+ })
76
+
77
+ describe('listSchedulerSafeQueueTargets', () => {
78
+ it('returns only queues whose workers ALL opted into scheduling, deduplicated and sorted', () => {
79
+ expect(listSchedulerSafeQueueTargets()).toEqual([
80
+ { queue: 'alpha-safe', moduleId: 'alpha', requiredFeatures: [] },
81
+ { queue: 'gated-queue', moduleId: 'alpha', requiredFeatures: ['orders.export'] },
82
+ ])
83
+ })
84
+
85
+ it('hides a queue when even one consumer did not opt in (#5213 M2)', () => {
86
+ // mixed-queue: alpha opts in, beta does not — the whole queue must stay hidden
87
+ expect(isSchedulerSafeQueue('mixed-queue')).toBe(false)
88
+ })
89
+ })
90
+
91
+ describe('target descriptors', () => {
92
+ it('surfaces required creator features from opted-in workers (#5213 M3)', () => {
93
+ expect(getSchedulerQueueRequiredFeatures('alpha-safe')).toEqual([])
94
+ expect(getSchedulerQueueRequiredFeatures('gated-queue')).toEqual(['orders.export'])
95
+ expect(getSchedulerQueueRequiredFeatures('unknown')).toEqual([])
96
+ })
97
+ })
98
+
99
+ describe('assertSchedulerQueueTargetAuthorized (#5213 M3)', () => {
100
+ const base = { queue: 'gated-queue' } as const
101
+
102
+ it('passes when the creator holds every required feature', async () => {
103
+ const reason = await assertSchedulerQueueTargetAuthorized({
104
+ ...base,
105
+ actorUserId: 'user-1',
106
+ tenantId: 't-1',
107
+ organizationId: 'o-1',
108
+ rbacService: { userHasAllFeatures: async () => true },
109
+ })
110
+ expect(reason).toBeNull()
111
+ })
112
+
113
+ it('rejects when the creator lacks a target-specific feature', async () => {
114
+ const reason = await assertSchedulerQueueTargetAuthorized({
115
+ ...base,
116
+ actorUserId: 'user-1',
117
+ tenantId: 't-1',
118
+ organizationId: 'o-1',
119
+ rbacService: { userHasAllFeatures: async () => false },
120
+ })
121
+ expect(reason).toContain('orders.export')
122
+ })
123
+
124
+ it('bypasses for super admins and feature-less targets', async () => {
125
+ expect(await assertSchedulerQueueTargetAuthorized({
126
+ ...base,
127
+ isSuperAdmin: true,
128
+ rbacService: { userHasAllFeatures: async () => false },
129
+ })).toBeNull()
130
+ expect(await assertSchedulerQueueTargetAuthorized({
131
+ queue: 'alpha-safe',
132
+ actorUserId: null,
133
+ })).toBeNull()
134
+ })
135
+ })
136
+
137
+ describe('isSchedulerSafeQueue', () => {
138
+ it('accepts opted-in queues', () => {
139
+ expect(isSchedulerSafeQueue('alpha-safe')).toBe(true)
140
+ })
141
+
142
+ it('rejects internal worker queues', () => {
143
+ expect(isSchedulerSafeQueue('alpha-internal')).toBe(false)
144
+ })
145
+
146
+ it('rejects unknown, empty, and missing queues', () => {
147
+ expect(isSchedulerSafeQueue('nope')).toBe(false)
148
+ expect(isSchedulerSafeQueue('')).toBe(false)
149
+ expect(isSchedulerSafeQueue(null)).toBe(false)
150
+ expect(isSchedulerSafeQueue(undefined)).toBe(false)
151
+ })
152
+ })
153
+
154
+ describe('canDispatchScheduleQueueTarget', () => {
155
+ it('allows module-authored schedules to target queues their own module owns', () => {
156
+ expect(canDispatchScheduleQueueTarget({
157
+ targetQueue: 'alpha-internal',
158
+ sourceType: 'module',
159
+ sourceModule: 'alpha',
160
+ })).toBe(true)
161
+ })
162
+
163
+ it('fails closed on pre-upgrade/forged module rows (#5213 B1)', () => {
164
+ // legacy row without any recorded owner
165
+ expect(canDispatchScheduleQueueTarget({ targetQueue: 'alpha-internal', sourceType: 'module' })).toBe(false)
166
+ // row claiming a module that does not own the queue — e.g. data_sync → stripe-webhook
167
+ expect(canDispatchScheduleQueueTarget({
168
+ targetQueue: 'stripe-webhook',
169
+ sourceType: 'module',
170
+ sourceModule: 'data_sync',
171
+ })).toBe(false)
172
+ expect(canDispatchScheduleQueueTarget({
173
+ targetQueue: 'alpha-internal',
174
+ sourceType: 'module',
175
+ sourceModule: 'beta',
176
+ })).toBe(false)
177
+ })
178
+
179
+ it('restricts user-authored schedules to scheduler-safe queues', () => {
180
+ expect(canDispatchScheduleQueueTarget({ targetQueue: 'alpha-safe', sourceType: 'user' })).toBe(true)
181
+ expect(canDispatchScheduleQueueTarget({ targetQueue: 'stripe-webhook', sourceType: 'user' })).toBe(false)
182
+ })
183
+
184
+ it('rejects schedules without a queue', () => {
185
+ expect(canDispatchScheduleQueueTarget({ targetQueue: null })).toBe(false)
186
+ expect(canDispatchScheduleQueueTarget({})).toBe(false)
187
+ })
188
+ })
189
+
190
+ describe('validateSchedulerTargetPayload (#5213 M3)', () => {
191
+ it('enforces registered schemas for known targets', async () => {
192
+ expect(validateSchedulerTargetPayload('scheduler-test', { message: 'hi' })).toBeNull()
193
+ expect(validateSchedulerTargetPayload('scheduler-test', { message: 123 })).toMatch(/message/)
194
+ expect(validateSchedulerTargetPayload('unregistered-queue', { anything: true })).toBeNull()
195
+ })
196
+ })
197
+
198
+ describe('buildTrustedScheduleScope', () => {
199
+ it('derives scope exclusively from the stored schedule row', () => {
200
+ expect(buildTrustedScheduleScope({ tenantId: 't1', organizationId: 'o1' }))
201
+ .toEqual({ tenantId: 't1', organizationId: 'o1' })
202
+ expect(buildTrustedScheduleScope({}))
203
+ .toEqual({ tenantId: null, organizationId: null })
204
+ })
205
+ })
206
+
207
+ describe('sanitizeSchedulerTargetPayload', () => {
208
+ const schedule = { tenantId: 't-1', organizationId: 'o-1' }
209
+
210
+ it('strips caller-supplied authority keys at the payload root', () => {
211
+ const result = sanitizeSchedulerTargetPayload({
212
+ scope: { tenantId: 'forged', organizationId: 'forged' },
213
+ tenantId: 'forged',
214
+ organizationId: 'forged',
215
+ keep: 'me',
216
+ }, schedule)
217
+
218
+ expect(result).toEqual({
219
+ keep: 'me',
220
+ scope: { tenantId: 't-1', organizationId: 'o-1' },
221
+ })
222
+ })
223
+
224
+ it('strips reserved keys inside the local-strategy payload wrapper level', () => {
225
+ const result = sanitizeSchedulerTargetPayload({
226
+ payload: {
227
+ nested: 'data',
228
+ scope: { tenantId: 'forged' },
229
+ _jobOrigin: 'inbound-webhook',
230
+ },
231
+ }, schedule)
232
+
233
+ expect(result).toEqual({
234
+ payload: { nested: 'data' },
235
+ scope: { tenantId: 't-1', organizationId: 'o-1' },
236
+ })
237
+ })
238
+
239
+ it('strips the reserved underscore-prefixed envelope namespace', () => {
240
+ const result = sanitizeSchedulerTargetPayload({
241
+ _idempotencyKey: 'forged',
242
+ _jobOrigin: 'inbound-webhook',
243
+ data: 1,
244
+ }, schedule)
245
+
246
+ expect(result).toEqual({
247
+ data: 1,
248
+ scope: { tenantId: 't-1', organizationId: 'o-1' },
249
+ })
250
+ })
251
+
252
+ it('does not mutate the input payload', () => {
253
+ const input = {
254
+ scope: { tenantId: 'forged' },
255
+ deep: { value: { x: 1 } },
256
+ }
257
+ const snapshot = structuredClone(input)
258
+
259
+ sanitizeSchedulerTargetPayload(input, schedule)
260
+
261
+ expect(input).toEqual(snapshot)
262
+ })
263
+
264
+ it('injects trusted scope for empty and missing payloads', () => {
265
+ expect(sanitizeSchedulerTargetPayload(null, schedule))
266
+ .toEqual({ scope: { tenantId: 't-1', organizationId: 'o-1' } })
267
+ expect(sanitizeSchedulerTargetPayload({}, schedule))
268
+ .toEqual({ scope: { tenantId: 't-1', organizationId: 'o-1' } })
269
+ })
270
+ })
271
+
272
+ describe('auditSchedulerModuleQueueRows (#5213 B1-residual-2)', () => {
273
+ it('flags API-key-authored forgeries that the runtime guard cannot distinguish', () => {
274
+ // A non-user-bound API key stamps no actor: at rest this row is
275
+ // indistinguishable from a genuine registration even though it was forged
276
+ // pre-upgrade. The runtime guard must keep allowing it (it cannot know),
277
+ // and the audit must surface it for operator review.
278
+ const apiKeyForged = {
279
+ id: 'row-forged',
280
+ name: 'Looks legitimate',
281
+ targetQueue: 'alpha-internal',
282
+ sourceType: 'module',
283
+ sourceModule: 'alpha',
284
+ createdByUserId: null,
285
+ isEnabled: true,
286
+ }
287
+ const genuine = { ...apiKeyForged, id: 'row-genuine', name: 'Real registration' }
288
+ const mismatched = {
289
+ ...apiKeyForged,
290
+ id: 'row-mismatched',
291
+ targetQueue: 'stripe-webhook',
292
+ sourceModule: 'alpha',
293
+ }
294
+
295
+ const audits = auditSchedulerModuleQueueRows([apiKeyForged, genuine, mismatched])
296
+
297
+ expect(audits).toHaveLength(3)
298
+ const forged = audits.find((a) => a.scheduleId === 'row-forged')
299
+ expect(forged?.dispatchAllowed).toBe(true)
300
+ // the mismatched-name row fails closed and is reported as blocked
301
+ expect(audits.find((a) => a.scheduleId === 'row-mismatched')?.dispatchAllowed).toBe(false)
302
+ expect(audits.find((a) => a.scheduleId === 'row-genuine')?.dispatchAllowed).toBe(true)
303
+ })
304
+
305
+ it('ignores user-authored rows entirely', () => {
306
+ const audits = auditSchedulerModuleQueueRows([
307
+ { id: 'row-user', targetQueue: 'alpha-internal', sourceType: 'user', sourceModule: null },
308
+ ])
309
+ expect(audits).toHaveLength(0)
310
+ })
311
+ })