@open-mercato/search 0.6.6-develop.6453.1.6cbc2d5d47 → 0.6.6-develop.6456.1.d4b0c54321

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:search] found 94 entry points
1
+ [build:search] found 95 entry points
2
2
  [build:search] built successfully
@@ -0,0 +1,87 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ import { expectId, getTokenScope, readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
+ import {
5
+ createRoleFixture,
6
+ createUserFixture,
7
+ deleteOrganizationIfExists,
8
+ deleteRoleIfExists,
9
+ deleteUserIfExists,
10
+ setRoleAclFeatures,
11
+ setUserAclVisibility
12
+ } from "@open-mercato/core/helpers/integration/authFixtures";
13
+ const VALID_PASSWORD = "Valid1!Pass";
14
+ const FULLTEXT_CANCEL = "/api/search/reindex/cancel";
15
+ const VECTOR_CANCEL = "/api/search/embeddings/reindex/cancel";
16
+ test.describe("TC-SEARCH-013: reindex cancellation is tenant/org scoped", () => {
17
+ test("scoped cancel returns the 200 contract per scope and stays isolated across orgs", async ({ request }) => {
18
+ test.slow();
19
+ test.setTimeout(12e4);
20
+ const stamp = Date.now();
21
+ let adminToken = null;
22
+ let superToken = null;
23
+ let orgBId = null;
24
+ let roleId = null;
25
+ let userBId = null;
26
+ const roleName = `qa-search-013-${stamp}`;
27
+ const userBEmail = `qa-search-013-${stamp}@acme.com`;
28
+ const expectScopedCancelOk = async (token, path, label) => {
29
+ const res = await apiRequest(request, "POST", path, { token });
30
+ expect(res.status(), `${label}: cancel must pass the auth/feature gates`).not.toBe(401);
31
+ expect(res.status(), `${label}: cancel must pass the auth/feature gates`).not.toBe(403);
32
+ expect(
33
+ res.status(),
34
+ `${label}: scoped cancel must succeed (200), not fail closed (503) \u2014 proves removeQueuedJobsByScope is wired`
35
+ ).toBe(200);
36
+ const body = await readJsonSafe(res) ?? {};
37
+ expect(body.ok, `${label}: cancel reports ok`).toBe(true);
38
+ expect(typeof body.jobsRemoved, `${label}: cancel reports a numeric jobsRemoved`).toBe("number");
39
+ expect(body.jobsRemoved, `${label}: jobsRemoved is a non-negative count`).toBeGreaterThanOrEqual(0);
40
+ expect(body.error, `${label}: a 200 scoped cancel carries no error`).toBeUndefined();
41
+ };
42
+ try {
43
+ adminToken = await getAuthToken(request, "admin");
44
+ superToken = await getAuthToken(request, "superadmin");
45
+ const adminScope = getTokenScope(adminToken);
46
+ await expectScopedCancelOk(adminToken, FULLTEXT_CANCEL, "orgA fulltext");
47
+ await expectScopedCancelOk(adminToken, VECTOR_CANCEL, "orgA vector");
48
+ const orgResp = await apiRequest(request, "POST", "/api/directory/organizations", {
49
+ token: superToken,
50
+ data: { name: `QA Search 013 OrgB ${stamp}`, tenantId: adminScope.tenantId }
51
+ });
52
+ if (orgResp.status() !== 201) {
53
+ test.skip(true, `cannot provision a second organization in this environment (status ${orgResp.status()})`);
54
+ return;
55
+ }
56
+ orgBId = expectId((await readJsonSafe(orgResp))?.id, "organization create returns an id");
57
+ roleId = await createRoleFixture(request, adminToken, { name: roleName, tenantId: adminScope.tenantId });
58
+ await setRoleAclFeatures(request, adminToken, {
59
+ roleId,
60
+ features: ["search.reindex", "search.embeddings.manage"]
61
+ });
62
+ userBId = await createUserFixture(request, superToken, {
63
+ email: userBEmail,
64
+ password: VALID_PASSWORD,
65
+ organizationId: orgBId,
66
+ roles: [roleName],
67
+ name: "QA Search 013 OrgB User"
68
+ });
69
+ await setUserAclVisibility(request, superToken, {
70
+ userId: userBId,
71
+ organizations: [orgBId],
72
+ features: ["search.reindex", "search.embeddings.manage"]
73
+ });
74
+ const userBToken = await getAuthToken(request, userBEmail, VALID_PASSWORD);
75
+ expect(getTokenScope(userBToken).organizationId, "org-B user is scoped to orgB").toBe(orgBId);
76
+ await expectScopedCancelOk(userBToken, FULLTEXT_CANCEL, "orgB fulltext");
77
+ await expectScopedCancelOk(adminToken, FULLTEXT_CANCEL, "orgA fulltext (after orgB)");
78
+ await expectScopedCancelOk(userBToken, VECTOR_CANCEL, "orgB vector");
79
+ await expectScopedCancelOk(adminToken, VECTOR_CANCEL, "orgA vector (after orgB)");
80
+ } finally {
81
+ await deleteUserIfExists(request, superToken, userBId);
82
+ await deleteRoleIfExists(request, adminToken, roleId);
83
+ await deleteOrganizationIfExists(request, superToken, orgBId);
84
+ }
85
+ });
86
+ });
87
+ //# sourceMappingURL=TC-SEARCH-013.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/search/__integration__/TC-SEARCH-013.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { expectId, getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n createRoleFixture,\n createUserFixture,\n deleteOrganizationIfExists,\n deleteRoleIfExists,\n deleteUserIfExists,\n setRoleAclFeatures,\n setUserAclVisibility,\n} from '@open-mercato/core/helpers/integration/authFixtures'\n\ntype CancelBody = { ok?: boolean; jobsRemoved?: number; error?: string }\n\nconst VALID_PASSWORD = 'Valid1!Pass'\nconst FULLTEXT_CANCEL = '/api/search/reindex/cancel'\nconst VECTOR_CANCEL = '/api/search/embeddings/reindex/cancel'\n\n/**\n * TC-SEARCH-013: search reindex cancellation is tenant/organization scoped.\n * Source: PR #3992 follow-up comment (Fixes #3900).\n *\n * PR #3992 replaced the shared-queue-wiping `queue.clear()` in the fulltext and\n * vector reindex cancel routes with a scoped removal:\n * queue.removeQueuedJobsByScope({ tenantId, organizationId?, jobTypes: ['batch-index'] })\n * and made both routes **fail closed with 503** when that scoped method is\n * unavailable or throws. The queue strategies (local/async) gained the method.\n * The PR shipped only unit + route tests against a MOCKED queue; this integration\n * spec locks the contract in against the REAL cancel API + REAL queue strategy.\n *\n * What this asserts (deterministic in the backend-less integration env):\n * 1. Scoped-cancel contract is wired on the real queue. An authenticated admin\n * cancel of BOTH the fulltext and vector paths returns 200 { ok:true,\n * jobsRemoved:<number> } \u2014 NOT the 503 fail-closed body. Under the old code\n * cancel always returned 200 via clear(); under the new code the route 503s\n * when the strategy lacks removeQueuedJobsByScope. So a green result proves\n * the running app's real local queue strategy implements the scoped method \u2014\n * the queue-strategy half of #3992 the mocked unit tests never exercise.\n * 2. Per-scope isolation. A second organization (orgB) with its own confined user\n * cancels independently; admin (orgA) and orgB cancels interleave and every\n * call returns a clean 200 { ok:true }. One scope's cancel neither errors nor\n * 503s another scope's cancel \u2014 the observable proxy, without a live search\n * backend, for \"cancel does not disrupt other scopes' queue state\".\n *\n * Environment constraints (see TC-SEARCH-007): this env has no Meilisearch and no\n * embedding provider, so a reindex enqueues zero `batch-index` jobs and holds no\n * lock \u2014 jobsRemoved is therefore always 0 here, and cross-scope job SURVIVAL with\n * real enqueued jobs plus the 503 fail-closed path (fault injection) are NOT\n * black-box observable; they remain covered by the PR's unit/route tests. Full\n * second-TENANT provisioning is unavailable to integration fixtures, so \u2014 like\n * TC-SEARCH-004 \u2014 a second ORGANIZATION in the same tenant exercises the scope's\n * organizationId dimension; the test skips the isolation case if orgB cannot be\n * provisioned. Cancel is idempotent without a prior reindex (TC-SEARCH-003/007\n * already rely on this in teardown).\n */\ntest.describe('TC-SEARCH-013: reindex cancellation is tenant/org scoped', () => {\n test('scoped cancel returns the 200 contract per scope and stays isolated across orgs', async ({ request }) => {\n test.slow()\n test.setTimeout(120_000)\n\n const stamp = Date.now()\n let adminToken: string | null = null\n let superToken: string | null = null\n let orgBId: string | null = null\n let roleId: string | null = null\n let userBId: string | null = null\n const roleName = `qa-search-013-${stamp}`\n const userBEmail = `qa-search-013-${stamp}@acme.com`\n\n const expectScopedCancelOk = async (token: string, path: string, label: string): Promise<void> => {\n const res = await apiRequest(request, 'POST', path, { token })\n // The scoped-removal path must be reachable: not 401/403 (gates), and crucially\n // not the 503 fail-closed body that the route returns when the real queue\n // strategy lacks removeQueuedJobsByScope.\n expect(res.status(), `${label}: cancel must pass the auth/feature gates`).not.toBe(401)\n expect(res.status(), `${label}: cancel must pass the auth/feature gates`).not.toBe(403)\n expect(\n res.status(),\n `${label}: scoped cancel must succeed (200), not fail closed (503) \u2014 proves removeQueuedJobsByScope is wired`,\n ).toBe(200)\n const body = (await readJsonSafe<CancelBody>(res)) ?? {}\n expect(body.ok, `${label}: cancel reports ok`).toBe(true)\n expect(typeof body.jobsRemoved, `${label}: cancel reports a numeric jobsRemoved`).toBe('number')\n expect(body.jobsRemoved, `${label}: jobsRemoved is a non-negative count`).toBeGreaterThanOrEqual(0)\n expect(body.error, `${label}: a 200 scoped cancel carries no error`).toBeUndefined()\n }\n\n try {\n adminToken = await getAuthToken(request, 'admin')\n superToken = await getAuthToken(request, 'superadmin')\n const adminScope = getTokenScope(adminToken)\n\n // 1. Admin (orgA) \u2014 the scoped-cancel contract is wired on the real queue for\n // both the fulltext and the vector paths.\n await expectScopedCancelOk(adminToken, FULLTEXT_CANCEL, 'orgA fulltext')\n await expectScopedCancelOk(adminToken, VECTOR_CANCEL, 'orgA vector')\n\n // 2. Provision a second organization in the same tenant plus a confined user\n // that holds the reindex + embeddings-manage features. superadmin bypasses\n // the directory create command's tenant-selection enforcement.\n const orgResp = await apiRequest(request, 'POST', '/api/directory/organizations', {\n token: superToken,\n data: { name: `QA Search 013 OrgB ${stamp}`, tenantId: adminScope.tenantId },\n })\n if (orgResp.status() !== 201) {\n test.skip(true, `cannot provision a second organization in this environment (status ${orgResp.status()})`)\n return\n }\n orgBId = expectId((await readJsonSafe<{ id?: string }>(orgResp))?.id, 'organization create returns an id')\n\n roleId = await createRoleFixture(request, adminToken, { name: roleName, tenantId: adminScope.tenantId })\n await setRoleAclFeatures(request, adminToken, {\n roleId,\n features: ['search.reindex', 'search.embeddings.manage'],\n })\n userBId = await createUserFixture(request, superToken, {\n email: userBEmail,\n password: VALID_PASSWORD,\n organizationId: orgBId,\n roles: [roleName],\n name: 'QA Search 013 OrgB User',\n })\n await setUserAclVisibility(request, superToken, {\n userId: userBId,\n organizations: [orgBId],\n features: ['search.reindex', 'search.embeddings.manage'],\n })\n const userBToken = await getAuthToken(request, userBEmail, VALID_PASSWORD)\n expect(getTokenScope(userBToken).organizationId, 'org-B user is scoped to orgB').toBe(orgBId)\n\n // 3. Per-scope isolation: interleave orgA and orgB cancels. Each call returns a\n // clean 200 { ok:true } \u2014 one scope's cancel never errors or fails closed\n // another scope's cancel, i.e. cancel does not wipe/disrupt shared state.\n await expectScopedCancelOk(userBToken, FULLTEXT_CANCEL, 'orgB fulltext')\n await expectScopedCancelOk(adminToken, FULLTEXT_CANCEL, 'orgA fulltext (after orgB)')\n await expectScopedCancelOk(userBToken, VECTOR_CANCEL, 'orgB vector')\n await expectScopedCancelOk(adminToken, VECTOR_CANCEL, 'orgA vector (after orgB)')\n } finally {\n await deleteUserIfExists(request, superToken, userBId)\n await deleteRoleIfExists(request, adminToken, roleId)\n await deleteOrganizationIfExists(request, superToken, orgBId)\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/search",
3
- "version": "0.6.6-develop.6453.1.6cbc2d5d47",
3
+ "version": "0.6.6-develop.6456.1.d4b0c54321",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -127,9 +127,9 @@
127
127
  "zod": "^4.4.3"
128
128
  },
