@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.
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 +4 -9
  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-CRUDFORM-001.spec.js +3 -3
  9. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
  10. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +44 -107
  11. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
  12. package/dist/modules/scheduler/__integration__/helpers/scheduler.js +3 -13
  13. package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
  14. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js +13 -7
  15. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js.map +2 -2
  16. package/dist/modules/scheduler/api/targets/route.js +12 -2
  17. package/dist/modules/scheduler/api/targets/route.js.map +2 -2
  18. package/dist/modules/scheduler/api/trigger/route.js +13 -7
  19. package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
  20. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js +1 -3
  21. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js.map +2 -2
  22. package/dist/modules/scheduler/cli.js +1 -32
  23. package/dist/modules/scheduler/cli.js.map +2 -2
  24. package/dist/modules/scheduler/commands/jobs.js +2 -84
  25. package/dist/modules/scheduler/commands/jobs.js.map +2 -2
  26. package/dist/modules/scheduler/data/validators.js +3 -27
  27. package/dist/modules/scheduler/data/validators.js.map +2 -2
  28. package/dist/modules/scheduler/i18n/de.json +0 -2
  29. package/dist/modules/scheduler/i18n/en.json +0 -2
  30. package/dist/modules/scheduler/i18n/es.json +0 -2
  31. package/dist/modules/scheduler/i18n/ko.json +0 -2
  32. package/dist/modules/scheduler/i18n/pl.json +0 -2
  33. package/dist/modules/scheduler/services/localSchedulerService.js +6 -31
  34. package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
  35. package/dist/modules/scheduler/workers/execute-schedule.worker.js +6 -64
  36. package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
  37. package/package.json +6 -7
  38. package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
  39. package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +8 -14
  40. package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
  41. package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
  42. package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +66 -154
  43. package/src/modules/scheduler/__integration__/helpers/scheduler.ts +5 -24
  44. package/src/modules/scheduler/api/jobs/[id]/executions/route.ts +19 -9
  45. package/src/modules/scheduler/api/targets/__tests__/route.test.ts +4 -3
  46. package/src/modules/scheduler/api/targets/route.ts +17 -6
  47. package/src/modules/scheduler/api/trigger/route.ts +19 -9
  48. package/src/modules/scheduler/backend/config/scheduled-jobs/page.tsx +1 -4
  49. package/src/modules/scheduler/cli.ts +1 -44
  50. package/src/modules/scheduler/commands/jobs.ts +3 -137
  51. package/src/modules/scheduler/data/__tests__/validators.test.ts +4 -26
  52. package/src/modules/scheduler/data/validators.ts +2 -37
  53. package/src/modules/scheduler/i18n/de.json +0 -2
  54. package/src/modules/scheduler/i18n/en.json +0 -2
  55. package/src/modules/scheduler/i18n/es.json +0 -2
  56. package/src/modules/scheduler/i18n/ko.json +0 -2
  57. package/src/modules/scheduler/i18n/pl.json +0 -2
  58. package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +0 -179
  59. package/src/modules/scheduler/services/localSchedulerService.ts +6 -39
  60. package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +3 -25
  61. package/src/modules/scheduler/workers/execute-schedule.worker.ts +7 -76
  62. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +0 -72
  63. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +0 -7
  64. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js +0 -66
  65. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js.map +0 -7
  66. package/dist/modules/scheduler/lib/safeQueueTargets.js +0 -157
  67. package/dist/modules/scheduler/lib/safeQueueTargets.js.map +0 -7
  68. package/dist/modules/scheduler/lib/scheduleAccess.js +0 -22
  69. package/dist/modules/scheduler/lib/scheduleAccess.js.map +0 -7
  70. package/dist/modules/scheduler/workers/test-echo-queue.worker.js +0 -22
  71. package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +0 -7
  72. package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +0 -90
  73. package/src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts +0 -94
  74. package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +0 -177
  75. package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +0 -110
  76. package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +0 -311
  77. package/src/modules/scheduler/lib/__tests__/scheduleAccess.test.ts +0 -164
  78. package/src/modules/scheduler/lib/safeQueueTargets.ts +0 -324
  79. package/src/modules/scheduler/lib/scheduleAccess.ts +0 -104
  80. package/src/modules/scheduler/workers/test-echo-queue.worker.ts +0 -25
