@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
@@ -1,2 +1,2 @@
1
- [build:scheduler] found 55 entry points
1
+ [build:scheduler] found 60 entry points
2
2
  [build:scheduler] built successfully
@@ -11,8 +11,8 @@ async function createSchedule(request, token) {
11
11
  scheduleType: "interval",
12
12
  scheduleValue: "15m",
13
13
  timezone: "UTC",
14
- targetType: "queue",
15
- targetQueue: "scheduler-execution",
14
+ targetType: "command",
15
+ targetCommand: "scheduler.test.echo",
16
16
  targetPayload: { source: "integration-test" },
17
17
  isEnabled: true,
18
18
  sourceType: "user"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts"],
4
- "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'\n\nconst isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || 'local') === 'async'\n\ntype CreateScheduleResponse = { id?: string }\ntype SchedulerListResponse = { items?: Array<{ id?: string }> }\ntype SchedulerErrorResponse = { error?: string; message?: string; available?: boolean; items?: unknown[] }\n\nasync function createSchedule(request: import('@playwright/test').APIRequestContext, token: string) {\n const response = await apiRequest(request, 'POST', '/api/scheduler/jobs', {\n token,\n data: {\n name: `Integration Scheduler ${Date.now()}`,\n description: 'Runtime Redis URL integration probe',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'scheduler-execution',\n targetPayload: { source: 'integration-test' },\n isEnabled: true,\n sourceType: 'user',\n },\n })\n\n expect(response.status()).toBe(201)\n const body = (await response.json()) as CreateScheduleResponse\n expect(body.id).toMatch(/^[0-9a-f-]{36}$/i)\n return String(body.id)\n}\n\ntest.describe('TC-SCHED-001: Scheduler runtime queue APIs', () => {\n test('lists created schedules and exposes execution-history endpoint semantics', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n scheduleId = await createSchedule(request, token)\n\n const listResponse = await apiRequest(request, 'GET', `/api/scheduler/jobs?id=${scheduleId}`, { token })\n expect(listResponse.status()).toBe(200)\n const listBody = (await listResponse.json()) as SchedulerListResponse\n const ids = (listBody.items ?? []).map((item) => item.id)\n expect(ids).toContain(scheduleId)\n\n const executionsResponse = await apiRequest(\n request,\n 'GET',\n `/api/scheduler/jobs/${scheduleId}/executions?pageSize=5`,\n { token },\n )\n\n if (isAsyncQueueStrategy) {\n expect(executionsResponse.status()).toBe(200)\n const executionsBody = (await executionsResponse.json()) as { items?: unknown[]; total?: number; pageSize?: number }\n expect(Array.isArray(executionsBody.items)).toBe(true)\n expect(typeof executionsBody.total).toBe('number')\n expect(executionsBody.pageSize).toBe(5)\n } else {\n expect(executionsResponse.status()).toBe(400)\n const executionsBody = (await executionsResponse.json()) as SchedulerErrorResponse\n expect(executionsBody.error ?? '').toMatch(/QUEUE_STRATEGY=async|Execution history requires/i)\n expect(Array.isArray(executionsBody.items)).toBe(true)\n }\n } finally {\n if (scheduleId) {\n await apiRequest(request, 'DELETE', '/api/scheduler/jobs', {\n token,\n data: { id: scheduleId },\n }).catch(() => null)\n }\n }\n })\n\n test('validates queue names and async-only queue job access', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const invalidQueueResponse = await apiRequest(\n request,\n 'GET',\n '/api/scheduler/queue-jobs/non-existent?queue=not-a-registered-queue',\n { token },\n )\n expect(invalidQueueResponse.status()).toBe(400)\n const invalidQueueBody = (await invalidQueueResponse.json()) as SchedulerErrorResponse\n expect(invalidQueueBody.error ?? '').toMatch(/Invalid queue name/i)\n\n const validQueueResponse = await apiRequest(\n request,\n 'GET',\n '/api/scheduler/queue-jobs/non-existent?queue=scheduler-execution',\n { token },\n )\n\n if (isAsyncQueueStrategy) {\n expect(validQueueResponse.status()).toBe(404)\n const validQueueBody = (await validQueueResponse.json()) as SchedulerErrorResponse\n expect(validQueueBody.error ?? '').toMatch(/Job not found/i)\n } else {\n expect(validQueueResponse.status()).toBe(400)\n const validQueueBody = (await validQueueResponse.json()) as SchedulerErrorResponse\n expect(validQueueBody.error ?? '').toMatch(/QUEUE_STRATEGY=async|BullMQ job logs are only available/i)\n expect(validQueueBody.available).toBe(false)\n }\n })\n})\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'\n\nconst isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || 'local') === 'async'\n\ntype CreateScheduleResponse = { id?: string }\ntype SchedulerListResponse = { items?: Array<{ id?: string }> }\ntype SchedulerErrorResponse = { error?: string; message?: string; available?: boolean; items?: unknown[] }\n\nasync function createSchedule(request: import('@playwright/test').APIRequestContext, token: string) {\n const response = await apiRequest(request, 'POST', '/api/scheduler/jobs', {\n token,\n data: {\n name: `Integration Scheduler ${Date.now()}`,\n description: 'Runtime Redis URL integration probe',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'command',\n targetCommand: 'scheduler.test.echo',\n targetPayload: { source: 'integration-test' },\n isEnabled: true,\n sourceType: 'user',\n },\n })\n\n expect(response.status()).toBe(201)\n const body = (await response.json()) as CreateScheduleResponse\n expect(body.id).toMatch(/^[0-9a-f-]{36}$/i)\n return String(body.id)\n}\n\ntest.describe('TC-SCHED-001: Scheduler runtime queue APIs', () => {\n test('lists created schedules and exposes execution-history endpoint semantics', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n scheduleId = await createSchedule(request, token)\n\n const listResponse = await apiRequest(request, 'GET', `/api/scheduler/jobs?id=${scheduleId}`, { token })\n expect(listResponse.status()).toBe(200)\n const listBody = (await listResponse.json()) as SchedulerListResponse\n const ids = (listBody.items ?? []).map((item) => item.id)\n expect(ids).toContain(scheduleId)\n\n const executionsResponse = await apiRequest(\n request,\n 'GET',\n `/api/scheduler/jobs/${scheduleId}/executions?pageSize=5`,\n { token },\n )\n\n if (isAsyncQueueStrategy) {\n expect(executionsResponse.status()).toBe(200)\n const executionsBody = (await executionsResponse.json()) as { items?: unknown[]; total?: number; pageSize?: number }\n expect(Array.isArray(executionsBody.items)).toBe(true)\n expect(typeof executionsBody.total).toBe('number')\n expect(executionsBody.pageSize).toBe(5)\n } else {\n expect(executionsResponse.status()).toBe(400)\n const executionsBody = (await executionsResponse.json()) as SchedulerErrorResponse\n expect(executionsBody.error ?? '').toMatch(/QUEUE_STRATEGY=async|Execution history requires/i)\n expect(Array.isArray(executionsBody.items)).toBe(true)\n }\n } finally {\n if (scheduleId) {\n await apiRequest(request, 'DELETE', '/api/scheduler/jobs', {\n token,\n data: { id: scheduleId },\n }).catch(() => null)\n }\n }\n })\n\n test('validates queue names and async-only queue job access', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const invalidQueueResponse = await apiRequest(\n request,\n 'GET',\n '/api/scheduler/queue-jobs/non-existent?queue=not-a-registered-queue',\n { token },\n )\n expect(invalidQueueResponse.status()).toBe(400)\n const invalidQueueBody = (await invalidQueueResponse.json()) as SchedulerErrorResponse\n expect(invalidQueueBody.error ?? '').toMatch(/Invalid queue name/i)\n\n const validQueueResponse = await apiRequest(\n request,\n 'GET',\n '/api/scheduler/queue-jobs/non-existent?queue=scheduler-execution',\n { token },\n )\n\n if (isAsyncQueueStrategy) {\n expect(validQueueResponse.status()).toBe(404)\n const validQueueBody = (await validQueueResponse.json()) as SchedulerErrorResponse\n expect(validQueueBody.error ?? '').toMatch(/Job not found/i)\n } else {\n expect(validQueueResponse.status()).toBe(400)\n const validQueueBody = (await validQueueResponse.json()) as SchedulerErrorResponse\n expect(validQueueBody.error ?? '').toMatch(/QUEUE_STRATEGY=async|BullMQ job logs are only available/i)\n expect(validQueueBody.available).toBe(false)\n }\n })\n})\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -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.js";
4
+ import {
5
+ SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,
6
+ SCHEDULER_TARGETS_PATH
7
+ } from "./helpers/scheduler.js";
5
8
  function expectSortedByValue(options, label) {
6
9
  const values = options.map((option) => option.value);
7
10
  expect(values, `${label} should be sorted alphabetically by value`).toEqual(
@@ -15,7 +18,7 @@ function expectOptionShape(options, label) {
15
18
  }
16
19
  }
17
20
  test.describe("TC-SCHED-004: GET /api/scheduler/targets lists queues and commands", () => {
18
- test("returns sorted queues (incl. scheduler-execution) and a non-empty command list", async ({ request }) => {
21
+ test("returns only scheduler-safe queues plus a non-empty sorted command list", async ({ request }) => {
19
22
  const token = await getAuthToken(request, "admin");
20
23
  const response = await apiRequest(request, "GET", SCHEDULER_TARGETS_PATH, { token });
21
24
  expect(response.status()).toBe(200);
@@ -23,10 +26,12 @@ test.describe("TC-SCHED-004: GET /api/scheduler/targets lists queues and command
23
26
  const queues = body?.queues ?? [];
24
27
  const commands = body?.commands ?? [];
25
28
  expect(Array.isArray(body?.queues)).toBe(true);
26
- expect(queues.length).toBeGreaterThan(0);
27
29
  expectOptionShape(queues, "queues");
28
- expect(queues.map((queue) => queue.value)).toContain(SCHEDULER_EXECUTION_QUEUE);
29
30
  expectSortedByValue(queues, "queues");
31
+ const queueValues = queues.map((queue) => queue.value);
32
+ for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
33
+ expect(queueValues, `internal queue ${forbidden} must not be a scheduler target`).not.toContain(forbidden);
34
+ }
30
35
  expect(Array.isArray(body?.commands)).toBe(true);
31
36
  expect(commands.length).toBeGreaterThan(0);
32
37
  expectOptionShape(commands, "commands");
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts"],
4
- "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_TARGETS_PATH } from './helpers/scheduler'\n\ntype TargetOption = { value: string; label: string }\ntype TargetsResponse = { queues?: TargetOption[]; commands?: TargetOption[] }\n\nfunction expectSortedByValue(options: TargetOption[], label: string) {\n const values = options.map((option) => option.value)\n expect(values, `${label} should be sorted alphabetically by value`).toEqual(\n [...values].sort((a, b) => a.localeCompare(b)),\n )\n}\n\nfunction expectOptionShape(options: TargetOption[], label: string) {\n for (const option of options) {\n expect(typeof option.value, `${label} option value should be a string`).toBe('string')\n expect(typeof option.label, `${label} option label should be a string`).toBe('string')\n }\n}\n\n/**\n * TC-SCHED-004: GET /api/scheduler/targets returns available queue names\n * (from module workers) and registered command IDs (from the command\n * registry), both sorted alphabetically by value. Read-only, no fixtures.\n */\ntest.describe('TC-SCHED-004: GET /api/scheduler/targets lists queues and commands', () => {\n test('returns sorted queues (incl. scheduler-execution) and a non-empty command list', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })\n expect(response.status()).toBe(200)\n\n const body = await readJsonSafe<TargetsResponse>(response)\n const queues = body?.queues ?? []\n const commands = body?.commands ?? []\n\n // Queues: non-empty, well-shaped, sorted, and include the known scheduler queue.\n expect(Array.isArray(body?.queues)).toBe(true)\n expect(queues.length).toBeGreaterThan(0)\n expectOptionShape(queues, 'queues')\n expect(queues.map((queue) => queue.value)).toContain(SCHEDULER_EXECUTION_QUEUE)\n expectSortedByValue(queues, 'queues')\n\n // Commands: non-empty, well-shaped, and sorted.\n expect(Array.isArray(body?.commands)).toBe(true)\n expect(commands.length).toBeGreaterThan(0)\n expectOptionShape(commands, 'commands')\n expectSortedByValue(commands, 'commands')\n })\n})\n"],
5
- "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B,8BAA8B;AAKlE,SAAS,oBAAoB,SAAyB,OAAe;AACnE,QAAM,SAAS,QAAQ,IAAI,CAAC,WAAW,OAAO,KAAK;AACnD,SAAO,QAAQ,GAAG,KAAK,2CAA2C,EAAE;AAAA,IAClE,CAAC,GAAG,MAAM,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAAA,EAC/C;AACF;AAEA,SAAS,kBAAkB,SAAyB,OAAe;AACjE,aAAW,UAAU,SAAS;AAC5B,WAAO,OAAO,OAAO,OAAO,GAAG,KAAK,kCAAkC,EAAE,KAAK,QAAQ;AACrF,WAAO,OAAO,OAAO,OAAO,GAAG,KAAK,kCAAkC,EAAE,KAAK,QAAQ;AAAA,EACvF;AACF;AAOA,KAAK,SAAS,sEAAsE,MAAM;AACxF,OAAK,kFAAkF,OAAO,EAAE,QAAQ,MAAM;AAC5G,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,UAAM,WAAW,MAAM,WAAW,SAAS,OAAO,wBAAwB,EAAE,MAAM,CAAC;AACnF,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAElC,UAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,UAAM,SAAS,MAAM,UAAU,CAAC;AAChC,UAAM,WAAW,MAAM,YAAY,CAAC;AAGpC,WAAO,MAAM,QAAQ,MAAM,MAAM,CAAC,EAAE,KAAK,IAAI;AAC7C,WAAO,OAAO,MAAM,EAAE,gBAAgB,CAAC;AACvC,sBAAkB,QAAQ,QAAQ;AAClC,WAAO,OAAO,IAAI,CAAC,UAAU,MAAM,KAAK,CAAC,EAAE,UAAU,yBAAyB;AAC9E,wBAAoB,QAAQ,QAAQ;AAGpC,WAAO,MAAM,QAAQ,MAAM,QAAQ,CAAC,EAAE,KAAK,IAAI;AAC/C,WAAO,SAAS,MAAM,EAAE,gBAAgB,CAAC;AACzC,sBAAkB,UAAU,UAAU;AACtC,wBAAoB,UAAU,UAAU;AAAA,EAC1C,CAAC;AACH,CAAC;",
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,\n SCHEDULER_TARGETS_PATH,\n} from './helpers/scheduler'\n\ntype TargetOption = { value: string; label: string }\ntype TargetsResponse = { queues?: TargetOption[]; commands?: TargetOption[] }\n\nfunction expectSortedByValue(options: TargetOption[], label: string) {\n const values = options.map((option) => option.value)\n expect(values, `${label} should be sorted alphabetically by value`).toEqual(\n [...values].sort((a, b) => a.localeCompare(b)),\n )\n}\n\nfunction expectOptionShape(options: TargetOption[], label: string) {\n for (const option of options) {\n expect(typeof option.value, `${label} option value should be a string`).toBe('string')\n expect(typeof option.label, `${label} option label should be a string`).toBe('string')\n }\n}\n\n/**\n * TC-SCHED-004: GET /api/scheduler/targets returns scheduler-safe queue names\n * and registered command IDs (from the command registry), both sorted\n * alphabetically by value. Internal/system workers must stay undiscoverable.\n * Read-only, no fixtures.\n */\ntest.describe('TC-SCHED-004: GET /api/scheduler/targets lists queues and commands', () => {\n test('returns only scheduler-safe queues plus a non-empty sorted command list', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })\n expect(response.status()).toBe(200)\n\n const body = await readJsonSafe<TargetsResponse>(response)\n const queues = body?.queues ?? []\n const commands = body?.commands ?? []\n\n // Queues: well-shaped, sorted, and free of internal/system worker queues (#5213).\n expect(Array.isArray(body?.queues)).toBe(true)\n expectOptionShape(queues, 'queues')\n expectSortedByValue(queues, 'queues')\n const queueValues = queues.map((queue) => queue.value)\n for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {\n expect(queueValues, `internal queue ${forbidden} must not be a scheduler target`).not.toContain(forbidden)\n }\n\n // Commands: non-empty, well-shaped, and sorted.\n expect(Array.isArray(body?.commands)).toBe(true)\n expect(commands.length).toBeGreaterThan(0)\n expectOptionShape(commands, 'commands')\n expectSortedByValue(commands, 'commands')\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAKP,SAAS,oBAAoB,SAAyB,OAAe;AACnE,QAAM,SAAS,QAAQ,IAAI,CAAC,WAAW,OAAO,KAAK;AACnD,SAAO,QAAQ,GAAG,KAAK,2CAA2C,EAAE;AAAA,IAClE,CAAC,GAAG,MAAM,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAAA,EAC/C;AACF;AAEA,SAAS,kBAAkB,SAAyB,OAAe;AACjE,aAAW,UAAU,SAAS;AAC5B,WAAO,OAAO,OAAO,OAAO,GAAG,KAAK,kCAAkC,EAAE,KAAK,QAAQ;AACrF,WAAO,OAAO,OAAO,OAAO,GAAG,KAAK,kCAAkC,EAAE,KAAK,QAAQ;AAAA,EACvF;AACF;AAQA,KAAK,SAAS,sEAAsE,MAAM;AACxF,OAAK,2EAA2E,OAAO,EAAE,QAAQ,MAAM;AACrG,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,UAAM,WAAW,MAAM,WAAW,SAAS,OAAO,wBAAwB,EAAE,MAAM,CAAC;AACnF,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAElC,UAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,UAAM,SAAS,MAAM,UAAU,CAAC;AAChC,UAAM,WAAW,MAAM,YAAY,CAAC;AAGpC,WAAO,MAAM,QAAQ,MAAM,MAAM,CAAC,EAAE,KAAK,IAAI;AAC7C,sBAAkB,QAAQ,QAAQ;AAClC,wBAAoB,QAAQ,QAAQ;AACpC,UAAM,cAAc,OAAO,IAAI,CAAC,UAAU,MAAM,KAAK;AACrD,eAAW,aAAa,qCAAqC;AAC3D,aAAO,aAAa,kBAAkB,SAAS,iCAAiC,EAAE,IAAI,UAAU,SAAS;AAAA,IAC3G;AAGA,WAAO,MAAM,QAAQ,MAAM,QAAQ,CAAC,EAAE,KAAK,IAAI;AAC/C,WAAO,SAAS,MAAM,EAAE,gBAAgB,CAAC;AACzC,sBAAkB,UAAU,UAAU;AACtC,wBAAoB,UAAU,UAAU;AAAA,EAC1C,CAAC;AACH,CAAC;",
6
6
  "names": []
7
7
  }
@@ -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.js";
4
+ import { SCHEDULER_JOBS_PATH, SCHEDULER_TEST_COMMAND, uniqueScheduleName } from "./helpers/scheduler.js";
5
5
  const invalidCases = [
6
6
  { label: "cron: not a cron expression", scheduleType: "cron", scheduleValue: "not-a-cron" },
7
7
  { label: "cron: too few fields", scheduleType: "cron", scheduleValue: "* * *" },
@@ -23,8 +23,8 @@ test.describe("TC-SCHED-007: POST /api/scheduler/jobs validates schedule value f
23
23
  scheduleType: testCase.scheduleType,
24
24
  scheduleValue: testCase.scheduleValue,
25
25
  timezone: "UTC",
26
- targetType: "queue",
27
- targetQueue: SCHEDULER_EXECUTION_QUEUE,
26
+ targetType: "command",
27
+ targetCommand: SCHEDULER_TEST_COMMAND,
28
28
  isEnabled: true,
29
29
  sourceType: "user"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts"],
4
- "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_JOBS_PATH, uniqueScheduleName } from './helpers/scheduler'\n\ntype ValidationError = { error?: string; details?: Array<{ path?: Array<string | number> }> }\n\n/**\n * TC-SCHED-007: POST /api/scheduler/jobs rejects invalid cron and interval\n * `scheduleValue`s with 400 and an error scoped to the `scheduleValue` path.\n *\n * Interval format is `<number><unit>` with unit in s|m|h|d and a minimum floor\n * of one minute. All cases are rejected at validation, so no records are\n * created and no teardown is required.\n */\nconst invalidCases = [\n { label: 'cron: not a cron expression', scheduleType: 'cron' as const, scheduleValue: 'not-a-cron' },\n { label: 'cron: too few fields', scheduleType: 'cron' as const, scheduleValue: '* * *' },\n { label: 'interval: unsupported unit', scheduleType: 'interval' as const, scheduleValue: '15x' },\n { label: 'interval: missing unit', scheduleType: 'interval' as const, scheduleValue: '99' },\n { label: 'interval: zero seconds', scheduleType: 'interval' as const, scheduleValue: '0s' },\n { label: 'interval: one second', scheduleType: 'interval' as const, scheduleValue: '1s' },\n { label: 'interval: below one minute', scheduleType: 'interval' as const, scheduleValue: '59s' },\n]\n\ntest.describe('TC-SCHED-007: POST /api/scheduler/jobs validates schedule value format', () => {\n for (const testCase of invalidCases) {\n test(`rejects ${testCase.label} with 400 on scheduleValue`, async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, {\n token,\n data: {\n name: uniqueScheduleName('Invalid Schedule Value'),\n scopeType: 'organization',\n scheduleType: testCase.scheduleType,\n scheduleValue: testCase.scheduleValue,\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: SCHEDULER_EXECUTION_QUEUE,\n isEnabled: true,\n sourceType: 'user',\n },\n })\n\n expect(response.status(), `${testCase.label} should be rejected with 400`).toBe(400)\n const body = await readJsonSafe<ValidationError>(response)\n const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join('.'))\n expect(issuePaths, `${testCase.label} error should target scheduleValue`).toContain('scheduleValue')\n })\n }\n})\n"],
5
- "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B,qBAAqB,0BAA0B;AAYnF,MAAM,eAAe;AAAA,EACnB,EAAE,OAAO,+BAA+B,cAAc,QAAiB,eAAe,aAAa;AAAA,EACnG,EAAE,OAAO,wBAAwB,cAAc,QAAiB,eAAe,QAAQ;AAAA,EACvF,EAAE,OAAO,8BAA8B,cAAc,YAAqB,eAAe,MAAM;AAAA,EAC/F,EAAE,OAAO,0BAA0B,cAAc,YAAqB,eAAe,KAAK;AAAA,EAC1F,EAAE,OAAO,0BAA0B,cAAc,YAAqB,eAAe,KAAK;AAAA,EAC1F,EAAE,OAAO,wBAAwB,cAAc,YAAqB,eAAe,KAAK;AAAA,EACxF,EAAE,OAAO,8BAA8B,cAAc,YAAqB,eAAe,MAAM;AACjG;AAEA,KAAK,SAAS,0EAA0E,MAAM;AAC5F,aAAW,YAAY,cAAc;AACnC,SAAK,WAAW,SAAS,KAAK,8BAA8B,OAAO,EAAE,QAAQ,MAAM;AACjF,YAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,YAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,qBAAqB;AAAA,QACtE;AAAA,QACA,MAAM;AAAA,UACJ,MAAM,mBAAmB,wBAAwB;AAAA,UACjD,WAAW;AAAA,UACX,cAAc,SAAS;AAAA,UACvB,eAAe,SAAS;AAAA,UACxB,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,aAAa;AAAA,UACb,WAAW;AAAA,UACX,YAAY;AAAA,QACd;AAAA,MACF,CAAC;AAED,aAAO,SAAS,OAAO,GAAG,GAAG,SAAS,KAAK,8BAA8B,EAAE,KAAK,GAAG;AACnF,YAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,YAAM,cAAc,MAAM,WAAW,CAAC,GAAG,IAAI,CAAC,WAAW,MAAM,QAAQ,CAAC,GAAG,KAAK,GAAG,CAAC;AACpF,aAAO,YAAY,GAAG,SAAS,KAAK,oCAAoC,EAAE,UAAU,eAAe;AAAA,IACrG,CAAC;AAAA,EACH;AACF,CAAC;",
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { SCHEDULER_JOBS_PATH, SCHEDULER_TEST_COMMAND, uniqueScheduleName } from './helpers/scheduler'\n\ntype ValidationError = { error?: string; details?: Array<{ path?: Array<string | number> }> }\n\n/**\n * TC-SCHED-007: POST /api/scheduler/jobs rejects invalid cron and interval\n * `scheduleValue`s with 400 and an error scoped to the `scheduleValue` path.\n *\n * Interval format is `<number><unit>` with unit in s|m|h|d and a minimum floor\n * of one minute. All cases are rejected at validation, so no records are\n * created and no teardown is required.\n */\nconst invalidCases = [\n { label: 'cron: not a cron expression', scheduleType: 'cron' as const, scheduleValue: 'not-a-cron' },\n { label: 'cron: too few fields', scheduleType: 'cron' as const, scheduleValue: '* * *' },\n { label: 'interval: unsupported unit', scheduleType: 'interval' as const, scheduleValue: '15x' },\n { label: 'interval: missing unit', scheduleType: 'interval' as const, scheduleValue: '99' },\n { label: 'interval: zero seconds', scheduleType: 'interval' as const, scheduleValue: '0s' },\n { label: 'interval: one second', scheduleType: 'interval' as const, scheduleValue: '1s' },\n { label: 'interval: below one minute', scheduleType: 'interval' as const, scheduleValue: '59s' },\n]\n\ntest.describe('TC-SCHED-007: POST /api/scheduler/jobs validates schedule value format', () => {\n for (const testCase of invalidCases) {\n test(`rejects ${testCase.label} with 400 on scheduleValue`, async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, {\n token,\n data: {\n name: uniqueScheduleName('Invalid Schedule Value'),\n scopeType: 'organization',\n scheduleType: testCase.scheduleType,\n scheduleValue: testCase.scheduleValue,\n timezone: 'UTC',\n targetType: 'command',\n targetCommand: SCHEDULER_TEST_COMMAND,\n isEnabled: true,\n sourceType: 'user',\n },\n })\n\n expect(response.status(), `${testCase.label} should be rejected with 400`).toBe(400)\n const body = await readJsonSafe<ValidationError>(response)\n const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join('.'))\n expect(issuePaths, `${testCase.label} error should target scheduleValue`).toContain('scheduleValue')\n })\n }\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B,SAAS,qBAAqB,wBAAwB,0BAA0B;AAYhF,MAAM,eAAe;AAAA,EACnB,EAAE,OAAO,+BAA+B,cAAc,QAAiB,eAAe,aAAa;AAAA,EACnG,EAAE,OAAO,wBAAwB,cAAc,QAAiB,eAAe,QAAQ;AAAA,EACvF,EAAE,OAAO,8BAA8B,cAAc,YAAqB,eAAe,MAAM;AAAA,EAC/F,EAAE,OAAO,0BAA0B,cAAc,YAAqB,eAAe,KAAK;AAAA,EAC1F,EAAE,OAAO,0BAA0B,cAAc,YAAqB,eAAe,KAAK;AAAA,EAC1F,EAAE,OAAO,wBAAwB,cAAc,YAAqB,eAAe,KAAK;AAAA,EACxF,EAAE,OAAO,8BAA8B,cAAc,YAAqB,eAAe,MAAM;AACjG;AAEA,KAAK,SAAS,0EAA0E,MAAM;AAC5F,aAAW,YAAY,cAAc;AACnC,SAAK,WAAW,SAAS,KAAK,8BAA8B,OAAO,EAAE,QAAQ,MAAM;AACjF,YAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,YAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,qBAAqB;AAAA,QACtE;AAAA,QACA,MAAM;AAAA,UACJ,MAAM,mBAAmB,wBAAwB;AAAA,UACjD,WAAW;AAAA,UACX,cAAc,SAAS;AAAA,UACvB,eAAe,SAAS;AAAA,UACxB,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,eAAe;AAAA,UACf,WAAW;AAAA,UACX,YAAY;AAAA,QACd;AAAA,MACF,CAAC;AAED,aAAO,SAAS,OAAO,GAAG,GAAG,SAAS,KAAK,8BAA8B,EAAE,KAAK,GAAG;AACnF,YAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,YAAM,cAAc,MAAM,WAAW,CAAC,GAAG,IAAI,CAAC,WAAW,MAAM,QAAQ,CAAC,GAAG,KAAK,GAAG,CAAC;AACpF,aAAO,YAAY,GAAG,SAAS,KAAK,oCAAoC,EAAE,UAAU,eAAe;AAAA,IACrG,CAAC;AAAA,EACH;AACF,CAAC;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,72 @@
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.js";
13
+ test.describe("TC-SCHED-008: scheduler queue targets restricted to safe workers", () => {
14
+ for (const forbiddenQueue of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
15
+ test(`rejects creating a schedule targeting the ${forbiddenQueue} worker`, async ({ request }) => {
16
+ const token = await getAuthToken(request, "admin");
17
+ const response = await apiRequest(request, "POST", SCHEDULER_JOBS_PATH, {
18
+ token,
19
+ data: {
20
+ name: uniqueScheduleName("Forbidden Queue Target"),
21
+ scopeType: "organization",
22
+ scheduleType: "interval",
23
+ scheduleValue: "15m",
24
+ timezone: "UTC",
25
+ targetType: "queue",
26
+ targetQueue: forbiddenQueue,
27
+ targetPayload: { scope: { tenantId: "forged", organizationId: "forged" } },
28
+ isEnabled: true,
29
+ sourceType: "user"
30
+ }
31
+ });
32
+ expect(response.status(), `${forbiddenQueue} must not be schedulable`).toBe(400);
33
+ const body = await readJsonSafe(response);
34
+ const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join("."));
35
+ expect(issuePaths).toContain("targetQueue");
36
+ });
37
+ }
38
+ test("rejects retargeting an existing schedule to an internal worker queue", async ({ request }) => {
39
+ const token = await getAuthToken(request, "admin");
40
+ let scheduleId = null;
41
+ try {
42
+ scheduleId = await createScheduleJob(request, token, { name: uniqueScheduleName("Retarget Guard") });
43
+ const response = await apiRequest(request, "PUT", SCHEDULER_JOBS_PATH, {
44
+ token,
45
+ data: {
46
+ id: scheduleId,
47
+ targetType: "queue",
48
+ targetQueue: "stripe-webhook"
49
+ }
50
+ });
51
+ expect(response.status()).toBe(422);
52
+ const body = await readJsonSafe(response);
53
+ expect(body?.error ?? "").toMatch(/not an approved scheduler target/i);
54
+ const unchanged = await getScheduleJobById(request, token, scheduleId);
55
+ expect(unchanged).not.toBeNull();
56
+ expect(unchanged.targetType).toBe("command");
57
+ } finally {
58
+ await deleteScheduleJob(request, token, scheduleId);
59
+ }
60
+ });
61
+ test("targets endpoint keeps internal worker queues undiscoverable", async ({ request }) => {
62
+ const token = await getAuthToken(request, "admin");
63
+ const response = await apiRequest(request, "GET", SCHEDULER_TARGETS_PATH, { token });
64
+ expect(response.status()).toBe(200);
65
+ const body = await readJsonSafe(response);
66
+ const values = (body?.queues ?? []).map((queue) => queue.value);
67
+ for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
68
+ expect(values).not.toContain(forbidden);
69
+ }
70
+ });
71
+ });
72
+ //# sourceMappingURL=TC-SCHED-008-safe-targets.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,\n SCHEDULER_JOBS_PATH,\n SCHEDULER_TARGETS_PATH,\n createScheduleJob,\n deleteScheduleJob,\n getScheduleJobById,\n uniqueScheduleName,\n} from './helpers/scheduler'\n\n/**\n * TC-SCHED-008: Scheduler queue targets are restricted to authorized safe\n * workers (#5213). A principal holding only `scheduler.jobs.manage` must not be\n * able to aim a schedule at an internal/system worker queue with an arbitrary\n * payload \u2014 neither at creation, nor by retargeting later.\n */\ntest.describe('TC-SCHED-008: scheduler queue targets restricted to safe workers', () => {\n for (const forbiddenQueue of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {\n test(`rejects creating a schedule targeting the ${forbiddenQueue} worker`, async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, {\n token,\n data: {\n name: uniqueScheduleName('Forbidden Queue Target'),\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: forbiddenQueue,\n targetPayload: { scope: { tenantId: 'forged', organizationId: 'forged' } },\n isEnabled: true,\n sourceType: 'user',\n },\n })\n\n expect(response.status(), `${forbiddenQueue} must not be schedulable`).toBe(400)\n const body = await readJsonSafe<{ error?: string; details?: Array<{ path?: Array<string | number> }> }>(response)\n const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join('.'))\n expect(issuePaths).toContain('targetQueue')\n })\n }\n\n test('rejects retargeting an existing schedule to an internal worker queue', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n scheduleId = await createScheduleJob(request, token, { name: uniqueScheduleName('Retarget Guard') })\n\n const response = await apiRequest(request, 'PUT', SCHEDULER_JOBS_PATH, {\n token,\n data: {\n id: scheduleId,\n targetType: 'queue',\n targetQueue: 'stripe-webhook',\n },\n })\n\n // Enforcement lives in the update command (change-detection vs the stored\n // row), so an actual retarget lands as 422 with the approval error.\n expect(response.status()).toBe(422)\n const body = await readJsonSafe<{ error?: string }>(response)\n expect(body?.error ?? '').toMatch(/not an approved scheduler target/i)\n\n const unchanged = await getScheduleJobById(request, token, scheduleId)\n expect(unchanged).not.toBeNull()\n expect(unchanged!.targetType).toBe('command')\n } finally {\n await deleteScheduleJob(request, token, scheduleId)\n }\n })\n\n test('targets endpoint keeps internal worker queues undiscoverable', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })\n expect(response.status()).toBe(200)\n\n const body = await readJsonSafe<{ queues?: Array<{ value: string }> }>(response)\n const values = (body?.queues ?? []).map((queue) => queue.value)\n for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {\n expect(values).not.toContain(forbidden)\n }\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAQP,KAAK,SAAS,oEAAoE,MAAM;AACtF,aAAW,kBAAkB,qCAAqC;AAChE,SAAK,6CAA6C,cAAc,WAAW,OAAO,EAAE,QAAQ,MAAM;AAChG,YAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,YAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,qBAAqB;AAAA,QACtE;AAAA,QACA,MAAM;AAAA,UACJ,MAAM,mBAAmB,wBAAwB;AAAA,UACjD,WAAW;AAAA,UACX,cAAc;AAAA,UACd,eAAe;AAAA,UACf,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,aAAa;AAAA,UACb,eAAe,EAAE,OAAO,EAAE,UAAU,UAAU,gBAAgB,SAAS,EAAE;AAAA,UACzE,WAAW;AAAA,UACX,YAAY;AAAA,QACd;AAAA,MACF,CAAC;AAED,aAAO,SAAS,OAAO,GAAG,GAAG,cAAc,0BAA0B,EAAE,KAAK,GAAG;AAC/E,YAAM,OAAO,MAAM,aAAqF,QAAQ;AAChH,YAAM,cAAc,MAAM,WAAW,CAAC,GAAG,IAAI,CAAC,WAAW,MAAM,QAAQ,CAAC,GAAG,KAAK,GAAG,CAAC;AACpF,aAAO,UAAU,EAAE,UAAU,aAAa;AAAA,IAC5C,CAAC;AAAA,EACH;AAEA,OAAK,wEAAwE,OAAO,EAAE,QAAQ,MAAM;AAClG,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAEhC,QAAI;AACF,mBAAa,MAAM,kBAAkB,SAAS,OAAO,EAAE,MAAM,mBAAmB,gBAAgB,EAAE,CAAC;AAEnG,YAAM,WAAW,MAAM,WAAW,SAAS,OAAO,qBAAqB;AAAA,QACrE;AAAA,QACA,MAAM;AAAA,UACJ,IAAI;AAAA,UACJ,YAAY;AAAA,UACZ,aAAa;AAAA,QACf;AAAA,MACF,CAAC;AAID,aAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,YAAM,OAAO,MAAM,aAAiC,QAAQ;AAC5D,aAAO,MAAM,SAAS,EAAE,EAAE,QAAQ,mCAAmC;AAErE,YAAM,YAAY,MAAM,mBAAmB,SAAS,OAAO,UAAU;AACrE,aAAO,SAAS,EAAE,IAAI,SAAS;AAC/B,aAAO,UAAW,UAAU,EAAE,KAAK,SAAS;AAAA,IAC9C,UAAE;AACA,YAAM,kBAAkB,SAAS,OAAO,UAAU;AAAA,IACpD;AAAA,EACF,CAAC;AAED,OAAK,gEAAgE,OAAO,EAAE,QAAQ,MAAM;AAC1F,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,UAAM,WAAW,MAAM,WAAW,SAAS,OAAO,wBAAwB,EAAE,MAAM,CAAC;AACnF,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAElC,UAAM,OAAO,MAAM,aAAoD,QAAQ;AAC/E,UAAM,UAAU,MAAM,UAAU,CAAC,GAAG,IAAI,CAAC,UAAU,MAAM,KAAK;AAC9D,eAAW,aAAa,qCAAqC;AAC3D,aAAO,MAAM,EAAE,IAAI,UAAU,SAAS;AAAA,IACxC;AAAA,EACF,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,66 @@
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.js";
11
+ const isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || "local") === "async";
12
+ test.describe("TC-SCHED-008: system-scoped schedule access on trigger and executions", () => {
13
+ test("super admin reaches the schedule on both routes; a non-super-admin is forbidden", async ({ request }) => {
14
+ const superToken = await getAuthToken(request, "superadmin");
15
+ const adminToken = await getAuthToken(request, "admin");
16
+ let scheduleId = null;
17
+ try {
18
+ scheduleId = await createScheduleJob(request, superToken, {
19
+ name: uniqueScheduleName("System-Trigger-Test"),
20
+ scopeType: "system",
21
+ scheduleType: "interval",
22
+ scheduleValue: "1h"
23
+ });
24
+ const superResponse = await apiRequest(request, "POST", SCHEDULER_TRIGGER_PATH, {
25
+ token: superToken,
26
+ data: { id: scheduleId }
27
+ });
28
+ const superBody = await readJsonSafe(superResponse);
29
+ expect(
30
+ superResponse.status(),
31
+ "a super admin must not be told a system-scoped schedule does not exist"
32
+ ).not.toBe(404);
33
+ if (isAsyncQueueStrategy) {
34
+ expect(superResponse.status()).toBe(200);
35
+ expect(superBody?.ok).toBe(true);
36
+ expect(typeof superBody?.jobId === "string" && superBody.jobId.length > 0).toBe(true);
37
+ } else {
38
+ expect(superResponse.status()).toBe(400);
39
+ expect(superBody?.error ?? "").toMatch(/async/i);
40
+ }
41
+ const adminResponse = await apiRequest(request, "POST", SCHEDULER_TRIGGER_PATH, {
42
+ token: adminToken,
43
+ data: { id: scheduleId }
44
+ });
45
+ expect(
46
+ adminResponse.status(),
47
+ "a non-super-admin must be forbidden from triggering a system-scoped schedule"
48
+ ).toBe(403);
49
+ const executionsPath = `${SCHEDULER_JOBS_PATH}/${scheduleId}/executions`;
50
+ const superExecutions = await apiRequest(request, "GET", executionsPath, { token: superToken });
51
+ expect(
52
+ superExecutions.status(),
53
+ "a super admin must not be told a system-scoped schedule has no execution history route"
54
+ ).not.toBe(404);
55
+ expect(superExecutions.status()).toBe(isAsyncQueueStrategy ? 200 : 400);
56
+ const adminExecutions = await apiRequest(request, "GET", executionsPath, { token: adminToken });
57
+ expect(
58
+ adminExecutions.status(),
59
+ "a non-super-admin must be forbidden from reading a system-scoped execution history"
60
+ ).toBe(403);
61
+ } finally {
62
+ await deleteScheduleJob(request, superToken, scheduleId);
63
+ }
64
+ });
65
+ });
66
+ //# sourceMappingURL=TC-SCHED-008.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n SCHEDULER_JOBS_PATH,\n SCHEDULER_TRIGGER_PATH,\n createScheduleJob,\n deleteScheduleJob,\n uniqueScheduleName,\n} from './helpers/scheduler'\n\nconst isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || 'local') === 'async'\n\ntype TriggerBody = { ok?: boolean; jobId?: string; error?: string; message?: string }\n\n/**\n * TC-SCHED-008: POST /api/scheduler/trigger and GET /api/scheduler/jobs/[id]/executions\n * resolve the scope of a system-scoped schedule on the loaded row, not in the lookup.\n *\n * A system-scoped schedule has `tenantId === null` and `organizationId === null`. When both\n * routes folded the caller's tenant/org into the `where` clause, no such row could ever match\n * and every caller \u2014 super admins included \u2014 got 404, which also masked each route's own\n * super-admin gate. A super admin must now reach the schedule, and a non-super-admin must be\n * told 403 rather than 404.\n *\n * The super-admin assertions hold under either queue strategy: each route's\n * `QUEUE_STRATEGY=async` check sits after the lookup, so `local` yields the pre-existing\n * 400 \u2014 never 404.\n */\ntest.describe('TC-SCHED-008: system-scoped schedule access on trigger and executions', () => {\n test('super admin reaches the schedule on both routes; a non-super-admin is forbidden', async ({ request }) => {\n const superToken = await getAuthToken(request, 'superadmin')\n const adminToken = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n // Creating a system-scoped schedule is itself super-admin only.\n scheduleId = await createScheduleJob(request, superToken, {\n name: uniqueScheduleName('System-Trigger-Test'),\n scopeType: 'system',\n scheduleType: 'interval',\n scheduleValue: '1h',\n })\n\n const superResponse = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {\n token: superToken,\n data: { id: scheduleId },\n })\n const superBody = await readJsonSafe<TriggerBody>(superResponse)\n expect(\n superResponse.status(),\n 'a super admin must not be told a system-scoped schedule does not exist',\n ).not.toBe(404)\n\n if (isAsyncQueueStrategy) {\n expect(superResponse.status()).toBe(200)\n expect(superBody?.ok).toBe(true)\n expect(typeof superBody?.jobId === 'string' && superBody.jobId.length > 0).toBe(true)\n } else {\n // The strategy check runs after the scope check, so this 400 is the response the\n // 404 used to mask.\n expect(superResponse.status()).toBe(400)\n expect(superBody?.error ?? '').toMatch(/async/i)\n }\n\n // The same schedule is visible enough to be refused, not hidden, from a tenant admin.\n const adminResponse = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {\n token: adminToken,\n data: { id: scheduleId },\n })\n expect(\n adminResponse.status(),\n 'a non-super-admin must be forbidden from triggering a system-scoped schedule',\n ).toBe(403)\n\n // The execution-history route carried the same lookup and needs the same guarantees.\n const executionsPath = `${SCHEDULER_JOBS_PATH}/${scheduleId}/executions`\n const superExecutions = await apiRequest(request, 'GET', executionsPath, { token: superToken })\n expect(\n superExecutions.status(),\n 'a super admin must not be told a system-scoped schedule has no execution history route',\n ).not.toBe(404)\n expect(superExecutions.status()).toBe(isAsyncQueueStrategy ? 200 : 400)\n\n const adminExecutions = await apiRequest(request, 'GET', executionsPath, { token: adminToken })\n expect(\n adminExecutions.status(),\n 'a non-super-admin must be forbidden from reading a system-scoped execution history',\n ).toBe(403)\n } finally {\n await deleteScheduleJob(request, superToken, scheduleId)\n }\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,MAAM,wBAAwB,QAAQ,IAAI,kBAAkB,aAAa;AAkBzE,KAAK,SAAS,yEAAyE,MAAM;AAC3F,OAAK,mFAAmF,OAAO,EAAE,QAAQ,MAAM;AAC7G,UAAM,aAAa,MAAM,aAAa,SAAS,YAAY;AAC3D,UAAM,aAAa,MAAM,aAAa,SAAS,OAAO;AACtD,QAAI,aAA4B;AAEhC,QAAI;AAEF,mBAAa,MAAM,kBAAkB,SAAS,YAAY;AAAA,QACxD,MAAM,mBAAmB,qBAAqB;AAAA,QAC9C,WAAW;AAAA,QACX,cAAc;AAAA,QACd,eAAe;AAAA,MACjB,CAAC;AAED,YAAM,gBAAgB,MAAM,WAAW,SAAS,QAAQ,wBAAwB;AAAA,QAC9E,OAAO;AAAA,QACP,MAAM,EAAE,IAAI,WAAW;AAAA,MACzB,CAAC;AACD,YAAM,YAAY,MAAM,aAA0B,aAAa;AAC/D;AAAA,QACE,cAAc,OAAO;AAAA,QACrB;AAAA,MACF,EAAE,IAAI,KAAK,GAAG;AAEd,UAAI,sBAAsB;AACxB,eAAO,cAAc,OAAO,CAAC,EAAE,KAAK,GAAG;AACvC,eAAO,WAAW,EAAE,EAAE,KAAK,IAAI;AAC/B,eAAO,OAAO,WAAW,UAAU,YAAY,UAAU,MAAM,SAAS,CAAC,EAAE,KAAK,IAAI;AAAA,MACtF,OAAO;AAGL,eAAO,cAAc,OAAO,CAAC,EAAE,KAAK,GAAG;AACvC,eAAO,WAAW,SAAS,EAAE,EAAE,QAAQ,QAAQ;AAAA,MACjD;AAGA,YAAM,gBAAgB,MAAM,WAAW,SAAS,QAAQ,wBAAwB;AAAA,QAC9E,OAAO;AAAA,QACP,MAAM,EAAE,IAAI,WAAW;AAAA,MACzB,CAAC;AACD;AAAA,QACE,cAAc,OAAO;AAAA,QACrB;AAAA,MACF,EAAE,KAAK,GAAG;AAGV,YAAM,iBAAiB,GAAG,mBAAmB,IAAI,UAAU;AAC3D,YAAM,kBAAkB,MAAM,WAAW,SAAS,OAAO,gBAAgB,EAAE,OAAO,WAAW,CAAC;AAC9F;AAAA,QACE,gBAAgB,OAAO;AAAA,QACvB;AAAA,MACF,EAAE,IAAI,KAAK,GAAG;AACd,aAAO,gBAAgB,OAAO,CAAC,EAAE,KAAK,uBAAuB,MAAM,GAAG;AAEtE,YAAM,kBAAkB,MAAM,WAAW,SAAS,OAAO,gBAAgB,EAAE,OAAO,WAAW,CAAC;AAC9F;AAAA,QACE,gBAAgB,OAAO;AAAA,QACvB;AAAA,MACF,EAAE,KAAK,GAAG;AAAA,IACZ,UAAE;AACA,YAAM,kBAAkB,SAAS,YAAY,UAAU;AAAA,IACzD;AAAA,EACF,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -50,7 +50,7 @@ test.describe("TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, tar
50
50
  scheduleValue: "15m",
51
51
  timezone: "UTC",
52
52
  targetType: "queue",
53
- targetQueue: "scheduler-execution",
53
+ targetQueue: "scheduler-test",
54
54
  targetPayload: createPayload,
55
55
  isEnabled: true,
56
56
  sourceType: "user"
@@ -65,7 +65,7 @@ test.describe("TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, tar
65
65
  scheduleValue: "15m",
66
66
  timezone: "UTC",
67
67
  targetType: "queue",
68
- targetQueue: "scheduler-execution",
68
+ targetQueue: "scheduler-test",
69
69
  targetCommand: null,
70
70
  targetPayload: createPayload,
71
71
  isEnabled: true,
@@ -93,7 +93,7 @@ test.describe("TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, tar
93
93
  scheduleValue: "0 0 * * *",
94
94
  timezone: "Europe/Warsaw",
95
95
  targetType: "queue",
96
- targetQueue: "scheduler-execution",
96
+ targetQueue: "scheduler-test",
97
97
  targetCommand: null,
98
98
  targetPayload: updatePayload,
99
99
  isEnabled: false
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts"],
4
- "sourcesContent": ["import { expect, test, type APIRequestContext } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport {\n runCrudFormRoundTrip,\n skipIfCrudFormExtensionTestsDisabled,\n type CrudRecord,\n} from '@open-mercato/core/helpers/integration/crudFormPersistence';\n\n/**\n * TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, target + payload JSON (#2466).\n *\n * The scheduler's scheduled-job is the Tier-B \"JSON-bearing scalar\" surface: scope\n * (scopeType, server-derived org/tenant), schedule (scheduleType/scheduleValue/timezone),\n * target (targetType/targetQueue), and a nested `targetPayload` JSON object. Proves create +\n * update round-trip every value.\n *\n * Verified contract (differs from the makeCrud-default reference specs):\n * - The list GET filters by `?id=` (single uuid), not the `?ids=` other sweep modules use; the\n * explicit `readById` documents that contract and mirrors the sibling specs (the shared\n * harness default reads back via `?id=` too).\n * - Read-back is camelCase: the list `transformItem` maps every column to camelCase\n * (`scopeType`, `scheduleType`, `targetPayload`, `isEnabled`, ...), so the scalar\n * expectations use camelCase keys (not the snake_case of resources/staff).\n * - The scheduled-job declares no custom entity (no `ce.ts`), so there are no `cf_*` fields \u2014\n * the surface is scalars + nested JSON only.\n * - Scope is derived server-side from the caller's auth context and is immutable on update:\n * `scopeType: 'organization'` fills org+tenant from the admin token and survives the edit.\n * - Update is a partial PUT: omitted target fields are retained, so the update changes the\n * payload JSON while proving `targetType`/`targetQueue` survive untouched.\n * - Harness cleanup deletes via `?id=`; the scheduler delete resolves the id from the query\n * string, so the default `finally` cleanup removes the fixture.\n *\n * Self-contained: the job needs no pre-created fixtures (queue target + inline JSON), and the\n * harness deletes it in `finally`.\n *\n * Gated by `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED` (default off \u2192 runs).\n */\nconst SCHEDULER_JOBS_PATH = '/api/scheduler/jobs';\n\nasync function readScheduledJobById(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<CrudRecord | null> {\n const response = await apiRequest(\n request,\n 'GET',\n `${SCHEDULER_JOBS_PATH}?id=${encodeURIComponent(id)}&page=1&pageSize=100`,\n { token },\n );\n expect(response.status(), `read-back scheduler jobs failed: ${response.status()}`).toBe(200);\n const body = await readJsonSafe<{ items?: CrudRecord[] }>(response);\n return (body?.items ?? []).find((item) => item.id === id) ?? null;\n}\n\ntest.describe('TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, target + payload JSON', () => {\n test.beforeAll(() => {\n skipIfCrudFormExtensionTestsDisabled();\n });\n\n test('round-trips scalars, scope, target + nested payload JSON on create and update', async ({ request }) => {\n const token = await getAuthToken(request, 'admin');\n const stamp = Date.now();\n\n const createPayload = {\n source: 'integration-test',\n attempts: 3,\n retries: { max: 5, backoff: 'exponential' },\n tags: ['alpha', 'beta'],\n };\n const updatePayload = {\n source: 'integration-test-edited',\n attempts: 1,\n retries: { max: 0, backoff: 'fixed' },\n tags: ['gamma'],\n };\n\n await runCrudFormRoundTrip({\n request,\n token,\n collectionPath: SCHEDULER_JOBS_PATH,\n readById: (id) => readScheduledJobById(request, token, id),\n create: {\n payload: {\n name: `QA CRUDFORM Scheduled Job ${stamp}`,\n description: 'Original scheduled job description',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'scheduler-execution',\n targetPayload: createPayload,\n isEnabled: true,\n sourceType: 'user',\n },\n },\n expectAfterCreate: {\n scalars: {\n name: `QA CRUDFORM Scheduled Job ${stamp}`,\n description: 'Original scheduled job description',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'scheduler-execution',\n targetCommand: null,\n targetPayload: createPayload,\n isEnabled: true,\n sourceType: 'user',\n },\n },\n update: {\n payload: (id) => ({\n id,\n name: `QA CRUDFORM Scheduled Job ${stamp} EDITED`,\n description: 'Updated scheduled job description',\n scheduleType: 'cron',\n scheduleValue: '0 0 * * *',\n timezone: 'Europe/Warsaw',\n targetPayload: updatePayload,\n isEnabled: false,\n }),\n },\n expectAfterUpdate: {\n scalars: {\n name: `QA CRUDFORM Scheduled Job ${stamp} EDITED`,\n description: 'Updated scheduled job description',\n scopeType: 'organization',\n scheduleType: 'cron',\n scheduleValue: '0 0 * * *',\n timezone: 'Europe/Warsaw',\n targetType: 'queue',\n targetQueue: 'scheduler-execution',\n targetCommand: null,\n targetPayload: updatePayload,\n isEnabled: false,\n },\n },\n });\n });\n});\n"],
4
+ "sourcesContent": ["import { expect, test, type APIRequestContext } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport {\n runCrudFormRoundTrip,\n skipIfCrudFormExtensionTestsDisabled,\n type CrudRecord,\n} from '@open-mercato/core/helpers/integration/crudFormPersistence';\n\n/**\n * TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, target + payload JSON (#2466).\n *\n * The scheduler's scheduled-job is the Tier-B \"JSON-bearing scalar\" surface: scope\n * (scopeType, server-derived org/tenant), schedule (scheduleType/scheduleValue/timezone),\n * target (targetType/targetQueue), and a nested `targetPayload` JSON object. Proves create +\n * update round-trip every value.\n *\n * Verified contract (differs from the makeCrud-default reference specs):\n * - The list GET filters by `?id=` (single uuid), not the `?ids=` other sweep modules use; the\n * explicit `readById` documents that contract and mirrors the sibling specs (the shared\n * harness default reads back via `?id=` too).\n * - Read-back is camelCase: the list `transformItem` maps every column to camelCase\n * (`scopeType`, `scheduleType`, `targetPayload`, `isEnabled`, ...), so the scalar\n * expectations use camelCase keys (not the snake_case of resources/staff).\n * - The scheduled-job declares no custom entity (no `ce.ts`), so there are no `cf_*` fields \u2014\n * the surface is scalars + nested JSON only.\n * - Scope is derived server-side from the caller's auth context and is immutable on update:\n * `scopeType: 'organization'` fills org+tenant from the admin token and survives the edit.\n * - Update is a partial PUT: omitted target fields are retained, so the update changes the\n * payload JSON while proving `targetType`/`targetQueue` survive untouched.\n * - Harness cleanup deletes via `?id=`; the scheduler delete resolves the id from the query\n * string, so the default `finally` cleanup removes the fixture.\n *\n * Self-contained: the job needs no pre-created fixtures (queue target + inline JSON), and the\n * harness deletes it in `finally`.\n *\n * Gated by `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED` (default off \u2192 runs).\n */\nconst SCHEDULER_JOBS_PATH = '/api/scheduler/jobs';\n\nasync function readScheduledJobById(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<CrudRecord | null> {\n const response = await apiRequest(\n request,\n 'GET',\n `${SCHEDULER_JOBS_PATH}?id=${encodeURIComponent(id)}&page=1&pageSize=100`,\n { token },\n );\n expect(response.status(), `read-back scheduler jobs failed: ${response.status()}`).toBe(200);\n const body = await readJsonSafe<{ items?: CrudRecord[] }>(response);\n return (body?.items ?? []).find((item) => item.id === id) ?? null;\n}\n\ntest.describe('TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, target + payload JSON', () => {\n test.beforeAll(() => {\n skipIfCrudFormExtensionTestsDisabled();\n });\n\n test('round-trips scalars, scope, target + nested payload JSON on create and update', async ({ request }) => {\n const token = await getAuthToken(request, 'admin');\n const stamp = Date.now();\n\n const createPayload = {\n source: 'integration-test',\n attempts: 3,\n retries: { max: 5, backoff: 'exponential' },\n tags: ['alpha', 'beta'],\n };\n const updatePayload = {\n source: 'integration-test-edited',\n attempts: 1,\n retries: { max: 0, backoff: 'fixed' },\n tags: ['gamma'],\n };\n\n await runCrudFormRoundTrip({\n request,\n token,\n collectionPath: SCHEDULER_JOBS_PATH,\n readById: (id) => readScheduledJobById(request, token, id),\n create: {\n payload: {\n name: `QA CRUDFORM Scheduled Job ${stamp}`,\n description: 'Original scheduled job description',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'scheduler-test',\n targetPayload: createPayload,\n isEnabled: true,\n sourceType: 'user',\n },\n },\n expectAfterCreate: {\n scalars: {\n name: `QA CRUDFORM Scheduled Job ${stamp}`,\n description: 'Original scheduled job description',\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'scheduler-test',\n targetCommand: null,\n targetPayload: createPayload,\n isEnabled: true,\n sourceType: 'user',\n },\n },\n update: {\n payload: (id) => ({\n id,\n name: `QA CRUDFORM Scheduled Job ${stamp} EDITED`,\n description: 'Updated scheduled job description',\n scheduleType: 'cron',\n scheduleValue: '0 0 * * *',\n timezone: 'Europe/Warsaw',\n targetPayload: updatePayload,\n isEnabled: false,\n }),\n },\n expectAfterUpdate: {\n scalars: {\n name: `QA CRUDFORM Scheduled Job ${stamp} EDITED`,\n description: 'Updated scheduled job description',\n scopeType: 'organization',\n scheduleType: 'cron',\n scheduleValue: '0 0 * * *',\n timezone: 'Europe/Warsaw',\n targetType: 'queue',\n targetQueue: 'scheduler-test',\n targetCommand: null,\n targetPayload: updatePayload,\n isEnabled: false,\n },\n },\n });\n });\n});\n"],
5
5
  "mappings": "AAAA,SAAS,QAAQ,YAAoC;AACrD,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AA+BP,MAAM,sBAAsB;AAE5B,eAAe,qBACb,SACA,OACA,IAC4B;AAC5B,QAAM,WAAW,MAAM;AAAA,IACrB;AAAA,IACA;AAAA,IACA,GAAG,mBAAmB,OAAO,mBAAmB,EAAE,CAAC;AAAA,IACnD,EAAE,MAAM;AAAA,EACV;AACA,SAAO,SAAS,OAAO,GAAG,oCAAoC,SAAS,OAAO,CAAC,EAAE,EAAE,KAAK,GAAG;AAC3F,QAAM,OAAO,MAAM,aAAuC,QAAQ;AAClE,UAAQ,MAAM,SAAS,CAAC,GAAG,KAAK,CAAC,SAAS,KAAK,OAAO,EAAE,KAAK;AAC/D;AAEA,KAAK,SAAS,uFAAuF,MAAM;AACzG,OAAK,UAAU,MAAM;AACnB,yCAAqC;AAAA,EACvC,CAAC;AAED,OAAK,iFAAiF,OAAO,EAAE,QAAQ,MAAM;AAC3G,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,UAAM,QAAQ,KAAK,IAAI;AAEvB,UAAM,gBAAgB;AAAA,MACpB,QAAQ;AAAA,MACR,UAAU;AAAA,MACV,SAAS,EAAE,KAAK,GAAG,SAAS,cAAc;AAAA,MAC1C,MAAM,CAAC,SAAS,MAAM;AAAA,IACxB;AACA,UAAM,gBAAgB;AAAA,MACpB,QAAQ;AAAA,MACR,UAAU;AAAA,MACV,SAAS,EAAE,KAAK,GAAG,SAAS,QAAQ;AAAA,MACpC,MAAM,CAAC,OAAO;AAAA,IAChB;AAEA,UAAM,qBAAqB;AAAA,MACzB;AAAA,MACA;AAAA,MACA,gBAAgB;AAAA,MAChB,UAAU,CAAC,OAAO,qBAAqB,SAAS,OAAO,EAAE;AAAA,MACzD,QAAQ;AAAA,QACN,SAAS;AAAA,UACP,MAAM,6BAA6B,KAAK;AAAA,UACxC,aAAa;AAAA,UACb,WAAW;AAAA,UACX,cAAc;AAAA,UACd,eAAe;AAAA,UACf,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,aAAa;AAAA,UACb,eAAe;AAAA,UACf,WAAW;AAAA,UACX,YAAY;AAAA,QACd;AAAA,MACF;AAAA,MACA,mBAAmB;AAAA,QACjB,SAAS;AAAA,UACP,MAAM,6BAA6B,KAAK;AAAA,UACxC,aAAa;AAAA,UACb,WAAW;AAAA,UACX,cAAc;AAAA,UACd,eAAe;AAAA,UACf,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,aAAa;AAAA,UACb,eAAe;AAAA,UACf,eAAe;AAAA,UACf,WAAW;AAAA,UACX,YAAY;AAAA,QACd;AAAA,MACF;AAAA,MACA,QAAQ;AAAA,QACN,SAAS,CAAC,QAAQ;AAAA,UAChB;AAAA,UACA,MAAM,6BAA6B,KAAK;AAAA,UACxC,aAAa;AAAA,UACb,cAAc;AAAA,UACd,eAAe;AAAA,UACf,UAAU;AAAA,UACV,eAAe;AAAA,UACf,WAAW;AAAA,QACb;AAAA,MACF;AAAA,MACA,mBAAmB;AAAA,QACjB,SAAS;AAAA,UACP,MAAM,6BAA6B,KAAK;AAAA,UACxC,aAAa;AAAA,UACb,WAAW;AAAA,UACX,cAAc;AAAA,UACd,eAAe;AAAA,UACf,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,aAAa;AAAA,UACb,eAAe;AAAA,UACf,eAAe;AAAA,UACf,WAAW;AAAA,QACb;AAAA,MACF;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AACH,CAAC;",
6
6
  "names": []
7
7
  }
@@ -1,56 +1,119 @@
1
1
  import { expect, test } 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.js";
14
- function jobCreatePayload(stamp) {
15
- return {
16
- name: `TC-UNDO-001 Scheduler ${stamp}`,
17
- description: "TC-UNDO-001 undo/redo probe",
18
- scopeType: "organization",
19
- scheduleType: "interval",
20
- scheduleValue: "15m",
21
- timezone: "UTC",
22
- targetType: "queue",
23
- targetQueue: "scheduler-execution",
24
- isEnabled: true,
25
- sourceType: "user"
26
- };
3
+ async function settleAuditClock() {
4
+ await new Promise((resolve) => setTimeout(resolve, 50));
27
5
  }
28
- test.describe("TC-UNDO-001 scheduler.jobs undo/redo (#2504)", () => {
29
- test.beforeAll(() => {
30
- skipIfUndoTestsDisabled();
6
+ function readUndoToken(res) {
7
+ const header = res.headers()["x-om-operation"] ?? "";
8
+ const enc = header.startsWith("omop:") ? header.slice(5) : "";
9
+ expect(enc, "x-om-operation header should carry an omop: payload").not.toBe("");
10
+ const payload = JSON.parse(decodeURIComponent(enc));
11
+ expect(typeof payload.undoToken, "undoToken present in operation payload").toBe("string");
12
+ return payload.undoToken;
13
+ }
14
+ async function getById(request, token, id) {
15
+ const res = await apiRequest(request, "GET", `/api/scheduler/jobs?id=${encodeURIComponent(id)}`, { token });
16
+ expect(res.status()).toBe(200);
17
+ const body = await res.json();
18
+ return (body.items ?? [])[0];
19
+ }
20
+ async function createJob(request, token, name) {
21
+ const res = await apiRequest(request, "POST", "/api/scheduler/jobs", {
22
+ token,
23
+ data: {
24
+ name,
25
+ description: "TC-UNDO-001 probe",
26
+ scopeType: "tenant",
27
+ scheduleType: "cron",
28
+ scheduleValue: "0 0 * * *",
29
+ timezone: "UTC",
30
+ targetType: "command",
31
+ targetCommand: "scheduler.test.echo",
32
+ isEnabled: true,
33
+ sourceType: "user"
34
+ }
35
+ });
36
+ expect(res.status(), "create returns 201").toBe(201);
37
+ const id = (await res.json()).id;
38
+ expect(id, "create returns an id").toBeTruthy();
39
+ return { id, res };
40
+ }
41
+ async function undo(request, token, undoToken) {
42
+ const res = await apiRequest(request, "POST", "/api/audit_logs/audit-logs/actions/undo", {
43
+ token,
44
+ data: { undoToken }
31
45
  });
32
- test("jobs CRUD commands restore scalar state on undo/redo (I1/I2/I3/I5/I6)", async ({ request }) => {
33
- const token = await getAuthToken(request, "admin");
34
- await runCrudUndoRoundTrip(request, token, {
35
- label: "scheduler.jobs",
36
- collectionPath: SCHEDULER_JOBS_PATH,
37
- field: "name",
38
- createPayload: jobCreatePayload,
39
- updatePayload: (id, stamp) => ({ id, name: `TC-UNDO-001 Scheduler Renamed ${stamp}` })
40
- });
46
+ const raw = await res.text();
47
+ expect(res.status(), `undo returns 200 (body: ${raw})`).toBe(200);
48
+ const body = JSON.parse(raw);
49
+ expect(body.ok, "undo body is { ok: true }").toBe(true);
50
+ }
51
+ async function cleanup(request, token, id) {
52
+ if (!token || !id) return;
53
+ try {
54
+ await apiRequest(request, "DELETE", "/api/scheduler/jobs", { token, data: { id } });
55
+ } catch {
56
+ }
57
+ }
58
+ test.describe("TC-UNDO-001: scheduler.jobs undo actually restores state (#2504)", () => {
59
+ test("UPDATE -> undo restores the prior name", async ({ request }) => {
60
+ let token = null;
61
+ let id = null;
62
+ try {
63
+ token = await getAuthToken(request, "admin");
64
+ const originalName = `TC-UNDO-001 Update ${Date.now()}`;
65
+ ({ id } = await createJob(request, token, originalName));
66
+ await settleAuditClock();
67
+ const updateRes = await apiRequest(request, "PUT", "/api/scheduler/jobs", {
68
+ token,
69
+ data: { id, name: `${originalName} RENAMED` }
70
+ });
71
+ expect(updateRes.status(), "update returns 200").toBe(200);
72
+ expect((await getById(request, token, id))?.name).toBe(`${originalName} RENAMED`);
73
+ await undo(request, token, readUndoToken(updateRes));
74
+ const restored = await getById(request, token, id);
75
+ expect(restored, "job still exists after update-undo").toBeTruthy();
76
+ expect(restored.name, "name restored to original by undo").toBe(originalName);
77
+ } finally {
78
+ await cleanup(request, token, id);
79
+ }
41
80
  });
42
- test("\xA74 trigger exposes no undo token", async ({ request }) => {
43
- const token = await getAuthToken(request, "admin");
44
- let jobId = null;
81
+ test("DELETE -> undo re-lists the soft-deleted job", async ({ request }) => {
82
+ let token = null;
83
+ let id = null;
45
84
  try {
46
- jobId = await createScheduleJob(request, token);
47
- const triggerRes = await apiRequest(request, "POST", SCHEDULER_TRIGGER_PATH, {
85
+ token = await getAuthToken(request, "admin");
86
+ const name = `TC-UNDO-001 Delete ${Date.now()}`;
87
+ ({ id } = await createJob(request, token, name));
88
+ await settleAuditClock();
89
+ const deleteRes = await apiRequest(request, "DELETE", "/api/scheduler/jobs", {
48
90
  token,
49
- data: { id: jobId }
91
+ data: { id }
50
92
  });
51
- expect(extractOperation(triggerRes), "trigger response carries no undo token (\xA74)").toBeNull();
93
+ expect(deleteRes.status(), "delete returns 200").toBe(200);
94
+ expect(await getById(request, token, id), "job is gone after delete").toBeFalsy();
95
+ await undo(request, token, readUndoToken(deleteRes));
96
+ const restored = await getById(request, token, id);
97
+ expect(restored, "job re-materialized by delete-undo").toBeTruthy();
98
+ expect(restored.name).toBe(name);
99
+ } finally {
100
+ await cleanup(request, token, id);
101
+ }
102
+ });
103
+ test("CREATE -> undo removes the created job", async ({ request }) => {
104
+ let token = null;
105
+ let id = null;
106
+ try {
107
+ token = await getAuthToken(request, "admin");
108
+ const name = `TC-UNDO-001 Create ${Date.now()}`;
109
+ const created = await createJob(request, token, name);
110
+ id = created.id;
111
+ expect(await getById(request, token, id), "job exists after create").toBeTruthy();
112
+ await undo(request, token, readUndoToken(created.res));
113
+ expect(await getById(request, token, id), "job removed by create-undo").toBeFalsy();
114
+ id = null;
52
115
  } finally {
53
- await deleteScheduleJob(request, token, jobId);
116
+ await cleanup(request, token, id);
54
117
  }
55
118
  });
56
119
  });