@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/scheduler/__integration__/TC-SCHED-010-full-export-tenant-scope.spec.js +149 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-010-full-export-tenant-scope.spec.js.map +7 -0
- package/package.json +5 -5
- package/src/modules/scheduler/__integration__/TC-SCHED-010-full-export-tenant-scope.spec.ts +197 -0
- package/src/modules/scheduler/api/jobs/__tests__/route.scope.test.ts +130 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:scheduler] found
|
|
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
|
package/dist/modules/scheduler/__integration__/TC-SCHED-010-full-export-tenant-scope.spec.js.map
ADDED
|
@@ -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.
|
|
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.
|
|
63
|
-
"@open-mercato/queue": "0.10.1-develop.
|
|
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.
|
|
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.
|
|
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
|
})
|