@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.
- 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-008.spec.js +66 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-008.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/jobs/[id]/executions/route.js +7 -13
- package/dist/modules/scheduler/api/jobs/[id]/executions/route.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/api/trigger/route.js +7 -13
- package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
- package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js +3 -1
- package/dist/modules/scheduler/backend/config/scheduled-jobs/page.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/lib/scheduleAccess.js +22 -0
- package/dist/modules/scheduler/lib/scheduleAccess.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 +7 -6
- 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-008.spec.ts +94 -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/jobs/[id]/executions/route.ts +9 -19
- 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/api/trigger/route.ts +9 -19
- package/src/modules/scheduler/backend/config/scheduled-jobs/page.tsx +4 -1
- 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/__tests__/scheduleAccess.test.ts +164 -0
- package/src/modules/scheduler/lib/safeQueueTargets.ts +324 -0
- package/src/modules/scheduler/lib/scheduleAccess.ts +104 -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
|
@@ -5,6 +5,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
5
5
|
import type { EntityManager } from '@mikro-orm/core'
|
|
6
6
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
7
7
|
import { ScheduledJob } from '../../../../data/entities.js'
|
|
8
|
+
import { resolveScheduleAccess } from '../../../../lib/scheduleAccess.js'
|
|
8
9
|
import { getRedisUrlOrThrow, parseRedisUrl } from '@open-mercato/shared/lib/redis/connection'
|
|
9
10
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
10
11
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
@@ -40,30 +41,19 @@ export async function GET(
|
|
|
40
41
|
const scheduleId = params.id
|
|
41
42
|
|
|
42
43
|
try {
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
deletedAt: null,
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Apply tenant isolation: scope the query to the user's tenant/org
|
|
50
|
-
if (auth.tenantId) {
|
|
51
|
-
findFilter.tenantId = auth.tenantId
|
|
52
|
-
}
|
|
53
|
-
if (auth.orgId) {
|
|
54
|
-
findFilter.organizationId = auth.orgId
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const schedule = await em.findOne(ScheduledJob, findFilter)
|
|
44
|
+
// Load by id alone, then apply isolation to the loaded row. Folding the actor's
|
|
45
|
+
// tenant/org into the lookup would make every system-scoped schedule unmatchable.
|
|
46
|
+
const schedule = await em.findOne(ScheduledJob, { id: scheduleId, deletedAt: null })
|
|
58
47
|
|
|
59
48
|
if (!schedule) {
|
|
60
49
|
return NextResponse.json({ error: translate('scheduler.error.not_found', 'Schedule not found') }, { status: 404 })
|
|
61
50
|
}
|
|
62
51
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
52
|
+
const access = resolveScheduleAccess(schedule, auth)
|
|
53
|
+
if (access === 'not_found') {
|
|
54
|
+
return NextResponse.json({ error: translate('scheduler.error.not_found', 'Schedule not found') }, { status: 404 })
|
|
55
|
+
}
|
|
56
|
+
if (access === 'forbidden') {
|
|
67
57
|
return NextResponse.json({ error: translate('scheduler.error.access_denied', 'Access denied') }, { status: 403 })
|
|
68
58
|
}
|
|
69
59
|
|
|
@@ -50,8 +50,8 @@ describe('GET /api/scheduler/targets', () => {
|
|
|
50
50
|
orgId: 'org-1',
|
|
51
51
|
})
|
|
52
52
|
;(getModules as jest.Mock).mockReturnValue([
|
|
53
|
-
{ workers: [{ queue: 'z-queue' }] },
|
|
54
|
-
{ workers: [{ queue: 'a-queue' }] },
|
|
53
|
+
{ workers: [{ queue: 'z-queue' }, { queue: 'internal-queue' }] },
|
|
54
|
+
{ workers: [{ queue: 'a-queue', schedulerSafe: true }] },
|
|
55
55
|
])
|
|
56
56
|
})
|
|
57
57
|
|
|
@@ -60,14 +60,13 @@ describe('GET /api/scheduler/targets', () => {
|
|
|
60
60
|
unregisterCommand(unsafeCommand.id)
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
it('lists only scheduler-safe commands', async () => {
|
|
63
|
+
it('lists only scheduler-safe queues and commands', async () => {
|
|
64
64
|
const response = await GET(new NextRequest('http://localhost/api/scheduler/targets'))
|
|
65
65
|
const body = await response.json()
|
|
66
66
|
|
|
67
67
|
expect(response.status).toBe(200)
|
|
68
68
|
expect(body.queues).toEqual([
|
|
69
69
|
{ value: 'a-queue', label: 'a-queue' },
|
|
70
|
-
{ value: 'z-queue', label: 'z-queue' },
|
|
71
70
|
])
|
|
72
71
|
expect(body.commands).toEqual([
|
|
73
72
|
{ value: safeCommand.id, label: safeCommand.id },
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
-
import { getModules } from '@open-mercato/shared/lib/modules/registry'
|
|
5
4
|
import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
|
|
6
5
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
7
6
|
import { listSchedulerSafeCommands } from '../../lib/scheduler-safe-commands'
|
|
7
|
+
import { listSchedulerSafeQueueTargets } from '../../lib/safeQueueTargets'
|
|
8
8
|
|
|
9
9
|
export const metadata = {
|
|
10
10
|
requireAuth: true,
|
|
@@ -13,7 +13,9 @@ export const metadata = {
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* GET /api/scheduler/targets
|
|
16
|
-
* Returns
|
|
16
|
+
* Returns scheduler-safe queue names and command IDs for schedule target selection.
|
|
17
|
+
* Only queues whose workers opted into scheduling (`schedulerSafe: true`) are
|
|
18
|
+
* advertised; internal and system-only workers stay undiscoverable (#5213).
|
|
17
19
|
*/
|
|
18
20
|
export async function GET(req: NextRequest) {
|
|
19
21
|
const auth = await getAuthFromRequest(req)
|
|
@@ -21,20 +23,8 @@ export async function GET(req: NextRequest) {
|
|
|
21
23
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
const queueSet = new Set<string>()
|
|
27
|
-
for (const mod of modules) {
|
|
28
|
-
if (mod.workers) {
|
|
29
|
-
for (const worker of mod.workers) {
|
|
30
|
-
queueSet.add(worker.queue)
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const queues = Array.from(queueSet)
|
|
36
|
-
.sort((a, b) => a.localeCompare(b))
|
|
37
|
-
.map((queue) => ({ value: queue, label: queue }))
|
|
26
|
+
const queues = listSchedulerSafeQueueTargets()
|
|
27
|
+
.map((target) => ({ value: target.queue, label: target.queue }))
|
|
38
28
|
|
|
39
29
|
const commands = listSchedulerSafeCommands()
|
|
40
30
|
.filter((command) => commandRegistry.has(command.commandId))
|
|
@@ -66,7 +56,6 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
66
56
|
GET: {
|
|
67
57
|
operationId: 'listScheduleTargets',
|
|
68
58
|
summary: 'List available queues and commands',
|
|
69
|
-
description: 'Returns all registered queue names (from module workers) and explicitly scheduler-safe command IDs that can be used as schedule targets.',
|
|
70
59
|
responses: [
|
|
71
60
|
{
|
|
72
61
|
status: 200,
|
|
@@ -7,6 +7,7 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
|
7
7
|
import { createQueue } from '@open-mercato/queue'
|
|
8
8
|
import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
|
|
9
9
|
import { ScheduledJob } from '../../data/entities.js'
|
|
10
|
+
import { resolveScheduleAccess } from '../../lib/scheduleAccess.js'
|
|
10
11
|
import { scheduleTriggerSchema } from '../../data/validators.js'
|
|
11
12
|
import type { ExecuteSchedulePayload } from '../../workers/execute-schedule.worker.js'
|
|
12
13
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
@@ -40,30 +41,19 @@ export async function POST(req: NextRequest) {
|
|
|
40
41
|
const body = await req.json()
|
|
41
42
|
const input = scheduleTriggerSchema.parse(body)
|
|
42
43
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
deletedAt: null,
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Apply tenant isolation: scope the query to the user's tenant/org
|
|
50
|
-
if (auth.tenantId) {
|
|
51
|
-
findFilter.tenantId = auth.tenantId
|
|
52
|
-
}
|
|
53
|
-
if (auth.orgId) {
|
|
54
|
-
findFilter.organizationId = auth.orgId
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const schedule = await em.findOne(ScheduledJob, findFilter)
|
|
44
|
+
// Load by id alone, then apply isolation to the loaded row. Folding the actor's
|
|
45
|
+
// tenant/org into the lookup would make every system-scoped schedule unmatchable.
|
|
46
|
+
const schedule = await em.findOne(ScheduledJob, { id: input.id, deletedAt: null })
|
|
58
47
|
|
|
59
48
|
if (!schedule) {
|
|
60
49
|
return NextResponse.json({ error: translate('scheduler.error.not_found', 'Schedule not found') }, { status: 404 })
|
|
61
50
|
}
|
|
62
51
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
52
|
+
const access = resolveScheduleAccess(schedule, auth)
|
|
53
|
+
if (access === 'not_found') {
|
|
54
|
+
return NextResponse.json({ error: translate('scheduler.error.not_found', 'Schedule not found') }, { status: 404 })
|
|
55
|
+
}
|
|
56
|
+
if (access === 'forbidden') {
|
|
67
57
|
return NextResponse.json({ error: translate('scheduler.error.access_denied', 'Access denied') }, { status: 403 })
|
|
68
58
|
}
|
|
69
59
|
|
|
@@ -39,6 +39,7 @@ type SchedulesResponse = {
|
|
|
39
39
|
total?: number
|
|
40
40
|
page?: number
|
|
41
41
|
totalPages?: number
|
|
42
|
+
totalIsCapped?: boolean
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
function mapApiItem(item: Record<string, unknown>): ScheduleRow | null {
|
|
@@ -82,6 +83,7 @@ export default function SchedulerPage() {
|
|
|
82
83
|
const [pageSize] = React.useState(20)
|
|
83
84
|
const [total, setTotal] = React.useState(0)
|
|
84
85
|
const [totalPages, setTotalPages] = React.useState(1)
|
|
86
|
+
const [totalIsCapped, setTotalIsCapped] = React.useState(false)
|
|
85
87
|
const [search, setSearch] = React.useState('')
|
|
86
88
|
const [isLoading, setIsLoading] = React.useState(false)
|
|
87
89
|
const scopeVersion = useOrganizationScopeVersion()
|
|
@@ -104,6 +106,7 @@ export default function SchedulerPage() {
|
|
|
104
106
|
setRows(mapped)
|
|
105
107
|
setTotal(result?.total ?? 0)
|
|
106
108
|
setTotalPages(result?.totalPages ?? 1)
|
|
109
|
+
setTotalIsCapped(result?.totalIsCapped === true)
|
|
107
110
|
} catch (error) {
|
|
108
111
|
flash(t('scheduler.error.fetch_failed', 'Failed to load schedules'), 'error')
|
|
109
112
|
setRows([])
|
|
@@ -282,7 +285,7 @@ export default function SchedulerPage() {
|
|
|
282
285
|
createLabel={t('scheduler.action.create', 'New Schedule')}
|
|
283
286
|
/>
|
|
284
287
|
)}
|
|
285
|
-
pagination={{ page, pageSize, total, totalPages, onPageChange: setPage }}
|
|
288
|
+
pagination={{ page, pageSize, total, totalPages, totalIsCapped, onPageChange: setPage }}
|
|
286
289
|
isLoading={isLoading}
|
|
287
290
|
searchValue={search}
|
|
288
291
|
onSearchChange={(value) => { setSearch(value); setPage(1) }}
|
|
@@ -3,6 +3,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/core'
|
|
4
4
|
import { ScheduledJob } from './data/entities.js'
|
|
5
5
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
6
|
+
import { auditSchedulerModuleQueueRows } from './lib/safeQueueTargets'
|
|
6
7
|
|
|
7
8
|
const writeLine = (text = '') => {
|
|
8
9
|
process.stdout.write(`${text}\n`)
|
|
@@ -225,4 +226,46 @@ const startCommand: ModuleCli = {
|
|
|
225
226
|
},
|
|
226
227
|
}
|
|
227
228
|
|
|
228
|
-
|
|
229
|
+
// Lists every persisted module-authored queue-target row with its current
|
|
230
|
+
// dispatch verdict (#5213). Rows authored before the hardening by a
|
|
231
|
+
// non-user-bound API key are indistinguishable from genuine registrations at
|
|
232
|
+
// rest, so operators review the ALLOWED entries against their known integrations.
|
|
233
|
+
const auditQueueTargetsCommand: ModuleCli = {
|
|
234
|
+
command: 'audit-queue-targets',
|
|
235
|
+
async run(rest) {
|
|
236
|
+
const args = parseArgs(rest)
|
|
237
|
+
const { resolve } = await createRequestContainer()
|
|
238
|
+
const em = resolve('em') as EntityManager
|
|
239
|
+
|
|
240
|
+
const where: Record<string, unknown> = { deletedAt: null, sourceType: 'module' }
|
|
241
|
+
if (args.tenant || args.tenantId) where.tenantId = args.tenant || args.tenantId
|
|
242
|
+
|
|
243
|
+
const rows = await em.find(ScheduledJob, where, {
|
|
244
|
+
orderBy: { name: 'ASC' },
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const audits = auditSchedulerModuleQueueRows(rows)
|
|
248
|
+
|
|
249
|
+
writeLine(`Module-authored queue-target schedules: ${audits.length}`)
|
|
250
|
+
let allowedCount = 0
|
|
251
|
+
for (const audit of audits) {
|
|
252
|
+
const verdict = audit.dispatchAllowed ? 'ALLOWED' : 'blocked'
|
|
253
|
+
if (audit.dispatchAllowed) allowedCount += 1
|
|
254
|
+
writeLine(
|
|
255
|
+
`[${verdict}] ${audit.scheduleId}${audit.name ? ` — ${audit.name}` : ''}` +
|
|
256
|
+
` module=${audit.sourceModule ?? '(none)'} queue=${audit.targetQueue ?? '(none)'}` +
|
|
257
|
+
` enabled=${audit.isEnabled ? 'yes' : 'no'}`,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (allowedCount > 0) {
|
|
262
|
+
writeLine('')
|
|
263
|
+
writeLine(`Review the ${allowedCount} ALLOWED row(s) above against your known module integrations.`)
|
|
264
|
+
writeLine('Pre-upgrade rows created with a non-user-bound API key cannot be distinguished')
|
|
265
|
+
writeLine('from genuine registrations at rest; disable anything unrecognized:')
|
|
266
|
+
writeLine(' yarn mercato scheduler list --tenant <tenantId>')
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export default [listCommand, statusCommand, runCommand, startCommand, auditQueueTargetsCommand]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
export {}
|
|
2
|
+
|
|
3
|
+
const registerCommand = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.mock('@open-mercato/shared/lib/commands', () => ({
|
|
6
|
+
registerCommand,
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
10
|
+
resolveTranslations: jest.fn().mockResolvedValue({
|
|
11
|
+
translate: (_key: string, fallback?: string) => fallback ?? _key,
|
|
12
|
+
}),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
function loadCommands() {
|
|
16
|
+
let update: any
|
|
17
|
+
jest.isolateModules(() => {
|
|
18
|
+
require('../jobs')
|
|
19
|
+
update = registerCommand.mock.calls.find(([cmd]) => cmd.id === 'scheduler.jobs.update')?.[0]
|
|
20
|
+
})
|
|
21
|
+
return update
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const safeQueueRow = {
|
|
25
|
+
id: 'job-1',
|
|
26
|
+
name: 'QA queue schedule',
|
|
27
|
+
scopeType: 'organization',
|
|
28
|
+
tenantId: 'tenant-a',
|
|
29
|
+
organizationId: 'org-a',
|
|
30
|
+
isEnabled: true,
|
|
31
|
+
scheduleType: 'interval',
|
|
32
|
+
scheduleValue: '15m',
|
|
33
|
+
timezone: 'UTC',
|
|
34
|
+
targetType: 'queue',
|
|
35
|
+
targetQueue: 'scheduler-test',
|
|
36
|
+
targetCommand: null,
|
|
37
|
+
// nested payload the UI round-trips verbatim
|
|
38
|
+
targetPayload: { source: 'integration-test', retries: { max: 5 } },
|
|
39
|
+
sourceType: 'user',
|
|
40
|
+
sourceModule: null,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const moduleOwnedRow = {
|
|
44
|
+
...safeQueueRow,
|
|
45
|
+
id: 'job-module',
|
|
46
|
+
name: 'Module-owned schedule',
|
|
47
|
+
sourceType: 'module',
|
|
48
|
+
sourceModule: 'legacy_module',
|
|
49
|
+
createdByUserId: null,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeEm(schedule: Record<string, unknown>) {
|
|
53
|
+
return {
|
|
54
|
+
fork: jest.fn().mockReturnThis(),
|
|
55
|
+
findOne: jest.fn().mockResolvedValue(schedule),
|
|
56
|
+
persist: jest.fn(),
|
|
57
|
+
flush: jest.fn().mockResolvedValue(undefined),
|
|
58
|
+
count: jest.fn().mockResolvedValue(0),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('scheduler.jobs.update target-change detection (#5213 N1)', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
jest.clearAllMocks()
|
|
65
|
+
jest.resetModules()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('allows an operational-only save of a module-authored schedule whose form resends the unchanged target', async () => {
|
|
69
|
+
const update = loadCommands()
|
|
70
|
+
const em = makeEm(moduleOwnedRow)
|
|
71
|
+
const ctx = {
|
|
72
|
+
auth: { isSuperAdmin: false, tenantId: 'tenant-a', orgId: 'org-a', sub: 'user-1' },
|
|
73
|
+
container: { resolve: jest.fn(() => em) },
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = await update.execute(
|
|
77
|
+
{
|
|
78
|
+
id: moduleOwnedRow.id,
|
|
79
|
+
// the shipped edit form always resends every field…
|
|
80
|
+
targetType: moduleOwnedRow.targetType,
|
|
81
|
+
targetQueue: moduleOwnedRow.targetQueue,
|
|
82
|
+
targetPayload: moduleOwnedRow.targetPayload,
|
|
83
|
+
// …while the operator only changed operational values
|
|
84
|
+
name: 'Renamed by ops',
|
|
85
|
+
scheduleValue: '30m',
|
|
86
|
+
scheduleType: 'interval',
|
|
87
|
+
isEnabled: false,
|
|
88
|
+
},
|
|
89
|
+
ctx,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(result).toEqual({ ok: true })
|
|
93
|
+
expect(em.flush).toHaveBeenCalled()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('still blocks an actual target or payload change on a module-authored schedule', async () => {
|
|
97
|
+
const update = loadCommands()
|
|
98
|
+
const em = makeEm(moduleOwnedRow)
|
|
99
|
+
const ctx = {
|
|
100
|
+
auth: { isSuperAdmin: false, tenantId: 'tenant-a', orgId: 'org-a', sub: 'user-1' },
|
|
101
|
+
container: { resolve: jest.fn(() => em) },
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await expect(
|
|
105
|
+
update.execute({ id: moduleOwnedRow.id, targetQueue: 'stripe-webhook' }, ctx),
|
|
106
|
+
).rejects.toMatchObject({ status: 403 })
|
|
107
|
+
await expect(
|
|
108
|
+
update.execute({ id: moduleOwnedRow.id, targetPayload: { forged: true } }, ctx),
|
|
109
|
+
).rejects.toMatchObject({ status: 403 })
|
|
110
|
+
expect(em.flush).not.toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('lets admins disable a legacy user-authored row whose stored queue is no longer approved (#5213 N1)', async () => {
|
|
114
|
+
const update = loadCommands()
|
|
115
|
+
const legacyUnsafe = {
|
|
116
|
+
...safeQueueRow,
|
|
117
|
+
id: 'job-legacy',
|
|
118
|
+
targetQueue: 'stripe-webhook',
|
|
119
|
+
targetPayload: null as unknown,
|
|
120
|
+
}
|
|
121
|
+
const em = makeEm(legacyUnsafe)
|
|
122
|
+
const ctx = {
|
|
123
|
+
auth: { isSuperAdmin: false, tenantId: 'tenant-a', orgId: 'org-a', sub: 'user-1' },
|
|
124
|
+
container: { resolve: jest.fn(() => em) },
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// the shipped edit form resends every field — target fields included —
|
|
128
|
+
// while only flipping the switch
|
|
129
|
+
const result = await update.execute(
|
|
130
|
+
{
|
|
131
|
+
id: legacyUnsafe.id,
|
|
132
|
+
targetType: legacyUnsafe.targetType,
|
|
133
|
+
targetQueue: legacyUnsafe.targetQueue,
|
|
134
|
+
isEnabled: false,
|
|
135
|
+
},
|
|
136
|
+
ctx,
|
|
137
|
+
)
|
|
138
|
+
expect(result).toEqual({ ok: true })
|
|
139
|
+
|
|
140
|
+
// retargeting onto another unapproved queue is still rejected
|
|
141
|
+
await expect(
|
|
142
|
+
update.execute({ id: legacyUnsafe.id, targetQueue: 'payment-gateways-webhook' }, ctx),
|
|
143
|
+
).rejects.toMatchObject({ status: 422 })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('treats an empty-object stored payload as equal to the null the form submits (#5213 N1-residual)', async () => {
|
|
147
|
+
const update = loadCommands()
|
|
148
|
+
const emptyPayloadRow = {
|
|
149
|
+
...moduleOwnedRow,
|
|
150
|
+
id: 'job-module-empty-payload',
|
|
151
|
+
targetPayload: {},
|
|
152
|
+
}
|
|
153
|
+
const em = makeEm(emptyPayloadRow)
|
|
154
|
+
const ctx = {
|
|
155
|
+
auth: { isSuperAdmin: true, tenantId: null, orgId: null, sub: 'super-1' },
|
|
156
|
+
container: { resolve: jest.fn(() => em) },
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ai_assistant-style registration stores `targetPayload: {}`; the edit form
|
|
160
|
+
// normalizes that to `targetPayload: null` on submit. An operational save
|
|
161
|
+
// must not trip the module-owned-target guard over the shape difference.
|
|
162
|
+
const result = await update.execute(
|
|
163
|
+
{
|
|
164
|
+
id: emptyPayloadRow.id,
|
|
165
|
+
targetType: emptyPayloadRow.targetType,
|
|
166
|
+
targetQueue: emptyPayloadRow.targetQueue,
|
|
167
|
+
targetPayload: null,
|
|
168
|
+
name: 'Renamed by ops',
|
|
169
|
+
isEnabled: true,
|
|
170
|
+
},
|
|
171
|
+
ctx,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
expect(result).toEqual({ ok: true })
|
|
175
|
+
expect(em.flush).toHaveBeenCalled()
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -14,7 +14,12 @@ import type {
|
|
|
14
14
|
} from '../data/validators.js'
|
|
15
15
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
16
16
|
import type { BullMQSchedulerService } from '../services/bullmqSchedulerService.js'
|
|
17
|
-
|
|
17
|
+
import {
|
|
18
|
+
assertSchedulerQueueTargetAuthorized,
|
|
19
|
+
isSchedulerSafeQueue,
|
|
20
|
+
validateSchedulerTargetPayload,
|
|
21
|
+
} from '../lib/safeQueueTargets.js'
|
|
22
|
+
import type { SchedulerTargetRbacService } from '../lib/safeQueueTargets.js'
|
|
18
23
|
/**
|
|
19
24
|
* Snapshot of a schedule for undo/redo
|
|
20
25
|
*/
|
|
@@ -83,6 +88,64 @@ function toDate(value: Date | string | null | undefined): Date | null {
|
|
|
83
88
|
return value instanceof Date ? value : new Date(value)
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
function canonicalJson(value: unknown): string {
|
|
92
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)
|
|
93
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
94
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
95
|
+
.filter(([, v]) => v !== undefined)
|
|
96
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
97
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeTargetPayloadValue(value: unknown): unknown {
|
|
101
|
+
// `schedulerService.register()` persists `{}` as-is while the edit form sends
|
|
102
|
+
// `null` for an empty payload — treat the three empty shapes as equal (#5213).
|
|
103
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
104
|
+
&& Object.keys(value as Record<string, unknown>).length === 0) return null
|
|
105
|
+
return value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function targetPayloadChanged(inputValue: unknown, storedValue: unknown): boolean {
|
|
109
|
+
return targetValueChanged(
|
|
110
|
+
normalizeTargetPayloadValue(inputValue),
|
|
111
|
+
normalizeTargetPayloadValue(storedValue),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function targetValueChanged(inputValue: unknown, storedValue: unknown): boolean {
|
|
116
|
+
if (inputValue === undefined) return false
|
|
117
|
+
return canonicalJson(inputValue ?? null) !== canonicalJson(storedValue ?? null)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type QueueTargetAuthorizationContext = CommandRuntimeContext
|
|
121
|
+
|
|
122
|
+
async function assertQueueTargetAuthorizedForActor(
|
|
123
|
+
ctx: QueueTargetAuthorizationContext,
|
|
124
|
+
queue: string,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const rbacService = (() => {
|
|
127
|
+
try {
|
|
128
|
+
return ctx.container?.resolve<SchedulerTargetRbacService>('rbacService') ?? null
|
|
129
|
+
} catch {
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
})()
|
|
133
|
+
const reason = await assertSchedulerQueueTargetAuthorized({
|
|
134
|
+
queue,
|
|
135
|
+
actorUserId: resolveCommandActorUserId(ctx),
|
|
136
|
+
tenantId: ctx.auth?.tenantId ?? null,
|
|
137
|
+
organizationId: ctx.auth?.orgId ?? null,
|
|
138
|
+
isSuperAdmin: ctx.auth?.isSuperAdmin === true,
|
|
139
|
+
rbacService,
|
|
140
|
+
})
|
|
141
|
+
if (reason) throw new CrudHttpError(403, { error: reason })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function assertQueueTargetPayloadAllowed(queue: string, targetPayload: Record<string, unknown> | null | undefined): Promise<void> {
|
|
145
|
+
const reason = validateSchedulerTargetPayload(queue, targetPayload)
|
|
146
|
+
if (reason) throw new CrudHttpError(422, { error: `Invalid target payload for queue ${queue}: ${reason}` })
|
|
147
|
+
}
|
|
148
|
+
|
|
86
149
|
function resolveCommandActorUserId(ctx: CommandRuntimeContext): string | null {
|
|
87
150
|
const auth = ctx.auth
|
|
88
151
|
if (!auth) return null
|
|
@@ -212,6 +275,11 @@ const createScheduleCommand: CommandHandler<ScheduleCreateInput, { id: string }>
|
|
|
212
275
|
throw new Error('Failed to calculate next run time')
|
|
213
276
|
}
|
|
214
277
|
|
|
278
|
+
if (input.targetType === 'queue' && input.targetQueue) {
|
|
279
|
+
await assertQueueTargetAuthorizedForActor(ctx, input.targetQueue)
|
|
280
|
+
await assertQueueTargetPayloadAllowed(input.targetQueue, input.targetPayload)
|
|
281
|
+
}
|
|
282
|
+
|
|
215
283
|
// Create schedule
|
|
216
284
|
const schedule = em.create(ScheduledJob, {
|
|
217
285
|
name: input.name,
|
|
@@ -228,8 +296,8 @@ const createScheduleCommand: CommandHandler<ScheduleCreateInput, { id: string }>
|
|
|
228
296
|
targetPayload: input.targetPayload ?? null,
|
|
229
297
|
requireFeature: input.requireFeature ?? null,
|
|
230
298
|
isEnabled: input.isEnabled ?? true,
|
|
231
|
-
sourceType:
|
|
232
|
-
sourceModule:
|
|
299
|
+
sourceType: 'user',
|
|
300
|
+
sourceModule: null,
|
|
233
301
|
nextRunAt,
|
|
234
302
|
createdByUserId: resolveCommandActorUserId(ctx),
|
|
235
303
|
createdAt: new Date(),
|
|
@@ -310,10 +378,76 @@ const updateScheduleCommand: CommandHandler<ScheduleUpdateInput, { ok: boolean }
|
|
|
310
378
|
if (schedule.organizationId) ensureOrganizationScope(ctx, schedule.organizationId)
|
|
311
379
|
ensureCanManageSystemScopedJob(ctx, schedule)
|
|
312
380
|
|
|
381
|
+
// Module-authored schedules are trusted infrastructure wiring (#5213): their
|
|
382
|
+
// target and payload are owned by the registering module. Submitting them
|
|
383
|
+
// unchanged is a no-op (the edit form always resends every field) — only an
|
|
384
|
+
// actual change to provenance-relevant values is rejected.
|
|
385
|
+
const touchesModuleOwnedTarget =
|
|
386
|
+
schedule.sourceType === 'module'
|
|
387
|
+
&& (
|
|
388
|
+
targetValueChanged(input.targetType, schedule.targetType)
|
|
389
|
+
|| targetValueChanged(input.targetQueue, schedule.targetQueue)
|
|
390
|
+
|| targetValueChanged(input.targetCommand, schedule.targetCommand)
|
|
391
|
+
|| targetPayloadChanged(input.targetPayload, schedule.targetPayload)
|
|
392
|
+
)
|
|
393
|
+
if (touchesModuleOwnedTarget) {
|
|
394
|
+
const { translate } = await resolveTranslations()
|
|
395
|
+
throw new CrudHttpError(403, {
|
|
396
|
+
error: translate(
|
|
397
|
+
'scheduler.errors.module_target_immutable',
|
|
398
|
+
'Module-authored schedules own their target; only operational fields can be updated here.',
|
|
399
|
+
),
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
|
|
313
403
|
if (input.isEnabled === true && schedule.isEnabled !== true) {
|
|
314
404
|
await enforceTenantActiveScheduleLimit(em, schedule.tenantId)
|
|
315
405
|
}
|
|
316
406
|
|
|
407
|
+
const effectiveTargetType = input.targetType ?? schedule.targetType
|
|
408
|
+
const effectiveQueue = input.targetQueue ?? schedule.targetQueue
|
|
409
|
+
const queueChanged =
|
|
410
|
+
effectiveTargetType === 'queue'
|
|
411
|
+
&& (
|
|
412
|
+
targetValueChanged(input.targetQueue, schedule.targetQueue)
|
|
413
|
+
|| targetValueChanged(input.targetType, schedule.targetType)
|
|
414
|
+
|| targetPayloadChanged(input.targetPayload, schedule.targetPayload)
|
|
415
|
+
)
|
|
416
|
+
if (effectiveTargetType === 'queue' && effectiveQueue) {
|
|
417
|
+
const retargetedToUnapprovedQueue =
|
|
418
|
+
input.targetQueue !== undefined
|
|
419
|
+
&& input.targetQueue !== schedule.targetQueue
|
|
420
|
+
&& !isSchedulerSafeQueue(input.targetQueue)
|
|
421
|
+
// A save that leaves a user-authored row ACTIVE on an unapproved queue
|
|
422
|
+
// restores the invariant the update schema used to enforce; disabling the
|
|
423
|
+
// row stays allowed so admins can remediate legacy targets from the UI.
|
|
424
|
+
// (Dispatch-time re-verification remains the backstop either way.)
|
|
425
|
+
const keepsUnapprovedQueueActive =
|
|
426
|
+
effectiveQueue != null
|
|
427
|
+
&& !isSchedulerSafeQueue(effectiveQueue)
|
|
428
|
+
&& input.isEnabled !== false
|
|
429
|
+
&& schedule.isEnabled !== false
|
|
430
|
+
if (
|
|
431
|
+
(retargetedToUnapprovedQueue || keepsUnapprovedQueueActive)
|
|
432
|
+
&& schedule.sourceType !== 'module'
|
|
433
|
+
) {
|
|
434
|
+
const { translate } = await resolveTranslations()
|
|
435
|
+
throw new CrudHttpError(422, {
|
|
436
|
+
error: translate(
|
|
437
|
+
'scheduler.errors.queue_not_approved',
|
|
438
|
+
'Target queue is not an approved scheduler target. Only workers that opted into scheduling can be selected.',
|
|
439
|
+
),
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
if (queueChanged) {
|
|
443
|
+
await assertQueueTargetAuthorizedForActor(ctx, effectiveQueue)
|
|
444
|
+
const effectivePayload = input.targetPayload !== undefined
|
|
445
|
+
? input.targetPayload
|
|
446
|
+
: schedule.targetPayload
|
|
447
|
+
await assertQueueTargetPayloadAllowed(effectiveQueue, effectivePayload)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
317
451
|
const scheduleChanged = input.scheduleType !== undefined || input.scheduleValue !== undefined || input.timezone !== undefined
|
|
318
452
|
const nextRunAt = scheduleChanged
|
|
319
453
|
? calculateNextRunForWrite(
|