@@ -1,110 +0,0 @@
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,311 +0,0 @@
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
- })
@@ -1,164 +0,0 @@
1
- /**
2
- * Regression test for the manual-trigger 404 on system-scoped schedules.
3
- *
4
- * `POST /api/scheduler/trigger` and `GET /api/scheduler/jobs/[id]/executions` used to fold
5
- * the actor's tenant/org into the lookup, so a schedule with `tenantId === null` and
6
- * `organizationId === null` never matched and both routes answered 404 — for a super admin
7
- * too — leaving their own system-scope branch unreachable. This exercises the real
8
- * `resolveScheduleAccess` both routes now call.
9
- */
10
-
11
- import { describe, it, expect } from '@jest/globals'
12
- import { resolveScheduleAccess, type ScheduleScopeSubject } from '../scheduleAccess'
13
-
14
- const systemSchedule: ScheduleScopeSubject = { scopeType: 'system', tenantId: null, organizationId: null }
15
- const tenantSchedule: ScheduleScopeSubject = { scopeType: 'tenant', tenantId: 't1', organizationId: null }
16
- const orgSchedule: ScheduleScopeSubject = { scopeType: 'organization', tenantId: 't1', organizationId: 'o1' }
17
-
18
- describe('resolveScheduleAccess — system-scoped schedules', () => {
19
- it('allows a super admin whose session carries a tenant and an organization', () => {
20
- expect(
21
- resolveScheduleAccess(systemSchedule, { tenantId: 't1', orgId: 'o1', isSuperAdmin: true }),
22
- ).toBe('allowed')
23
- })
24
-
25
- it('allows a super admin whose session carries no tenant', () => {
26
- expect(
27
- resolveScheduleAccess(systemSchedule, { tenantId: null, orgId: null, isSuperAdmin: true }),
28
- ).toBe('allowed')
29
- })
30
-
31
- it('forbids a non-super-admin instead of hiding the schedule behind a 404', () => {
32
- expect(
33
- resolveScheduleAccess(systemSchedule, { tenantId: 't1', orgId: 'o1', isSuperAdmin: false }),
34
- ).toBe('forbidden')
35
- })
36
-
37
- it('forbids an actor missing the isSuperAdmin flag even when a role is named "superadmin"', () => {
38
- expect(
39
- resolveScheduleAccess(systemSchedule, { tenantId: 't1', orgId: 'o1' }),
40
- ).toBe('forbidden')
41
- })
42
-
43
- it('forbids an unauthenticated-shaped actor', () => {
44
- expect(resolveScheduleAccess(systemSchedule, null)).toBe('forbidden')
45
- })
46
- })
47
-
48
- /**
49
- * `ensureCanManageSystemScopedJob` in commands/jobs.ts treats a row as system-scoped when
50
- * `scopeType === 'system' || tenantId == null`. Update, delete, trigger and executions must
51
- * not disagree about what a system-scoped row is, so the same test is applied here — including
52
- * for the inconsistent rows the create validator's scope refinement should never produce.
53
- */
54
- describe('resolveScheduleAccess — system-scope classification matches commands/jobs.ts', () => {
55
- it('treats a null tenant as system-scoped even when an organization is set', () => {
56
- expect(
57
- resolveScheduleAccess(
58
- { scopeType: 'organization', tenantId: null, organizationId: 'o1' },
59
- { tenantId: 't1', orgId: 'o1', isSuperAdmin: false },
60
- ),
61
- ).toBe('forbidden')
62
- })
63
-
64
- it('treats scopeType "system" as system-scoped even when a tenant is set', () => {
65
- expect(
66
- resolveScheduleAccess(
67
- { scopeType: 'system', tenantId: 't1', organizationId: null },
68
- { tenantId: 't1', orgId: 'o1', isSuperAdmin: false },
69
- ),
70
- ).toBe('forbidden')
71
- })
72
-
73
- // 403 here would tell an actor in t1 that the id exists in t2. Only a genuinely
74
- // tenant-less row is allowed to answer `forbidden`.
75
- it('does not disclose a tenant-bound "system" row across a tenant boundary', () => {
76
- expect(
77
- resolveScheduleAccess(
78
- { scopeType: 'system', tenantId: 't2', organizationId: null },
79
- { tenantId: 't1', orgId: 'o1', isSuperAdmin: false },
80
- ),
81
- ).toBe('not_found')
82
- })
83
-
84
- it('still classifies by nullability when scopeType is absent', () => {
85
- expect(
86
- resolveScheduleAccess({ tenantId: null, organizationId: null }, { tenantId: 't1', isSuperAdmin: false }),
87
- ).toBe('forbidden')
88
- })
89
- })
90
-
91
- describe('resolveScheduleAccess — tenant isolation', () => {
92
- it('reports another tenant\'s schedule as not_found, never forbidden', () => {
93
- expect(
94
- resolveScheduleAccess(orgSchedule, { tenantId: 't2', orgId: 'o2', isSuperAdmin: false }),
95
- ).toBe('not_found')
96
- })
97
-
98
- it('hides another tenant\'s schedule from a super admin scoped to a different tenant', () => {
99
- expect(
100
- resolveScheduleAccess(orgSchedule, { tenantId: 't2', orgId: 'o2', isSuperAdmin: true }),
101
- ).toBe('not_found')
102
- })
103
-
104
- it('fails closed for a non-super-admin whose tenant could not be resolved', () => {
105
- expect(
106
- resolveScheduleAccess(orgSchedule, { tenantId: null, orgId: null, isSuperAdmin: false }),
107
- ).toBe('not_found')
108
- })
109
-
110
- // The exact shape the replaced lookup leaked: with no tenant clause applied, the filter
111
- // `{ id, deletedAt: null, organizationId: 'o1' }` matched org-bound rows in ANY tenant.
112
- it('closes the cross-tenant leak for an actor with an org but no resolvable tenant', () => {
113
- expect(
114
- resolveScheduleAccess(
115
- { scopeType: 'organization', tenantId: 't2', organizationId: 'o1' },
116
- { tenantId: null, orgId: 'o1', isSuperAdmin: false },
117
- ),
118
- ).toBe('not_found')
119
- })
120
-
121
- // buildSchedulerJobsFilters returns an unmatchable filter when the tenant is falsy, for
122
- // super admins too, so such an actor cannot see this row in the list either.
123
- it('fails closed for a super admin whose tenant could not be resolved', () => {
124
- expect(
125
- resolveScheduleAccess(orgSchedule, { tenantId: null, orgId: null, isSuperAdmin: true }),
126
- ).toBe('not_found')
127
- })
128
- })
129
-
130
- describe('resolveScheduleAccess — organization isolation', () => {
131
- it('reports another organization\'s schedule in the same tenant as not_found', () => {
132
- expect(
133
- resolveScheduleAccess(orgSchedule, { tenantId: 't1', orgId: 'o2', isSuperAdmin: false }),
134
- ).toBe('not_found')
135
- })
136
-
137
- it('allows the owning organization', () => {
138
- expect(
139
- resolveScheduleAccess(orgSchedule, { tenantId: 't1', orgId: 'o1', isSuperAdmin: false }),
140
- ).toBe('allowed')
141
- })
142
-
143
- // Inherited from the lookup this helper replaces, which omitted the organization clause
144
- // entirely when the actor carried no orgId. The list endpoint hides the row in this case
145
- // (its organization branch needs at least one id), so the two paths disagree. Pinned here
146
- // to document the divergence, not to endorse it — narrowing it is a separate change.
147
- it('allows an actor with no selected organization to reach an org-bound schedule in its tenant', () => {
148
- expect(
149
- resolveScheduleAccess(orgSchedule, { tenantId: 't1', orgId: null, isSuperAdmin: false }),
150
- ).toBe('allowed')
151
- })
152
-
153
- it('keeps a tenant-scoped schedule visible to an org-bound actor in that tenant', () => {
154
- expect(
155
- resolveScheduleAccess(tenantSchedule, { tenantId: 't1', orgId: 'o1', isSuperAdmin: false }),
156
- ).toBe('allowed')
157
- })
158
-
159
- it('does not treat an undefined organizationId as a distinct organization', () => {
160
- expect(
161
- resolveScheduleAccess({ scopeType: 'tenant', tenantId: 't1' }, { tenantId: 't1', orgId: 'o1', isSuperAdmin: false }),
162
- ).toBe('allowed')
163
- })
164
- })