129
129
  "peerDependencies": {
130
- "@open-mercato/core": "0.6.6-develop.6453.1.6cbc2d5d47",
131
- "@open-mercato/queue": "0.6.6-develop.6453.1.6cbc2d5d47",
132
- "@open-mercato/shared": "0.6.6-develop.6453.1.6cbc2d5d47"
130
+ "@open-mercato/core": "0.6.6-develop.6456.1.d4b0c54321",
131
+ "@open-mercato/queue": "0.6.6-develop.6456.1.d4b0c54321",
132
+ "@open-mercato/shared": "0.6.6-develop.6456.1.d4b0c54321"
133
133
  },
134
134
  "devDependencies": {
135
135
  "@types/jest": "^30.0.0",
@@ -0,0 +1,145 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { expectId, getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+ import {
5
+ createRoleFixture,
6
+ createUserFixture,
7
+ deleteOrganizationIfExists,
8
+ deleteRoleIfExists,
9
+ deleteUserIfExists,
10
+ setRoleAclFeatures,
11
+ setUserAclVisibility,
12
+ } from '@open-mercato/core/helpers/integration/authFixtures'
13
+
14
+ type CancelBody = { ok?: boolean; jobsRemoved?: number; error?: string }
15
+
16
+ const VALID_PASSWORD = 'Valid1!Pass'
17
+ const FULLTEXT_CANCEL = '/api/search/reindex/cancel'
18
+ const VECTOR_CANCEL = '/api/search/embeddings/reindex/cancel'
19
+
20
+ /**
21
+ * TC-SEARCH-013: search reindex cancellation is tenant/organization scoped.
22
+ * Source: PR #3992 follow-up comment (Fixes #3900).
23
+ *
24
+ * PR #3992 replaced the shared-queue-wiping `queue.clear()` in the fulltext and
25
+ * vector reindex cancel routes with a scoped removal:
26
+ * queue.removeQueuedJobsByScope({ tenantId, organizationId?, jobTypes: ['batch-index'] })
27
+ * and made both routes **fail closed with 503** when that scoped method is
28
+ * unavailable or throws. The queue strategies (local/async) gained the method.
29
+ * The PR shipped only unit + route tests against a MOCKED queue; this integration
30
+ * spec locks the contract in against the REAL cancel API + REAL queue strategy.
31
+ *
32
+ * What this asserts (deterministic in the backend-less integration env):
33
+ * 1. Scoped-cancel contract is wired on the real queue. An authenticated admin
34
+ * cancel of BOTH the fulltext and vector paths returns 200 { ok:true,
35
+ * jobsRemoved:<number> } — NOT the 503 fail-closed body. Under the old code
36
+ * cancel always returned 200 via clear(); under the new code the route 503s
37
+ * when the strategy lacks removeQueuedJobsByScope. So a green result proves
38
+ * the running app's real local queue strategy implements the scoped method —
39
+ * the queue-strategy half of #3992 the mocked unit tests never exercise.
40
+ * 2. Per-scope isolation. A second organization (orgB) with its own confined user
41
+ * cancels independently; admin (orgA) and orgB cancels interleave and every
42
+ * call returns a clean 200 { ok:true }. One scope's cancel neither errors nor
43
+ * 503s another scope's cancel — the observable proxy, without a live search
44
+ * backend, for "cancel does not disrupt other scopes' queue state".
45
+ *
46
+ * Environment constraints (see TC-SEARCH-007): this env has no Meilisearch and no
47
+ * embedding provider, so a reindex enqueues zero `batch-index` jobs and holds no
48
+ * lock — jobsRemoved is therefore always 0 here, and cross-scope job SURVIVAL with
49
+ * real enqueued jobs plus the 503 fail-closed path (fault injection) are NOT
50
+ * black-box observable; they remain covered by the PR's unit/route tests. Full
51
+ * second-TENANT provisioning is unavailable to integration fixtures, so — like
52
+ * TC-SEARCH-004 — a second ORGANIZATION in the same tenant exercises the scope's
53
+ * organizationId dimension; the test skips the isolation case if orgB cannot be
54
+ * provisioned. Cancel is idempotent without a prior reindex (TC-SEARCH-003/007
55
+ * already rely on this in teardown).
56
+ */
57
+ test.describe('TC-SEARCH-013: reindex cancellation is tenant/org scoped', () => {
58
+ test('scoped cancel returns the 200 contract per scope and stays isolated across orgs', async ({ request }) => {
59
+ test.slow()
60
+ test.setTimeout(120_000)
61
+
62
+ const stamp = Date.now()
63
+ let adminToken: string | null = null
64
+ let superToken: string | null = null
65
+ let orgBId: string | null = null
66
+ let roleId: string | null = null
67
+ let userBId: string | null = null
68
+ const roleName = `qa-search-013-${stamp}`
69
+ const userBEmail = `qa-search-013-${stamp}@acme.com`
70
+
71
+ const expectScopedCancelOk = async (token: string, path: string, label: string): Promise<void> => {
72
+ const res = await apiRequest(request, 'POST', path, { token })
73
+ // The scoped-removal path must be reachable: not 401/403 (gates), and crucially
74
+ // not the 503 fail-closed body that the route returns when the real queue
75
+ // strategy lacks removeQueuedJobsByScope.
76
+ expect(res.status(), `${label}: cancel must pass the auth/feature gates`).not.toBe(401)
77
+ expect(res.status(), `${label}: cancel must pass the auth/feature gates`).not.toBe(403)
78
+ expect(
79
+ res.status(),
80
+ `${label}: scoped cancel must succeed (200), not fail closed (503) — proves removeQueuedJobsByScope is wired`,
81
+ ).toBe(200)
82
+ const body = (await readJsonSafe<CancelBody>(res)) ?? {}
83
+ expect(body.ok, `${label}: cancel reports ok`).toBe(true)
84
+ expect(typeof body.jobsRemoved, `${label}: cancel reports a numeric jobsRemoved`).toBe('number')
85
+ expect(body.jobsRemoved, `${label}: jobsRemoved is a non-negative count`).toBeGreaterThanOrEqual(0)
86
+ expect(body.error, `${label}: a 200 scoped cancel carries no error`).toBeUndefined()
87
+ }
88
+
89
+ try {
90
+ adminToken = await getAuthToken(request, 'admin')
91
+ superToken = await getAuthToken(request, 'superadmin')
92
+ const adminScope = getTokenScope(adminToken)
93
+
94
+ // 1. Admin (orgA) — the scoped-cancel contract is wired on the real queue for
95
+ // both the fulltext and the vector paths.
96
+ await expectScopedCancelOk(adminToken, FULLTEXT_CANCEL, 'orgA fulltext')
97
+ await expectScopedCancelOk(adminToken, VECTOR_CANCEL, 'orgA vector')
98
+
99
+ // 2. Provision a second organization in the same tenant plus a confined user
100
+ // that holds the reindex + embeddings-manage features. superadmin bypasses
101
+ // the directory create command's tenant-selection enforcement.
102
+ const orgResp = await apiRequest(request, 'POST', '/api/directory/organizations', {
103
+ token: superToken,
104
+ data: { name: `QA Search 013 OrgB ${stamp}`, tenantId: adminScope.tenantId },
105
+ })
106
+ if (orgResp.status() !== 201) {
107
+ test.skip(true, `cannot provision a second organization in this environment (status ${orgResp.status()})`)
108
+ return
109
+ }
110
+ orgBId = expectId((await readJsonSafe<{ id?: string }>(orgResp))?.id, 'organization create returns an id')
111
+
112
+ roleId = await createRoleFixture(request, adminToken, { name: roleName, tenantId: adminScope.tenantId })
113
+ await setRoleAclFeatures(request, adminToken, {
114
+ roleId,
115
+ features: ['search.reindex', 'search.embeddings.manage'],
116
+ })
117
+ userBId = await createUserFixture(request, superToken, {
118
+ email: userBEmail,
119
+ password: VALID_PASSWORD,
120
+ organizationId: orgBId,
121
+ roles: [roleName],
122
+ name: 'QA Search 013 OrgB User',
123
+ })
124
+ await setUserAclVisibility(request, superToken, {
125
+ userId: userBId,
126
+ organizations: [orgBId],
127
+ features: ['search.reindex', 'search.embeddings.manage'],
128
+ })
129
+ const userBToken = await getAuthToken(request, userBEmail, VALID_PASSWORD)
130
+ expect(getTokenScope(userBToken).organizationId, 'org-B user is scoped to orgB').toBe(orgBId)
131
+
132
+ // 3. Per-scope isolation: interleave orgA and orgB cancels. Each call returns a
133
+ // clean 200 { ok:true } — one scope's cancel never errors or fails closed
134
+ // another scope's cancel, i.e. cancel does not wipe/disrupt shared state.
135
+ await expectScopedCancelOk(userBToken, FULLTEXT_CANCEL, 'orgB fulltext')
136
+ await expectScopedCancelOk(adminToken, FULLTEXT_CANCEL, 'orgA fulltext (after orgB)')
137
+ await expectScopedCancelOk(userBToken, VECTOR_CANCEL, 'orgB vector')
138
+ await expectScopedCancelOk(adminToken, VECTOR_CANCEL, 'orgA vector (after orgB)')
139
+ } finally {
140
+ await deleteUserIfExists(request, superToken, userBId)
141
+ await deleteRoleIfExists(request, adminToken, roleId)
142
+ await deleteOrganizationIfExists(request, superToken, orgBId)
143
+ }
144
+ })
145
+ })