@open-mercato/scheduler 0.10.1-develop.7690.1.9d41ec4115 → 0.10.1-develop.7699.1.5236fabda0

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.
@@ -1,2 +1,2 @@
1
- [build:scheduler] found 64 entry points
1
+ [build:scheduler] found 65 entry points
2
2
  [build:scheduler] built successfully
@@ -0,0 +1,149 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ import {
4
+ createOrganizationFixture,
5
+ createRoleFixture,
6
+ createUserFixture,
7
+ deleteOrganizationIfExists,
8
+ deleteRoleIfExists,
9
+ deleteUserIfExists,
10
+ setRoleAclFeatures
11
+ } from "@open-mercato/core/helpers/integration/authFixtures";
12
+ import { expectId, getTokenContext, readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
13
+ import { SCHEDULER_JOBS_PATH, createScheduleJob, deleteScheduleJob } from "./helpers/scheduler.js";
14
+ const SCHEDULER_FEATURES = ["scheduler.jobs.view", "scheduler.jobs.manage"];
15
+ async function createTenantFixture(request, superadminToken, name) {
16
+ const response = await apiRequest(request, "POST", "/api/directory/tenants", { token: superadminToken, data: { name } });
17
+ expect(response.status(), "POST /api/directory/tenants should return 201").toBe(201);
18
+ return expectId((await readJsonSafe(response))?.id, "tenant fixture should return an id");
19
+ }
20
+ async function deleteTenantIfExists(request, superadminToken, tenantId) {
21
+ if (!superadminToken || !tenantId) return;
22
+ await apiRequest(request, "DELETE", `/api/directory/tenants?id=${encodeURIComponent(tenantId)}`, { token: superadminToken }).catch(() => void 0);
23
+ }
24
+ async function createScopedActor(request, superadminToken, input) {
25
+ const roleId = await createRoleFixture(request, superadminToken, {
26
+ name: `sched-${input.label}-${input.stamp}`,
27
+ tenantId: input.tenantId
28
+ });
29
+ await setRoleAclFeatures(request, superadminToken, {
30
+ roleId,
31
+ features: input.features ?? SCHEDULER_FEATURES,
32
+ organizations: [input.organizationId]
33
+ });
34
+ const email = `sched-${input.label}-${input.stamp}@test.invalid`;
35
+ const password = "Valid1!Pass";
36
+ const userId = await createUserFixture(request, superadminToken, {
37
+ email,
38
+ password,
39
+ organizationId: input.organizationId,
40
+ roles: [roleId]
41
+ });
42
+ const token = await getAuthToken(request, email, password);
43
+ return { organizationId: input.organizationId, roleId, userId, token };
44
+ }
45
+ async function cleanupScopedActor(request, superadminToken, actor) {
46
+ if (!actor) return;
47
+ await deleteUserIfExists(request, superadminToken, actor.userId);
48
+ await deleteRoleIfExists(request, superadminToken, actor.roleId);
49
+ }
50
+ test.describe("TC-SCHED-010: full export stays scoped to the caller tenant/organization (#7806)", () => {
51
+ test("own-org job survives, other-org and foreign-tenant jobs do not; missing-feature caller is refused", async ({ request }) => {
52
+ const superadminToken = await getAuthToken(request, "superadmin");
53
+ const adminToken = await getAuthToken(request, "admin");
54
+ const { tenantId: homeTenantId, organizationId: homeOrgId } = getTokenContext(adminToken);
55
+ expect(homeTenantId.length, "admin token should carry a tenant id").toBeGreaterThan(0);
56
+ expect(homeOrgId.length, "admin token should carry an organization id").toBeGreaterThan(0);
57
+ const stamp = `${Date.now()}`;
58
+ let ownOrgActor = null;
59
+ let otherOrgActor = null;
60
+ let viewOnlyActor = null;
61
+ let secondOrgId = null;
62
+ let foreignTenantId = null;
63
+ let foreignOrgId = null;
64
+ let foreignRoleId = null;
65
+ let foreignUserId = null;
66
+ let ownOrgJobId = null;
67
+ let otherOrgJobId = null;
68
+ let foreignTenantJobId = null;
69
+ let foreignToken = null;
70
+ try {
71
+ ownOrgActor = await createScopedActor(request, superadminToken, {
72
+ tenantId: homeTenantId,
73
+ organizationId: homeOrgId,
74
+ stamp,
75
+ label: "own"
76
+ });
77
+ secondOrgId = await createOrganizationFixture(request, superadminToken, {
78
+ name: `QA SCHED Org B ${stamp}`,
79
+ tenantId: homeTenantId
80
+ });
81
+ otherOrgActor = await createScopedActor(request, superadminToken, {
82
+ tenantId: homeTenantId,
83
+ organizationId: secondOrgId,
84
+ stamp,
85
+ label: "other-org"
86
+ });
87
+ foreignTenantId = await createTenantFixture(request, superadminToken, `QA SCHED Tenant B ${stamp}`);
88
+ foreignOrgId = await createOrganizationFixture(request, superadminToken, {
89
+ name: `QA SCHED Org C ${stamp}`,
90
+ tenantId: foreignTenantId
91
+ });
92
+ foreignRoleId = await createRoleFixture(request, superadminToken, { name: `sched-foreign-${stamp}`, tenantId: foreignTenantId });
93
+ await setRoleAclFeatures(request, superadminToken, { roleId: foreignRoleId, features: SCHEDULER_FEATURES, organizations: [foreignOrgId] });
94
+ const foreignEmail = `sched-foreign-${stamp}@test.invalid`;
95
+ const foreignPassword = "Valid1!Pass";
96
+ foreignUserId = await createUserFixture(request, superadminToken, {
97
+ email: foreignEmail,
98
+ password: foreignPassword,
99
+ organizationId: foreignOrgId,
100
+ roles: [foreignRoleId]
101
+ });
102
+ foreignToken = await getAuthToken(request, foreignEmail, foreignPassword);
103
+ viewOnlyActor = await createScopedActor(request, superadminToken, {
104
+ tenantId: homeTenantId,
105
+ organizationId: homeOrgId,
106
+ stamp,
107
+ label: "nofeat",
108
+ features: []
109
+ });
110
+ ownOrgJobId = await createScheduleJob(request, ownOrgActor.token, { name: `QA SCHED Own Org Job ${stamp}` });
111
+ otherOrgJobId = await createScheduleJob(request, otherOrgActor.token, { name: `QA SCHED Other Org Job ${stamp}` });
112
+ foreignTenantJobId = await createScheduleJob(request, foreignToken, { name: `QA SCHED Foreign Tenant Job ${stamp}` });
113
+ const exportResponse = await apiRequest(
114
+ request,
115
+ "GET",
116
+ `${SCHEDULER_JOBS_PATH}?format=json&exportScope=full`,
117
+ { token: ownOrgActor.token }
118
+ );
119
+ expect(exportResponse.status(), "full export should return 200 for an authorized caller").toBe(200);
120
+ const exported = await readJsonSafe(exportResponse);
121
+ const exportedIds = new Set((exported ?? []).map((item) => item.Id));
122
+ expect(exportedIds.has(ownOrgJobId), "own-org job must survive the full export").toBe(true);
123
+ expect(exportedIds.has(otherOrgJobId), "another organization's job (same tenant) must not leak into the export").toBe(false);
124
+ expect(exportedIds.has(foreignTenantJobId), "a foreign tenant's job must not leak into the export (#7806)").toBe(false);
125
+ const forbiddenResponse = await apiRequest(
126
+ request,
127
+ "GET",
128
+ `${SCHEDULER_JOBS_PATH}?format=json&exportScope=full`,
129
+ { token: viewOnlyActor.token }
130
+ );
131
+ expect(forbiddenResponse.status(), "full export must be refused without scheduler.jobs.view").toBe(403);
132
+ const forbiddenBody = await readJsonSafe(forbiddenResponse);
133
+ expect(forbiddenBody?.requiredFeatures ?? []).toContain("scheduler.jobs.view");
134
+ } finally {
135
+ await deleteScheduleJob(request, superadminToken, ownOrgJobId);
136
+ await deleteScheduleJob(request, otherOrgActor?.token ?? superadminToken, otherOrgJobId);
137
+ await deleteScheduleJob(request, foreignToken ?? superadminToken, foreignTenantJobId);
138
+ await cleanupScopedActor(request, superadminToken, ownOrgActor);
139
+ await cleanupScopedActor(request, superadminToken, otherOrgActor);
140
+ await cleanupScopedActor(request, superadminToken, viewOnlyActor);
141
+ await deleteUserIfExists(request, superadminToken, foreignUserId);
142
+ await deleteRoleIfExists(request, superadminToken, foreignRoleId);
143
+ await deleteOrganizationIfExists(request, superadminToken, secondOrgId);
144
+ await deleteOrganizationIfExists(request, superadminToken, foreignOrgId);
145
+ await deleteTenantIfExists(request, superadminToken, foreignTenantId);
146
+ }
147
+ });
148
+ });
149
+ //# sourceMappingURL=TC-SCHED-010-full-export-tenant-scope.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-010-full-export-tenant-scope.spec.ts"],
4
+ "sourcesContent": ["import { expect, test, type APIRequestContext } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport {\n createOrganizationFixture,\n createRoleFixture,\n createUserFixture,\n deleteOrganizationIfExists,\n deleteRoleIfExists,\n deleteUserIfExists,\n setRoleAclFeatures,\n} from '@open-mercato/core/helpers/integration/authFixtures'\nimport { expectId, getTokenContext, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { SCHEDULER_JOBS_PATH, createScheduleJob, deleteScheduleJob } from './helpers/scheduler'\n\n/**\n * TC-SCHED-010: real-world regression for #7806 \u2014 `GET /api/scheduler/jobs?format=json&exportScope=full`\n * must keep applying the route's own `buildFilters` visibility predicate (own-org, own-tenant-scope,\n * superadmin-only system-scope) instead of dropping to `{}`, which would leak every tenant's jobs on a\n * full export (scheduler jobs opt out of the factory's automatic tenant/org scope via\n * `omitAutomaticTenantOrgScope`, so it owns 100% of the visibility logic). Unlike the existing mocked\n * unit coverage in `api/jobs/__tests__/route.scope.test.ts`, this exercises the real authenticated route,\n * real ACL resolution, and a real Postgres-backed query engine.\n */\n\nconst SCHEDULER_FEATURES = ['scheduler.jobs.view', 'scheduler.jobs.manage']\n\nasync function createTenantFixture(request: APIRequestContext, superadminToken: string, name: string): Promise<string> {\n const response = await apiRequest(request, 'POST', '/api/directory/tenants', { token: superadminToken, data: { name } })\n expect(response.status(), 'POST /api/directory/tenants should return 201').toBe(201)\n return expectId((await readJsonSafe<{ id?: string }>(response))?.id, 'tenant fixture should return an id')\n}\n\nasync function deleteTenantIfExists(request: APIRequestContext, superadminToken: string | null, tenantId: string | null): Promise<void> {\n if (!superadminToken || !tenantId) return\n await apiRequest(request, 'DELETE', `/api/directory/tenants?id=${encodeURIComponent(tenantId)}`, { token: superadminToken }).catch(() => undefined)\n}\n\ntype ScopedActor = {\n organizationId: string\n roleId: string\n userId: string\n token: string\n}\n\n/** Provisions a role (scoped to exactly one organization) + user + token, so the actor's own-org\n * visibility can never widen to \"every org in the tenant\" and mask the isolation this test checks. */\nasync function createScopedActor(\n request: APIRequestContext,\n superadminToken: string,\n input: { tenantId: string; organizationId: string; stamp: string; label: string; features?: string[] },\n): Promise<ScopedActor> {\n const roleId = await createRoleFixture(request, superadminToken, {\n name: `sched-${input.label}-${input.stamp}`,\n tenantId: input.tenantId,\n })\n await setRoleAclFeatures(request, superadminToken, {\n roleId,\n features: input.features ?? SCHEDULER_FEATURES,\n organizations: [input.organizationId],\n })\n const email = `sched-${input.label}-${input.stamp}@test.invalid`\n const password = 'Valid1!Pass'\n const userId = await createUserFixture(request, superadminToken, {\n email,\n password,\n organizationId: input.organizationId,\n roles: [roleId],\n })\n const token = await getAuthToken(request, email, password)\n return { organizationId: input.organizationId, roleId, userId, token }\n}\n\nasync function cleanupScopedActor(request: APIRequestContext, superadminToken: string, actor: ScopedActor | null): Promise<void> {\n if (!actor) return\n await deleteUserIfExists(request, superadminToken, actor.userId)\n await deleteRoleIfExists(request, superadminToken, actor.roleId)\n}\n\ntest.describe('TC-SCHED-010: full export stays scoped to the caller tenant/organization (#7806)', () => {\n test('own-org job survives, other-org and foreign-tenant jobs do not; missing-feature caller is refused', async ({ request }) => {\n const superadminToken = await getAuthToken(request, 'superadmin')\n const adminToken = await getAuthToken(request, 'admin')\n const { tenantId: homeTenantId, organizationId: homeOrgId } = getTokenContext(adminToken)\n expect(homeTenantId.length, 'admin token should carry a tenant id').toBeGreaterThan(0)\n expect(homeOrgId.length, 'admin token should carry an organization id').toBeGreaterThan(0)\n\n const stamp = `${Date.now()}`\n\n let ownOrgActor: ScopedActor | null = null\n let otherOrgActor: ScopedActor | null = null\n let viewOnlyActor: ScopedActor | null = null\n let secondOrgId: string | null = null\n let foreignTenantId: string | null = null\n let foreignOrgId: string | null = null\n let foreignRoleId: string | null = null\n let foreignUserId: string | null = null\n let ownOrgJobId: string | null = null\n let otherOrgJobId: string | null = null\n let foreignTenantJobId: string | null = null\n let foreignToken: string | null = null\n\n try {\n // Own-org actor: scoped to the home tenant's existing organization.\n ownOrgActor = await createScopedActor(request, superadminToken, {\n tenantId: homeTenantId,\n organizationId: homeOrgId,\n stamp,\n label: 'own',\n })\n\n // Other-org actor: a SECOND organization inside the SAME tenant.\n secondOrgId = await createOrganizationFixture(request, superadminToken, {\n name: `QA SCHED Org B ${stamp}`,\n tenantId: homeTenantId,\n })\n otherOrgActor = await createScopedActor(request, superadminToken, {\n tenantId: homeTenantId,\n organizationId: secondOrgId,\n stamp,\n label: 'other-org',\n })\n\n // Foreign-tenant actor: a genuinely different tenant + organization (the #7806 boundary).\n foreignTenantId = await createTenantFixture(request, superadminToken, `QA SCHED Tenant B ${stamp}`)\n foreignOrgId = await createOrganizationFixture(request, superadminToken, {\n name: `QA SCHED Org C ${stamp}`,\n tenantId: foreignTenantId,\n })\n foreignRoleId = await createRoleFixture(request, superadminToken, { name: `sched-foreign-${stamp}`, tenantId: foreignTenantId })\n await setRoleAclFeatures(request, superadminToken, { roleId: foreignRoleId, features: SCHEDULER_FEATURES, organizations: [foreignOrgId] })\n const foreignEmail = `sched-foreign-${stamp}@test.invalid`\n const foreignPassword = 'Valid1!Pass'\n foreignUserId = await createUserFixture(request, superadminToken, {\n email: foreignEmail,\n password: foreignPassword,\n organizationId: foreignOrgId,\n roles: [foreignRoleId],\n })\n foreignToken = await getAuthToken(request, foreignEmail, foreignPassword)\n\n // View-only (no scheduler.jobs.view) actor in the home org, for the 403 assertion.\n viewOnlyActor = await createScopedActor(request, superadminToken, {\n tenantId: homeTenantId,\n organizationId: homeOrgId,\n stamp,\n label: 'nofeat',\n features: [],\n })\n\n ownOrgJobId = await createScheduleJob(request, ownOrgActor.token, { name: `QA SCHED Own Org Job ${stamp}` })\n otherOrgJobId = await createScheduleJob(request, otherOrgActor.token, { name: `QA SCHED Other Org Job ${stamp}` })\n foreignTenantJobId = await createScheduleJob(request, foreignToken, { name: `QA SCHED Foreign Tenant Job ${stamp}` })\n\n // Full export as the own-org actor must only surface the own-org job.\n const exportResponse = await apiRequest(\n request,\n 'GET',\n `${SCHEDULER_JOBS_PATH}?format=json&exportScope=full`,\n { token: ownOrgActor.token },\n )\n expect(exportResponse.status(), 'full export should return 200 for an authorized caller').toBe(200)\n // The full-export JSON serializer keys each row by its human-readable column header\n // (e.g. \"Id\"), not the raw field name \u2014 see `serializeJson` in `lib/crud/exporters.ts`.\n const exported = await readJsonSafe<Array<{ Id: string }>>(exportResponse)\n const exportedIds = new Set((exported ?? []).map((item) => item.Id))\n\n expect(exportedIds.has(ownOrgJobId), 'own-org job must survive the full export').toBe(true)\n expect(exportedIds.has(otherOrgJobId), 'another organization\\'s job (same tenant) must not leak into the export').toBe(false)\n expect(exportedIds.has(foreignTenantJobId), 'a foreign tenant\\'s job must not leak into the export (#7806)').toBe(false)\n\n // A caller without scheduler.jobs.view is refused outright, not served a scoped-empty export.\n const forbiddenResponse = await apiRequest(\n request,\n 'GET',\n `${SCHEDULER_JOBS_PATH}?format=json&exportScope=full`,\n { token: viewOnlyActor.token },\n )\n expect(forbiddenResponse.status(), 'full export must be refused without scheduler.jobs.view').toBe(403)\n const forbiddenBody = await readJsonSafe<{ error?: string; requiredFeatures?: string[] }>(forbiddenResponse)\n expect(forbiddenBody?.requiredFeatures ?? []).toContain('scheduler.jobs.view')\n } finally {\n await deleteScheduleJob(request, superadminToken, ownOrgJobId)\n await deleteScheduleJob(request, otherOrgActor?.token ?? superadminToken, otherOrgJobId)\n await deleteScheduleJob(request, foreignToken ?? superadminToken, foreignTenantJobId)\n\n await cleanupScopedActor(request, superadminToken, ownOrgActor)\n await cleanupScopedActor(request, superadminToken, otherOrgActor)\n await cleanupScopedActor(request, superadminToken, viewOnlyActor)\n await deleteUserIfExists(request, superadminToken, foreignUserId)\n await deleteRoleIfExists(request, superadminToken, foreignRoleId)\n\n await deleteOrganizationIfExists(request, superadminToken, secondOrgId)\n await deleteOrganizationIfExists(request, superadminToken, foreignOrgId)\n await deleteTenantIfExists(request, superadminToken, foreignTenantId)\n }\n })\n})\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/scheduler",
3
- "version": "0.10.1-develop.7690.1.9d41ec4115",
3
+ "version": "0.10.1-develop.7699.1.5236fabda0",
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.10.1-develop.7690.1.9d41ec4115",
63
- "@open-mercato/queue": "0.10.1-develop.7690.1.9d41ec4115",
62
+ "@open-mercato/events": "0.10.1-develop.7699.1.5236fabda0",
63
+ "@open-mercato/queue": "0.10.1-develop.7699.1.5236fabda0",
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.10.1-develop.7690.1.9d41ec4115",
68
+ "@open-mercato/shared": "0.10.1-develop.7699.1.5236fabda0",
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.10.1-develop.7690.1.9d41ec4115",
82
+ "@open-mercato/shared": "0.10.1-develop.7699.1.5236fabda0",
83
83
  "@types/jest": "^30.0.0",
84
84
  "@types/node": "^26.4.0",
85
85
  "jest": "^30.4.2",
@@ -0,0 +1,197 @@
1
+ import { expect, test, type APIRequestContext } from '@playwright/test'
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import {
4
+ createOrganizationFixture,
5
+ createRoleFixture,
6
+ createUserFixture,
7
+ deleteOrganizationIfExists,
8
+ deleteRoleIfExists,
9
+ deleteUserIfExists,
10
+ setRoleAclFeatures,
11
+ } from '@open-mercato/core/helpers/integration/authFixtures'
12
+ import { expectId, getTokenContext, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
13
+ import { SCHEDULER_JOBS_PATH, createScheduleJob, deleteScheduleJob } from './helpers/scheduler'
14
+
15
+ /**
16
+ * TC-SCHED-010: real-world regression for #7806 — `GET /api/scheduler/jobs?format=json&exportScope=full`
17
+ * must keep applying the route's own `buildFilters` visibility predicate (own-org, own-tenant-scope,
18
+ * superadmin-only system-scope) instead of dropping to `{}`, which would leak every tenant's jobs on a
19
+ * full export (scheduler jobs opt out of the factory's automatic tenant/org scope via
20
+ * `omitAutomaticTenantOrgScope`, so it owns 100% of the visibility logic). Unlike the existing mocked
21
+ * unit coverage in `api/jobs/__tests__/route.scope.test.ts`, this exercises the real authenticated route,
22
+ * real ACL resolution, and a real Postgres-backed query engine.
23
+ */
24
+
25
+ const SCHEDULER_FEATURES = ['scheduler.jobs.view', 'scheduler.jobs.manage']
26
+
27
+ async function createTenantFixture(request: APIRequestContext, superadminToken: string, name: string): Promise<string> {
28
+ const response = await apiRequest(request, 'POST', '/api/directory/tenants', { token: superadminToken, data: { name } })
29
+ expect(response.status(), 'POST /api/directory/tenants should return 201').toBe(201)
30
+ return expectId((await readJsonSafe<{ id?: string }>(response))?.id, 'tenant fixture should return an id')
31
+ }
32
+
33
+ async function deleteTenantIfExists(request: APIRequestContext, superadminToken: string | null, tenantId: string | null): Promise<void> {
34
+ if (!superadminToken || !tenantId) return
35
+ await apiRequest(request, 'DELETE', `/api/directory/tenants?id=${encodeURIComponent(tenantId)}`, { token: superadminToken }).catch(() => undefined)
36
+ }
37
+
38
+ type ScopedActor = {
39
+ organizationId: string
40
+ roleId: string
41
+ userId: string
42
+ token: string
43
+ }
44
+
45
+ /** Provisions a role (scoped to exactly one organization) + user + token, so the actor's own-org
46
+ * visibility can never widen to "every org in the tenant" and mask the isolation this test checks. */
47
+ async function createScopedActor(
48
+ request: APIRequestContext,
49
+ superadminToken: string,
50
+ input: { tenantId: string; organizationId: string; stamp: string; label: string; features?: string[] },
51
+ ): Promise<ScopedActor> {
52
+ const roleId = await createRoleFixture(request, superadminToken, {
53
+ name: `sched-${input.label}-${input.stamp}`,
54
+ tenantId: input.tenantId,
55
+ })
56
+ await setRoleAclFeatures(request, superadminToken, {
57
+ roleId,
58
+ features: input.features ?? SCHEDULER_FEATURES,
59
+ organizations: [input.organizationId],
60
+ })
61
+ const email = `sched-${input.label}-${input.stamp}@test.invalid`
62
+ const password = 'Valid1!Pass'
63
+ const userId = await createUserFixture(request, superadminToken, {
64
+ email,
65
+ password,
66
+ organizationId: input.organizationId,
67
+ roles: [roleId],
68
+ })
69
+ const token = await getAuthToken(request, email, password)
70
+ return { organizationId: input.organizationId, roleId, userId, token }
71
+ }
72
+
73
+ async function cleanupScopedActor(request: APIRequestContext, superadminToken: string, actor: ScopedActor | null): Promise<void> {
74
+ if (!actor) return
75
+ await deleteUserIfExists(request, superadminToken, actor.userId)
76
+ await deleteRoleIfExists(request, superadminToken, actor.roleId)
77
+ }
78
+
79
+ test.describe('TC-SCHED-010: full export stays scoped to the caller tenant/organization (#7806)', () => {
80
+ test('own-org job survives, other-org and foreign-tenant jobs do not; missing-feature caller is refused', async ({ request }) => {
81
+ const superadminToken = await getAuthToken(request, 'superadmin')
82
+ const adminToken = await getAuthToken(request, 'admin')
83
+ const { tenantId: homeTenantId, organizationId: homeOrgId } = getTokenContext(adminToken)
84
+ expect(homeTenantId.length, 'admin token should carry a tenant id').toBeGreaterThan(0)
85
+ expect(homeOrgId.length, 'admin token should carry an organization id').toBeGreaterThan(0)
86
+
87
+ const stamp = `${Date.now()}`
88
+
89
+ let ownOrgActor: ScopedActor | null = null
90
+ let otherOrgActor: ScopedActor | null = null
91
+ let viewOnlyActor: ScopedActor | null = null
92
+ let secondOrgId: string | null = null
93
+ let foreignTenantId: string | null = null
94
+ let foreignOrgId: string | null = null
95
+ let foreignRoleId: string | null = null
96
+ let foreignUserId: string | null = null
97
+ let ownOrgJobId: string | null = null
98
+ let otherOrgJobId: string | null = null
99
+ let foreignTenantJobId: string | null = null
100
+ let foreignToken: string | null = null
101
+
102
+ try {
103
+ // Own-org actor: scoped to the home tenant's existing organization.
104
+ ownOrgActor = await createScopedActor(request, superadminToken, {
105
+ tenantId: homeTenantId,
106
+ organizationId: homeOrgId,
107
+ stamp,
108
+ label: 'own',
109
+ })
110
+
111
+ // Other-org actor: a SECOND organization inside the SAME tenant.
112
+ secondOrgId = await createOrganizationFixture(request, superadminToken, {
113
+ name: `QA SCHED Org B ${stamp}`,
114
+ tenantId: homeTenantId,
115
+ })
116
+ otherOrgActor = await createScopedActor(request, superadminToken, {
117
+ tenantId: homeTenantId,
118
+ organizationId: secondOrgId,
119
+ stamp,
120
+ label: 'other-org',
121
+ })
122
+
123
+ // Foreign-tenant actor: a genuinely different tenant + organization (the #7806 boundary).
124
+ foreignTenantId = await createTenantFixture(request, superadminToken, `QA SCHED Tenant B ${stamp}`)
125
+ foreignOrgId = await createOrganizationFixture(request, superadminToken, {
126
+ name: `QA SCHED Org C ${stamp}`,
127
+ tenantId: foreignTenantId,
128
+ })
129
+ foreignRoleId = await createRoleFixture(request, superadminToken, { name: `sched-foreign-${stamp}`, tenantId: foreignTenantId })
130
+ await setRoleAclFeatures(request, superadminToken, { roleId: foreignRoleId, features: SCHEDULER_FEATURES, organizations: [foreignOrgId] })
131
+ const foreignEmail = `sched-foreign-${stamp}@test.invalid`
132
+ const foreignPassword = 'Valid1!Pass'
133
+ foreignUserId = await createUserFixture(request, superadminToken, {
134
+ email: foreignEmail,
135
+ password: foreignPassword,
136
+ organizationId: foreignOrgId,
137
+ roles: [foreignRoleId],
138
+ })
139
+ foreignToken = await getAuthToken(request, foreignEmail, foreignPassword)
140
+
141
+ // View-only (no scheduler.jobs.view) actor in the home org, for the 403 assertion.
142
+ viewOnlyActor = await createScopedActor(request, superadminToken, {
143
+ tenantId: homeTenantId,
144
+ organizationId: homeOrgId,
145
+ stamp,
146
+ label: 'nofeat',
147
+ features: [],
148
+ })
149
+
150
+ ownOrgJobId = await createScheduleJob(request, ownOrgActor.token, { name: `QA SCHED Own Org Job ${stamp}` })
151
+ otherOrgJobId = await createScheduleJob(request, otherOrgActor.token, { name: `QA SCHED Other Org Job ${stamp}` })
152
+ foreignTenantJobId = await createScheduleJob(request, foreignToken, { name: `QA SCHED Foreign Tenant Job ${stamp}` })
153
+
154
+ // Full export as the own-org actor must only surface the own-org job.
155
+ const exportResponse = await apiRequest(
156
+ request,
157
+ 'GET',
158
+ `${SCHEDULER_JOBS_PATH}?format=json&exportScope=full`,
159
+ { token: ownOrgActor.token },
160
+ )
161
+ expect(exportResponse.status(), 'full export should return 200 for an authorized caller').toBe(200)
162
+ // The full-export JSON serializer keys each row by its human-readable column header
163
+ // (e.g. "Id"), not the raw field name — see `serializeJson` in `lib/crud/exporters.ts`.
164
+ const exported = await readJsonSafe<Array<{ Id: string }>>(exportResponse)
165
+ const exportedIds = new Set((exported ?? []).map((item) => item.Id))
166
+
167
+ expect(exportedIds.has(ownOrgJobId), 'own-org job must survive the full export').toBe(true)
168
+ expect(exportedIds.has(otherOrgJobId), 'another organization\'s job (same tenant) must not leak into the export').toBe(false)
169
+ expect(exportedIds.has(foreignTenantJobId), 'a foreign tenant\'s job must not leak into the export (#7806)').toBe(false)
170
+
171
+ // A caller without scheduler.jobs.view is refused outright, not served a scoped-empty export.
172
+ const forbiddenResponse = await apiRequest(
173
+ request,
174
+ 'GET',
175
+ `${SCHEDULER_JOBS_PATH}?format=json&exportScope=full`,
176
+ { token: viewOnlyActor.token },
177
+ )
178
+ expect(forbiddenResponse.status(), 'full export must be refused without scheduler.jobs.view').toBe(403)
179
+ const forbiddenBody = await readJsonSafe<{ error?: string; requiredFeatures?: string[] }>(forbiddenResponse)
180
+ expect(forbiddenBody?.requiredFeatures ?? []).toContain('scheduler.jobs.view')
181
+ } finally {
182
+ await deleteScheduleJob(request, superadminToken, ownOrgJobId)
183
+ await deleteScheduleJob(request, otherOrgActor?.token ?? superadminToken, otherOrgJobId)
184
+ await deleteScheduleJob(request, foreignToken ?? superadminToken, foreignTenantJobId)
185
+
186
+ await cleanupScopedActor(request, superadminToken, ownOrgActor)
187
+ await cleanupScopedActor(request, superadminToken, otherOrgActor)
188
+ await cleanupScopedActor(request, superadminToken, viewOnlyActor)
189
+ await deleteUserIfExists(request, superadminToken, foreignUserId)
190
+ await deleteRoleIfExists(request, superadminToken, foreignRoleId)
191
+
192
+ await deleteOrganizationIfExists(request, superadminToken, secondOrgId)
193
+ await deleteOrganizationIfExists(request, superadminToken, foreignOrgId)
194
+ await deleteTenantIfExists(request, superadminToken, foreignTenantId)
195
+ }
196
+ })
197
+ })
@@ -7,6 +7,8 @@ jest.mock('@open-mercato/cache', () => ({
7
7
  const resolveContainerValue = jest.fn()
8
8
  const tenantId = '11111111-1111-4111-8111-111111111111'
9
9
  const organizationId = '22222222-2222-4222-8222-222222222222'
10
+ const otherTenantId = '33333333-3333-4333-8333-333333333333'
11
+ const otherOrganizationId = '44444444-4444-4444-8444-444444444444'
10
12
 
11
13
  jest.mock('@open-mercato/shared/lib/di/container', () => ({
12
14
  createRequestContainer: async () => ({ resolve: resolveContainerValue }),
@@ -22,8 +24,18 @@ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
22
24
  getAuthFromCookies: async () => null,
23
25
  }))
24
26
 
27
+ // Mutable per-test override so the full-export regression below can grant a
28
+ // non-empty organization scope while the pre-existing "denies" test keeps its
29
+ // original empty-scope behavior.
30
+ let organizationScopeOverride: {
31
+ tenantId: string
32
+ selectedId: string | null
33
+ filterIds: string[]
34
+ allowedIds: string[]
35
+ } | null = null
36
+
25
37
  jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () => ({
26
- resolveOrganizationScopeForRequest: async () => ({
38
+ resolveOrganizationScopeForRequest: async () => organizationScopeOverride ?? ({
27
39
  tenantId,
28
40
  selectedId: organizationId,
29
41
  filterIds: [],
@@ -33,9 +45,40 @@ jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () =>
33
45
 
34
46
  import { GET } from '../route'
35
47
 
48
+ type MockQueryOptions = {
49
+ filters?: Record<string, unknown>
50
+ page?: {
51
+ page?: number
52
+ pageSize?: number
53
+ }
54
+ }
55
+
56
+ // Minimal matcher for the `{ $or, $eq, $in }` filter shapes buildSchedulerJobsFilters
57
+ // produces, so the mocked query engine below genuinely enforces the filters the route
58
+ // passes it instead of always returning every row regardless of scope.
59
+ function matchesFilter(item: Record<string, unknown>, filter: Record<string, unknown> | undefined): boolean {
60
+ if (!filter) return true
61
+ for (const [key, condition] of Object.entries(filter)) {
62
+ if (key === '$or') {
63
+ const branches = condition as Record<string, unknown>[]
64
+ if (!branches.some((branch) => matchesFilter(item, branch))) return false
65
+ continue
66
+ }
67
+ if (condition && typeof condition === 'object' && !Array.isArray(condition)) {
68
+ const ops = condition as Record<string, unknown>
69
+ if ('$eq' in ops && item[key] !== ops.$eq) return false
70
+ if ('$in' in ops && !(ops.$in as unknown[]).includes(item[key])) return false
71
+ } else if (item[key] !== condition) {
72
+ return false
73
+ }
74
+ }
75
+ return true
76
+ }
77
+
36
78
  describe('scheduler jobs route organization scope', () => {
37
79
  beforeEach(() => {
38
80
  jest.clearAllMocks()
81
+ organizationScopeOverride = null
39
82
  })
40
83
 
41
84
  it('denies the opt-out list before cache, query-engine, or entity-manager access', async () => {
@@ -51,4 +94,90 @@ describe('scheduler jobs route organization scope', () => {
51
94
  })
52
95
  expect(resolveContainerValue).not.toHaveBeenCalled()
53
96
  })
97
+
98
+ describe('full export (#7806)', () => {
99
+ const baseJob = {
100
+ description: null,
101
+ schedule_type: 'cron',
102
+ schedule_value: '* * * * *',
103
+ timezone: 'UTC',
104
+ target_type: 'queue',
105
+ target_queue: 'default',
106
+ target_command: null,
107
+ target_payload: null,
108
+ require_feature: null,
109
+ is_enabled: true,
110
+ last_run_at: null,
111
+ next_run_at: null,
112
+ source_type: 'user',
113
+ source_module: null,
114
+ created_at: '2026-01-01T00:00:00.000Z',
115
+ updated_at: '2026-01-01T00:00:00.000Z',
116
+ }
117
+
118
+ const jobs = [
119
+ {
120
+ ...baseJob,
121
+ id: 'job-own-org',
122
+ name: 'Own org job',
123
+ scope_type: 'organization',
124
+ organization_id: organizationId,
125
+ tenant_id: tenantId,
126
+ },
127
+ {
128
+ ...baseJob,
129
+ id: 'job-own-tenant-scope',
130
+ name: 'Own tenant-scope job',
131
+ scope_type: 'tenant',
132
+ organization_id: null,
133
+ tenant_id: tenantId,
134
+ },
135
+ {
136
+ ...baseJob,
137
+ id: 'job-other-tenant',
138
+ name: 'Other tenant job',
139
+ scope_type: 'organization',
140
+ organization_id: otherOrganizationId,
141
+ tenant_id: otherTenantId,
142
+ },
143
+ ]
144
+
145
+ const makeQueryEngine = () => ({
146
+ query: jest.fn(async (_entityId: unknown, opts: MockQueryOptions) => {
147
+ const filtered = jobs.filter((job) => matchesFilter(job, opts.filters))
148
+ const page = opts.page?.page ?? 1
149
+ const pageSize = typeof opts.page?.pageSize === 'number' ? opts.page.pageSize : filtered.length
150
+ const start = (page - 1) * pageSize
151
+ return { items: filtered.slice(start, start + pageSize), total: filtered.length }
152
+ }),
153
+ })
154
+
155
+ it('scopes a full export to the caller tenant/organization, dropping other tenants', async () => {
156
+ organizationScopeOverride = {
157
+ tenantId,
158
+ selectedId: organizationId,
159
+ filterIds: [organizationId],
160
+ allowedIds: [organizationId],
161
+ }
162
+ const queryEngine = makeQueryEngine()
163
+ resolveContainerValue.mockImplementation((name: string) => ({ queryEngine } as Record<string, unknown>)[name])
164
+
165
+ const response = await GET(new Request('http://localhost/api/scheduler/jobs?format=json&exportScope=full'))
166
+ expect(response.status).toBe(200)
167
+
168
+ const text = await response.text()
169
+ const parsed = JSON.parse(text)
170
+
171
+ // Own-tenant rows (org-scoped and tenant-scoped) survive the export...
172
+ expect(text).toContain('Own org job')
173
+ expect(text).toContain('Own tenant-scope job')
174
+ // ...but the other tenant's row — and any trace of its ids — must not (#7806:
175
+ // before the fix, `exportFullRequested` dropped straight to `{}` as baseFilters,
176
+ // skipping buildSchedulerJobsFilters' visibility predicate entirely).
177
+ expect(text).not.toContain('Other tenant job')
178
+ expect(text).not.toContain(otherTenantId)
179
+ expect(text).not.toContain(otherOrganizationId)
180
+ expect(parsed).toHaveLength(2)
181
+ })
182
+ })
54
183
  })