@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/scheduler",
3
- "version": "0.7.0",
3
+ "version": "0.7.1-develop.7103.1.41ff100d93",
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.7.0",
63
- "@open-mercato/queue": "0.7.0",
62
+ "@open-mercato/events": "0.7.1-develop.7103.1.41ff100d93",
63
+ "@open-mercato/queue": "0.7.1-develop.7103.1.41ff100d93",
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.7.0",
68
+ "@open-mercato/shared": "0.7.1-develop.7103.1.41ff100d93",
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.7.0",
82
+ "@open-mercato/shared": "0.7.1-develop.7103.1.41ff100d93",
83
83
  "@types/jest": "^30.0.0",
84
84
  "@types/node": "^26.1.2",
85
85
  "jest": "^30.4.2",
@@ -93,5 +93,6 @@
93
93
  "type": "git",
94
94
  "url": "https://github.com/open-mercato/open-mercato",
95
95
  "directory": "packages/scheduler"
96
- }
96
+ },
97
+ "stableVersion": "0.7.0"
97
98
  }
@@ -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: 'queue',
21
- targetQueue: 'scheduler-execution',
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 { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_TARGETS_PATH } from './helpers/scheduler'
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 available queue names
25
- * (from module workers) and registered command IDs (from the command
26
- * registry), both sorted alphabetically by value. Read-only, no fixtures.
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 sorted queues (incl. scheduler-execution) and a non-empty command list', async ({ request }) => {
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: non-empty, well-shaped, sorted, and include the known scheduler queue.
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 { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_JOBS_PATH, uniqueScheduleName } from './helpers/scheduler'
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: 'queue',
40
- targetQueue: SCHEDULER_EXECUTION_QUEUE,
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
+ })
@@ -0,0 +1,94 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+ import {
5
+ SCHEDULER_JOBS_PATH,
6
+ SCHEDULER_TRIGGER_PATH,
7
+ createScheduleJob,
8
+ deleteScheduleJob,
9
+ uniqueScheduleName,
10
+ } from './helpers/scheduler'
11
+
12
+ const isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || 'local') === 'async'
13
+
14
+ type TriggerBody = { ok?: boolean; jobId?: string; error?: string; message?: string }
15
+
16
+ /**
17
+ * TC-SCHED-008: POST /api/scheduler/trigger and GET /api/scheduler/jobs/[id]/executions
18
+ * resolve the scope of a system-scoped schedule on the loaded row, not in the lookup.
19
+ *
20
+ * A system-scoped schedule has `tenantId === null` and `organizationId === null`. When both
21
+ * routes folded the caller's tenant/org into the `where` clause, no such row could ever match
22
+ * and every caller — super admins included — got 404, which also masked each route's own
23
+ * super-admin gate. A super admin must now reach the schedule, and a non-super-admin must be
24
+ * told 403 rather than 404.
25
+ *
26
+ * The super-admin assertions hold under either queue strategy: each route's
27
+ * `QUEUE_STRATEGY=async` check sits after the lookup, so `local` yields the pre-existing
28
+ * 400 — never 404.
29
+ */
30
+ test.describe('TC-SCHED-008: system-scoped schedule access on trigger and executions', () => {
31
+ test('super admin reaches the schedule on both routes; a non-super-admin is forbidden', async ({ request }) => {
32
+ const superToken = await getAuthToken(request, 'superadmin')
33
+ const adminToken = await getAuthToken(request, 'admin')
34
+ let scheduleId: string | null = null
35
+
36
+ try {
37
+ // Creating a system-scoped schedule is itself super-admin only.
38
+ scheduleId = await createScheduleJob(request, superToken, {
39
+ name: uniqueScheduleName('System-Trigger-Test'),
40
+ scopeType: 'system',
41
+ scheduleType: 'interval',
42
+ scheduleValue: '1h',
43
+ })
44
+
45
+ const superResponse = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {
46
+ token: superToken,
47
+ data: { id: scheduleId },
48
+ })
49
+ const superBody = await readJsonSafe<TriggerBody>(superResponse)
50
+ expect(
51
+ superResponse.status(),
52
+ 'a super admin must not be told a system-scoped schedule does not exist',
53
+ ).not.toBe(404)
54
+
55
+ if (isAsyncQueueStrategy) {
56
+ expect(superResponse.status()).toBe(200)
57
+ expect(superBody?.ok).toBe(true)
58
+ expect(typeof superBody?.jobId === 'string' && superBody.jobId.length > 0).toBe(true)
59
+ } else {
60
+ // The strategy check runs after the scope check, so this 400 is the response the
61
+ // 404 used to mask.
62
+ expect(superResponse.status()).toBe(400)
63
+ expect(superBody?.error ?? '').toMatch(/async/i)
64
+ }
65
+
66
+ // The same schedule is visible enough to be refused, not hidden, from a tenant admin.
67
+ const adminResponse = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {
68
+ token: adminToken,
69
+ data: { id: scheduleId },
70
+ })
71
+ expect(
72
+ adminResponse.status(),
73
+ 'a non-super-admin must be forbidden from triggering a system-scoped schedule',
74
+ ).toBe(403)
75
+
76
+ // The execution-history route carried the same lookup and needs the same guarantees.
77
+ const executionsPath = `${SCHEDULER_JOBS_PATH}/${scheduleId}/executions`
78
+ const superExecutions = await apiRequest(request, 'GET', executionsPath, { token: superToken })
79
+ expect(
80
+ superExecutions.status(),
81
+ 'a super admin must not be told a system-scoped schedule has no execution history route',
82
+ ).not.toBe(404)
83
+ expect(superExecutions.status()).toBe(isAsyncQueueStrategy ? 200 : 400)
84
+
85
+ const adminExecutions = await apiRequest(request, 'GET', executionsPath, { token: adminToken })
86
+ expect(
87
+ adminExecutions.status(),
88
+ 'a non-super-admin must be forbidden from reading a system-scoped execution history',
89
+ ).toBe(403)
90
+ } finally {
91
+ await deleteScheduleJob(request, superToken, scheduleId)
92
+ }
93
+ })
94
+ })
@@ -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-execution',
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-execution',
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-execution',
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 (#2582, refs #2468) — scheduler.jobs undo/redo correctness.
5
+ * TC-UNDO-001: scheduler.jobs undo round-trip (regression for issue #2504).
17
6
  *
18
- * Regression lock for #2504: scheduler.jobs undo was a silent no-op because every
19
- * undo handler read `logEntry.payload` (always undefined — the command bus persists
20
- * the undo snapshot under `commandPayload`), so undo returned `{ ok: true }` while
21
- * changing nothing. Fixed in PR #2514 via `extractUndoPayload`. This spec asserts the
22
- * corrected behavior (full restoration), driving the real command bus through the
23
- * public API + the audit-log undo/redo endpoints.
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
- * scheduler.jobs is a standard `makeCrudRoute` entity (POST/PUT/DELETE on the
26
- * collection, `?id=` read-back, camelCase serializer, no custom fields), so the shared
27
- * `runCrudUndoRoundTrip` harness covers the full round-trip in one pass:
28
- * - I1 update → undo restores every scalar (and bumps `updatedAt`)
29
- * - I2 delete → undo re-materializes the soft-deleted job
30
- * - I3 create → undo soft-deletes (never hard-deletes)
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
- function jobCreatePayload(stamp: string): Record<string, unknown> {
37
- return {
38
- name: `TC-UNDO-001 Scheduler ${stamp}`,
39
- description: 'TC-UNDO-001 undo/redo probe',
40
- scopeType: 'organization',
41
- scheduleType: 'interval',
42
- scheduleValue: '15m',
43
- timezone: 'UTC',
44
- targetType: 'queue',
45
- targetQueue: 'scheduler-execution',
46
- isEnabled: true,
47
- sourceType: 'user',
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
- test.describe('TC-UNDO-001 scheduler.jobs undo/redo (#2504)', () => {
52
- test.beforeAll(() => {
53
- skipIfUndoTestsDisabled()
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
- test('jobs CRUD commands restore scalar state on undo/redo (I1/I2/I3/I5/I6)', async ({ request }) => {
57
- const token = await getAuthToken(request, 'admin')
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
- await runCrudUndoRoundTrip(request, token, {
60
- label: 'scheduler.jobs',
61
- collectionPath: SCHEDULER_JOBS_PATH,
62
- field: 'name',
63
- createPayload: jobCreatePayload,
64
- updatePayload: (id, stamp) => ({ id, name: `TC-UNDO-001 Scheduler Renamed ${stamp}` }),
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
- // §4 manual trigger is a fire-and-forget enqueue that never goes through the
69
- // command bus, so it exposes no undo affordance. The assertion holds regardless of
70
- // QUEUE_STRATEGY: async returns 200 (enqueued), local returns 400 (rejected) — neither
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
- jobId = await createScheduleJob(request, token)
77
- const triggerRes = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {
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: jobId },
140
+ data: { id },
80
141
  })
81
- expect(extractOperation(triggerRes), 'trigger response carries no undo token (§4)').toBeNull()
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 deleteScheduleJob(request, token, jobId)
171
+ await cleanup(request, token, id)
84
172
  }
85
173
  })
86
174
  })
@@ -6,9 +6,25 @@ export const SCHEDULER_JOBS_PATH = '/api/scheduler/jobs'
6
6
  export const SCHEDULER_TARGETS_PATH = '/api/scheduler/targets'
7
7
  export const SCHEDULER_TRIGGER_PATH = '/api/scheduler/trigger'
8
8
 
9
- /** Queue registered by the scheduler module's execute-schedule worker. */
9
+ /** Queue registered by the scheduler module's execute-schedule worker (internal, not schedulable). */
10
10
  export const SCHEDULER_EXECUTION_QUEUE = 'scheduler-execution'
11
11
 
12
+ /** Deliberately side-effect-free queue whose worker opted into scheduling (#5213). */
13
+ export const SCHEDULER_TEST_QUEUE = 'scheduler-test'
14
+
15
+ /**
16
+ * Internal queues that must never be offered as scheduler targets (#5213).
17
+ * Workers must opt in via `schedulerSafe: true` to become schedulable.
18
+ */
19
+ export const SCHEDULER_FORBIDDEN_INTERNAL_QUEUES = [
20
+ SCHEDULER_EXECUTION_QUEUE,
21
+ 'stripe-webhook',
22
+ 'payment-gateways-webhook',
23
+ ] as const
24
+
25
+ /** Always-registered command used by fixtures now that queue targets are restricted. */
26
+ export const SCHEDULER_TEST_COMMAND = 'scheduler.test.echo'
27
+
12
28
  export type SchedulerJob = {
13
29
  id: string
14
30
  name: string
@@ -51,16 +67,19 @@ export function uniqueScheduleName(prefix: string): string {
51
67
  }
52
68
 
53
69
  /**
54
- * Create an organization-scoped queue-target schedule via the API and return its id.
70
+ * Create an organization-scoped schedule via the API and return its id.
55
71
  * Org/tenant are derived server-side from the caller's auth context, so the caller
56
72
  * only needs an admin (or otherwise scheduler.jobs.manage) token.
73
+ *
74
+ * Defaults to a command target: user-authored queue targets are restricted to
75
+ * scheduler-safe queues (#5213), so fixtures must not assume a queue is allowed.
57
76
  */
58
77
  export async function createScheduleJob(
59
78
  request: APIRequestContext,
60
79
  token: string,
61
80
  overrides: CreateScheduleOverrides = {},
62
81
  ): Promise<string> {
63
- const targetType = overrides.targetType ?? 'queue'
82
+ const targetType = overrides.targetType ?? 'command'
64
83
  const data: Record<string, unknown> = {
65
84
  name: overrides.name ?? uniqueScheduleName('Integration Scheduler'),
66
85
  description: overrides.description ?? 'Created by scheduler integration tests',
@@ -74,9 +93,9 @@ export async function createScheduleJob(
74
93
  sourceType: overrides.sourceType ?? 'user',
75
94
  }
76
95
  if (targetType === 'command') {
77
- data.targetCommand = overrides.targetCommand
96
+ data.targetCommand = overrides.targetCommand ?? SCHEDULER_TEST_COMMAND
78
97
  } else {
79
- data.targetQueue = overrides.targetQueue ?? SCHEDULER_EXECUTION_QUEUE
98
+ data.targetQueue = overrides.targetQueue ?? SCHEDULER_TEST_QUEUE
80
99
  }
81
100
 
82
101
  const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, { token, data })