@open-mercato/webhooks 0.6.5-develop.4544.1.71c003c861 → 0.6.5-develop.4559.1.839e136509
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/webhooks/__integration__/TC-WEBHOOK-004.spec.js +46 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js +92 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js +61 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js +76 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js +81 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js +69 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js +64 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/helpers/fixtures.js +50 -1
- package/dist/modules/webhooks/__integration__/helpers/fixtures.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.ts +63 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.ts +126 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.ts +82 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.ts +102 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.ts +109 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.ts +95 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.ts +89 -0
- package/src/modules/webhooks/__integration__/helpers/fixtures.ts +101 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:webhooks] found
|
|
1
|
+
[build:webhooks] found 66 entry points
|
|
2
2
|
[build:webhooks] built successfully
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import {
|
|
4
|
+
createWebhookFixture,
|
|
5
|
+
deleteWebhookIfExists,
|
|
6
|
+
getWebhookDetail,
|
|
7
|
+
rotateWebhookSecret
|
|
8
|
+
} from "./helpers/fixtures.js";
|
|
9
|
+
test.describe("TC-WEBHOOK-004: Secret rotation with previous secret retention", () => {
|
|
10
|
+
test("should rotate the signing secret, retain the previous one, and mask it on read", async ({ request }) => {
|
|
11
|
+
const token = await getAuthToken(request);
|
|
12
|
+
let webhookId = null;
|
|
13
|
+
try {
|
|
14
|
+
const created = await createWebhookFixture(request, token, {
|
|
15
|
+
name: `Webhook Rotate ${Date.now()}`,
|
|
16
|
+
url: "https://example.com/webhook-rotate",
|
|
17
|
+
subscribedEvents: ["sales.quote.created"]
|
|
18
|
+
});
|
|
19
|
+
webhookId = created.id;
|
|
20
|
+
expect(created.secret.startsWith("whsec_")).toBe(true);
|
|
21
|
+
const beforeRotation = await getWebhookDetail(request, token, created.id);
|
|
22
|
+
expect(beforeRotation.previousSecretSetAt).toBeNull();
|
|
23
|
+
const rotation = await rotateWebhookSecret(request, token, created.id);
|
|
24
|
+
expect(rotation.success).toBe(true);
|
|
25
|
+
expect(rotation.secret.startsWith("whsec_")).toBe(true);
|
|
26
|
+
expect(rotation.secret).not.toBe(created.secret);
|
|
27
|
+
expect(typeof rotation.previousSecretSetAt).toBe("string");
|
|
28
|
+
expect(Number.isNaN(Date.parse(rotation.previousSecretSetAt))).toBe(false);
|
|
29
|
+
const afterRotation = await getWebhookDetail(request, token, created.id);
|
|
30
|
+
expect(afterRotation.maskedSecret.startsWith("whsec_")).toBe(true);
|
|
31
|
+
expect(afterRotation.maskedSecret).not.toBe(rotation.secret);
|
|
32
|
+
expect(afterRotation.maskedSecret).not.toBe(created.secret);
|
|
33
|
+
expect(afterRotation.maskedSecret.length).toBeLessThan(rotation.secret.length);
|
|
34
|
+
expect(afterRotation.previousSecretSetAt).toBe(rotation.previousSecretSetAt);
|
|
35
|
+
const secondRotation = await rotateWebhookSecret(request, token, created.id);
|
|
36
|
+
expect(secondRotation.secret).not.toBe(rotation.secret);
|
|
37
|
+
expect(typeof secondRotation.previousSecretSetAt).toBe("string");
|
|
38
|
+
expect(Date.parse(secondRotation.previousSecretSetAt)).toBeGreaterThanOrEqual(
|
|
39
|
+
Date.parse(rotation.previousSecretSetAt)
|
|
40
|
+
);
|
|
41
|
+
} finally {
|
|
42
|
+
await deleteWebhookIfExists(request, token, webhookId);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
//# sourceMappingURL=TC-WEBHOOK-004.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test';\nimport { getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport {\n createWebhookFixture,\n deleteWebhookIfExists,\n getWebhookDetail,\n rotateWebhookSecret,\n} from './helpers/fixtures';\n\n/**\n * TC-WEBHOOK-004: Secret rotation with previous secret retention\n * Source: https://github.com/open-mercato/open-mercato/issues/2482\n *\n * Verifies the security-critical rotate-secret flow: a new whsec_ secret is issued,\n * the previous secret is retained (so deliveries can dual-sign during rollout), and\n * the detail endpoint reflects the masked new secret plus the rotation timestamp.\n */\ntest.describe('TC-WEBHOOK-004: Secret rotation with previous secret retention', () => {\n test('should rotate the signing secret, retain the previous one, and mask it on read', async ({ request }) => {\n const token = await getAuthToken(request);\n let webhookId: string | null = null;\n\n try {\n const created = await createWebhookFixture(request, token, {\n name: `Webhook Rotate ${Date.now()}`,\n url: 'https://example.com/webhook-rotate',\n subscribedEvents: ['sales.quote.created'],\n });\n webhookId = created.id;\n expect(created.secret.startsWith('whsec_')).toBe(true);\n\n const beforeRotation = await getWebhookDetail(request, token, created.id);\n expect(beforeRotation.previousSecretSetAt).toBeNull();\n\n const rotation = await rotateWebhookSecret(request, token, created.id);\n expect(rotation.success).toBe(true);\n expect(rotation.secret.startsWith('whsec_')).toBe(true);\n expect(rotation.secret).not.toBe(created.secret);\n expect(typeof rotation.previousSecretSetAt).toBe('string');\n expect(Number.isNaN(Date.parse(rotation.previousSecretSetAt as string))).toBe(false);\n\n const afterRotation = await getWebhookDetail(request, token, created.id);\n // The detail endpoint never exposes the raw secret \u2014 only a masked form that\n // reveals the constant \"whsec_\" prefix (maskSecret => `${secret.slice(0, 6)}\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022`).\n expect(afterRotation.maskedSecret.startsWith('whsec_')).toBe(true);\n expect(afterRotation.maskedSecret).not.toBe(rotation.secret);\n expect(afterRotation.maskedSecret).not.toBe(created.secret);\n expect(afterRotation.maskedSecret.length).toBeLessThan(rotation.secret.length);\n // previousSecretSetAt proves the prior secret is retained for dual-sign verification\n // (delivery signing reads webhook.previousSecret; covered at unit level in lib/__tests__).\n expect(afterRotation.previousSecretSetAt).toBe(rotation.previousSecretSetAt);\n\n const secondRotation = await rotateWebhookSecret(request, token, created.id);\n expect(secondRotation.secret).not.toBe(rotation.secret);\n expect(typeof secondRotation.previousSecretSetAt).toBe('string');\n expect(Date.parse(secondRotation.previousSecretSetAt as string)).toBeGreaterThanOrEqual(\n Date.parse(rotation.previousSecretSetAt as string),\n );\n } finally {\n await deleteWebhookIfExists(request, token, webhookId);\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAUP,KAAK,SAAS,kEAAkE,MAAM;AACpF,OAAK,kFAAkF,OAAO,EAAE,QAAQ,MAAM;AAC5G,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,QAAI,YAA2B;AAE/B,QAAI;AACF,YAAM,UAAU,MAAM,qBAAqB,SAAS,OAAO;AAAA,QACzD,MAAM,kBAAkB,KAAK,IAAI,CAAC;AAAA,QAClC,KAAK;AAAA,QACL,kBAAkB,CAAC,qBAAqB;AAAA,MAC1C,CAAC;AACD,kBAAY,QAAQ;AACpB,aAAO,QAAQ,OAAO,WAAW,QAAQ,CAAC,EAAE,KAAK,IAAI;AAErD,YAAM,iBAAiB,MAAM,iBAAiB,SAAS,OAAO,QAAQ,EAAE;AACxE,aAAO,eAAe,mBAAmB,EAAE,SAAS;AAEpD,YAAM,WAAW,MAAM,oBAAoB,SAAS,OAAO,QAAQ,EAAE;AACrE,aAAO,SAAS,OAAO,EAAE,KAAK,IAAI;AAClC,aAAO,SAAS,OAAO,WAAW,QAAQ,CAAC,EAAE,KAAK,IAAI;AACtD,aAAO,SAAS,MAAM,EAAE,IAAI,KAAK,QAAQ,MAAM;AAC/C,aAAO,OAAO,SAAS,mBAAmB,EAAE,KAAK,QAAQ;AACzD,aAAO,OAAO,MAAM,KAAK,MAAM,SAAS,mBAA6B,CAAC,CAAC,EAAE,KAAK,KAAK;AAEnF,YAAM,gBAAgB,MAAM,iBAAiB,SAAS,OAAO,QAAQ,EAAE;AAGvE,aAAO,cAAc,aAAa,WAAW,QAAQ,CAAC,EAAE,KAAK,IAAI;AACjE,aAAO,cAAc,YAAY,EAAE,IAAI,KAAK,SAAS,MAAM;AAC3D,aAAO,cAAc,YAAY,EAAE,IAAI,KAAK,QAAQ,MAAM;AAC1D,aAAO,cAAc,aAAa,MAAM,EAAE,aAAa,SAAS,OAAO,MAAM;AAG7E,aAAO,cAAc,mBAAmB,EAAE,KAAK,SAAS,mBAAmB;AAE3E,YAAM,iBAAiB,MAAM,oBAAoB,SAAS,OAAO,QAAQ,EAAE;AAC3E,aAAO,eAAe,MAAM,EAAE,IAAI,KAAK,SAAS,MAAM;AACtD,aAAO,OAAO,eAAe,mBAAmB,EAAE,KAAK,QAAQ;AAC/D,aAAO,KAAK,MAAM,eAAe,mBAA6B,CAAC,EAAE;AAAA,QAC/D,KAAK,MAAM,SAAS,mBAA6B;AAAA,MACnD;AAAA,IACF,UAAE;AACA,YAAM,sBAAsB,SAAS,OAAO,SAAS;AAAA,IACvD;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import { getTokenScope } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import {
|
|
5
|
+
cleanupWebhooksUser,
|
|
6
|
+
createWebhookFixture,
|
|
7
|
+
deleteWebhookIfExists,
|
|
8
|
+
provisionWebhooksUser
|
|
9
|
+
} from "./helpers/fixtures.js";
|
|
10
|
+
test.describe("TC-WEBHOOK-005: RBAC permission gates by feature flag", () => {
|
|
11
|
+
test("should gate each webhooks surface behind its declared feature", async ({ request }) => {
|
|
12
|
+
const adminToken = await getAuthToken(request);
|
|
13
|
+
const scope = getTokenScope(adminToken);
|
|
14
|
+
let webhookId = null;
|
|
15
|
+
let viewer = null;
|
|
16
|
+
let noAccess = null;
|
|
17
|
+
try {
|
|
18
|
+
viewer = await provisionWebhooksUser(request, adminToken, {
|
|
19
|
+
tenantId: scope.tenantId,
|
|
20
|
+
organizationId: scope.organizationId,
|
|
21
|
+
features: ["webhooks.view"],
|
|
22
|
+
slug: "viewer"
|
|
23
|
+
});
|
|
24
|
+
noAccess = await provisionWebhooksUser(request, adminToken, {
|
|
25
|
+
tenantId: scope.tenantId,
|
|
26
|
+
organizationId: scope.organizationId,
|
|
27
|
+
features: [],
|
|
28
|
+
slug: "noaccess"
|
|
29
|
+
});
|
|
30
|
+
const created = await createWebhookFixture(request, adminToken, {
|
|
31
|
+
name: `Webhook RBAC ${Date.now()}`,
|
|
32
|
+
url: "https://example.com/webhook-rbac",
|
|
33
|
+
subscribedEvents: ["catalog.product.created"],
|
|
34
|
+
customHeaders: { "x-mock-webhook-signature": "valid" }
|
|
35
|
+
});
|
|
36
|
+
webhookId = created.id;
|
|
37
|
+
const adminList = await apiRequest(request, "GET", "/api/webhooks", { token: adminToken });
|
|
38
|
+
expect(adminList.status()).toBe(200);
|
|
39
|
+
const adminDetail = await apiRequest(request, "GET", `/api/webhooks/${created.id}`, { token: adminToken });
|
|
40
|
+
expect(adminDetail.status()).toBe(200);
|
|
41
|
+
const viewerList = await apiRequest(request, "GET", "/api/webhooks", { token: viewer.token });
|
|
42
|
+
expect(viewerList.status()).toBe(200);
|
|
43
|
+
const viewerDetail = await apiRequest(request, "GET", `/api/webhooks/${created.id}`, { token: viewer.token });
|
|
44
|
+
expect(viewerDetail.status()).toBe(200);
|
|
45
|
+
const noAccessList = await apiRequest(request, "GET", "/api/webhooks", { token: noAccess.token });
|
|
46
|
+
expect([401, 403]).toContain(noAccessList.status());
|
|
47
|
+
const noAccessDetail = await apiRequest(request, "GET", `/api/webhooks/${created.id}`, { token: noAccess.token });
|
|
48
|
+
expect([401, 403]).toContain(noAccessDetail.status());
|
|
49
|
+
const viewerCreate = await apiRequest(request, "POST", "/api/webhooks", {
|
|
50
|
+
token: viewer.token,
|
|
51
|
+
data: {
|
|
52
|
+
name: `Webhook RBAC Denied ${Date.now()}`,
|
|
53
|
+
url: "https://example.com/webhook-denied",
|
|
54
|
+
subscribedEvents: ["catalog.product.created"]
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
expect([401, 403]).toContain(viewerCreate.status());
|
|
58
|
+
const viewerUpdate = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
59
|
+
token: viewer.token,
|
|
60
|
+
data: { name: `Webhook RBAC Hijack ${Date.now()}` }
|
|
61
|
+
});
|
|
62
|
+
expect([401, 403]).toContain(viewerUpdate.status());
|
|
63
|
+
const viewerDelete = await apiRequest(request, "DELETE", `/api/webhooks/${created.id}`, { token: viewer.token });
|
|
64
|
+
expect([401, 403]).toContain(viewerDelete.status());
|
|
65
|
+
const viewerRotate = await apiRequest(request, "POST", `/api/webhooks/${created.id}/rotate-secret`, {
|
|
66
|
+
token: viewer.token
|
|
67
|
+
});
|
|
68
|
+
expect([401, 403]).toContain(viewerRotate.status());
|
|
69
|
+
const viewerTest = await apiRequest(request, "POST", `/api/webhooks/${created.id}/test`, {
|
|
70
|
+
token: viewer.token,
|
|
71
|
+
data: { eventType: "catalog.product.created" }
|
|
72
|
+
});
|
|
73
|
+
expect([401, 403]).toContain(viewerTest.status());
|
|
74
|
+
const stillThere = await apiRequest(request, "GET", `/api/webhooks/${created.id}`, { token: adminToken });
|
|
75
|
+
expect(stillThere.status()).toBe(200);
|
|
76
|
+
const adminRotate = await apiRequest(request, "POST", `/api/webhooks/${created.id}/rotate-secret`, {
|
|
77
|
+
token: adminToken
|
|
78
|
+
});
|
|
79
|
+
expect(adminRotate.status()).toBe(200);
|
|
80
|
+
const adminTest = await apiRequest(request, "POST", `/api/webhooks/${created.id}/test`, {
|
|
81
|
+
token: adminToken,
|
|
82
|
+
data: { eventType: "catalog.product.created" }
|
|
83
|
+
});
|
|
84
|
+
expect(adminTest.status()).toBe(200);
|
|
85
|
+
} finally {
|
|
86
|
+
await deleteWebhookIfExists(request, adminToken, webhookId);
|
|
87
|
+
await cleanupWebhooksUser(request, adminToken, viewer);
|
|
88
|
+
await cleanupWebhooksUser(request, adminToken, noAccess);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
//# sourceMappingURL=TC-WEBHOOK-005.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { getTokenScope } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport {\n cleanupWebhooksUser,\n createWebhookFixture,\n deleteWebhookIfExists,\n provisionWebhooksUser,\n type ProvisionedWebhooksUser,\n} from './helpers/fixtures';\n\n/**\n * TC-WEBHOOK-005: RBAC permission gates \u2014 unauthorized access by feature flag\n * Source: https://github.com/open-mercato/open-mercato/issues/2482\n *\n * Verifies every webhooks API surface is gated by its declared feature:\n * webhooks.view \u2192 GET list + GET detail + deliveries\n * webhooks.manage \u2192 POST + PUT + DELETE\n * webhooks.secrets \u2192 POST rotate-secret\n * webhooks.test \u2192 POST test\n * A viewer holding only webhooks.view passes reads but is denied every write/secret/test\n * surface; a user holding no webhooks features is denied reads. Admin (webhooks.*) proves\n * the positive paths. Denials accept 401 or 403 per the issue's acceptance criteria.\n */\ntest.describe('TC-WEBHOOK-005: RBAC permission gates by feature flag', () => {\n test('should gate each webhooks surface behind its declared feature', async ({ request }) => {\n const adminToken = await getAuthToken(request);\n const scope = getTokenScope(adminToken);\n\n let webhookId: string | null = null;\n let viewer: ProvisionedWebhooksUser | null = null;\n let noAccess: ProvisionedWebhooksUser | null = null;\n\n try {\n viewer = await provisionWebhooksUser(request, adminToken, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n features: ['webhooks.view'],\n slug: 'viewer',\n });\n noAccess = await provisionWebhooksUser(request, adminToken, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n features: [],\n slug: 'noaccess',\n });\n\n // --- Positive: admin (webhooks.*) can exercise every surface ---\n const created = await createWebhookFixture(request, adminToken, {\n name: `Webhook RBAC ${Date.now()}`,\n url: 'https://example.com/webhook-rbac',\n subscribedEvents: ['catalog.product.created'],\n customHeaders: { 'x-mock-webhook-signature': 'valid' },\n });\n webhookId = created.id;\n\n const adminList = await apiRequest(request, 'GET', '/api/webhooks', { token: adminToken });\n expect(adminList.status()).toBe(200);\n const adminDetail = await apiRequest(request, 'GET', `/api/webhooks/${created.id}`, { token: adminToken });\n expect(adminDetail.status()).toBe(200);\n\n // --- webhooks.view gate ---\n const viewerList = await apiRequest(request, 'GET', '/api/webhooks', { token: viewer.token });\n expect(viewerList.status()).toBe(200);\n const viewerDetail = await apiRequest(request, 'GET', `/api/webhooks/${created.id}`, { token: viewer.token });\n expect(viewerDetail.status()).toBe(200);\n\n const noAccessList = await apiRequest(request, 'GET', '/api/webhooks', { token: noAccess.token });\n expect([401, 403]).toContain(noAccessList.status());\n const noAccessDetail = await apiRequest(request, 'GET', `/api/webhooks/${created.id}`, { token: noAccess.token });\n expect([401, 403]).toContain(noAccessDetail.status());\n\n // --- webhooks.manage gate (viewer lacks manage) ---\n const viewerCreate = await apiRequest(request, 'POST', '/api/webhooks', {\n token: viewer.token,\n data: {\n name: `Webhook RBAC Denied ${Date.now()}`,\n url: 'https://example.com/webhook-denied',\n subscribedEvents: ['catalog.product.created'],\n },\n });\n expect([401, 403]).toContain(viewerCreate.status());\n\n const viewerUpdate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token: viewer.token,\n data: { name: `Webhook RBAC Hijack ${Date.now()}` },\n });\n expect([401, 403]).toContain(viewerUpdate.status());\n\n const viewerDelete = await apiRequest(request, 'DELETE', `/api/webhooks/${created.id}`, { token: viewer.token });\n expect([401, 403]).toContain(viewerDelete.status());\n\n // --- webhooks.secrets gate (viewer lacks secrets) ---\n const viewerRotate = await apiRequest(request, 'POST', `/api/webhooks/${created.id}/rotate-secret`, {\n token: viewer.token,\n });\n expect([401, 403]).toContain(viewerRotate.status());\n\n // --- webhooks.test gate (viewer lacks test) ---\n const viewerTest = await apiRequest(request, 'POST', `/api/webhooks/${created.id}/test`, {\n token: viewer.token,\n data: { eventType: 'catalog.product.created' },\n });\n expect([401, 403]).toContain(viewerTest.status());\n\n // The webhook must still exist \u2014 none of the denied writes mutated it.\n const stillThere = await apiRequest(request, 'GET', `/api/webhooks/${created.id}`, { token: adminToken });\n expect(stillThere.status()).toBe(200);\n\n // --- Positive secrets + test paths (admin holds both features) ---\n const adminRotate = await apiRequest(request, 'POST', `/api/webhooks/${created.id}/rotate-secret`, {\n token: adminToken,\n });\n expect(adminRotate.status()).toBe(200);\n const adminTest = await apiRequest(request, 'POST', `/api/webhooks/${created.id}/test`, {\n token: adminToken,\n data: { eventType: 'catalog.product.created' },\n });\n expect(adminTest.status()).toBe(200);\n } finally {\n await deleteWebhookIfExists(request, adminToken, webhookId);\n await cleanupWebhooksUser(request, adminToken, viewer);\n await cleanupWebhooksUser(request, adminToken, noAccess);\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import {
|
|
4
|
+
buildMockInboundUrl,
|
|
5
|
+
createWebhookFixture,
|
|
6
|
+
deleteWebhookIfExists,
|
|
7
|
+
listWebhookDeliveries,
|
|
8
|
+
sendTestDelivery
|
|
9
|
+
} from "./helpers/fixtures.js";
|
|
10
|
+
test.describe("TC-WEBHOOK-006: Webhook deactivation blocks delivery", () => {
|
|
11
|
+
test("should deliver while active, block while inactive, and resume after re-activation", async ({ request }) => {
|
|
12
|
+
const token = await getAuthToken(request);
|
|
13
|
+
let webhookId = null;
|
|
14
|
+
try {
|
|
15
|
+
const created = await createWebhookFixture(request, token, {
|
|
16
|
+
name: `Webhook Deactivation ${Date.now()}`,
|
|
17
|
+
url: buildMockInboundUrl(),
|
|
18
|
+
subscribedEvents: ["catalog.product.created"],
|
|
19
|
+
customHeaders: { "x-mock-webhook-signature": "valid" }
|
|
20
|
+
});
|
|
21
|
+
webhookId = created.id;
|
|
22
|
+
const activeDelivery = await sendTestDelivery(request, token, created.id, {
|
|
23
|
+
eventType: "catalog.product.created"
|
|
24
|
+
});
|
|
25
|
+
expect(activeDelivery.delivery.status).toBe("delivered");
|
|
26
|
+
expect(activeDelivery.delivery.responseStatus).toBe(200);
|
|
27
|
+
const deactivate = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
28
|
+
token,
|
|
29
|
+
data: { isActive: false }
|
|
30
|
+
});
|
|
31
|
+
expect(deactivate.status()).toBe(200);
|
|
32
|
+
const blockedDelivery = await sendTestDelivery(request, token, created.id, {
|
|
33
|
+
eventType: "catalog.product.created"
|
|
34
|
+
});
|
|
35
|
+
expect(blockedDelivery.delivery.status).toBe("expired");
|
|
36
|
+
expect(blockedDelivery.delivery.errorMessage).toBe("Webhook is inactive");
|
|
37
|
+
expect(blockedDelivery.delivery.responseStatus).toBeNull();
|
|
38
|
+
const expiredDeliveries = await listWebhookDeliveries(
|
|
39
|
+
request,
|
|
40
|
+
token,
|
|
41
|
+
created.id,
|
|
42
|
+
`/api/webhooks/deliveries?webhookId=${encodeURIComponent(created.id)}&status=expired`
|
|
43
|
+
);
|
|
44
|
+
expect(expiredDeliveries.items.some((item) => item.id === blockedDelivery.delivery.id)).toBe(true);
|
|
45
|
+
expect(expiredDeliveries.items.every((item) => item.status === "expired")).toBe(true);
|
|
46
|
+
const reactivate = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
47
|
+
token,
|
|
48
|
+
data: { isActive: true }
|
|
49
|
+
});
|
|
50
|
+
expect(reactivate.status()).toBe(200);
|
|
51
|
+
const resumedDelivery = await sendTestDelivery(request, token, created.id, {
|
|
52
|
+
eventType: "catalog.product.created"
|
|
53
|
+
});
|
|
54
|
+
expect(resumedDelivery.delivery.status).toBe("delivered");
|
|
55
|
+
expect(resumedDelivery.delivery.responseStatus).toBe(200);
|
|
56
|
+
} finally {
|
|
57
|
+
await deleteWebhookIfExists(request, token, webhookId);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
//# sourceMappingURL=TC-WEBHOOK-006.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport {\n buildMockInboundUrl,\n createWebhookFixture,\n deleteWebhookIfExists,\n listWebhookDeliveries,\n sendTestDelivery,\n} from './helpers/fixtures';\n\n/**\n * TC-WEBHOOK-006: Webhook deactivation blocks delivery\n * Source: https://github.com/open-mercato/open-mercato/issues/2482\n *\n * The isActive flag must prevent delivery. The synchronous test route still records a\n * delivery attempt, but the delivery engine short-circuits inactive webhooks: the\n * delivery is marked `expired` with \"Webhook is inactive\" and no HTTP request is made\n * (responseStatus stays null). Re-activating restores successful delivery.\n */\ntest.describe('TC-WEBHOOK-006: Webhook deactivation blocks delivery', () => {\n test('should deliver while active, block while inactive, and resume after re-activation', async ({ request }) => {\n const token = await getAuthToken(request);\n let webhookId: string | null = null;\n\n try {\n const created = await createWebhookFixture(request, token, {\n name: `Webhook Deactivation ${Date.now()}`,\n url: buildMockInboundUrl(),\n subscribedEvents: ['catalog.product.created'],\n customHeaders: { 'x-mock-webhook-signature': 'valid' },\n });\n webhookId = created.id;\n\n // 1) Active webhook delivers successfully.\n const activeDelivery = await sendTestDelivery(request, token, created.id, {\n eventType: 'catalog.product.created',\n });\n expect(activeDelivery.delivery.status).toBe('delivered');\n expect(activeDelivery.delivery.responseStatus).toBe(200);\n\n // 2) Deactivate.\n const deactivate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token,\n data: { isActive: false },\n });\n expect(deactivate.status()).toBe(200);\n\n // 3) Inactive webhook does not reach the endpoint \u2014 delivery is expired, no HTTP attempt.\n const blockedDelivery = await sendTestDelivery(request, token, created.id, {\n eventType: 'catalog.product.created',\n });\n expect(blockedDelivery.delivery.status).toBe('expired');\n expect(blockedDelivery.delivery.errorMessage).toBe('Webhook is inactive');\n expect(blockedDelivery.delivery.responseStatus).toBeNull();\n\n // The blocked attempt is visible in the delivery log with a non-delivered status.\n const expiredDeliveries = await listWebhookDeliveries(\n request,\n token,\n created.id,\n `/api/webhooks/deliveries?webhookId=${encodeURIComponent(created.id)}&status=expired`,\n );\n expect(expiredDeliveries.items.some((item) => item.id === blockedDelivery.delivery.id)).toBe(true);\n expect(expiredDeliveries.items.every((item) => item.status === 'expired')).toBe(true);\n\n // 4) Re-activate and confirm delivery resumes.\n const reactivate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token,\n data: { isActive: true },\n });\n expect(reactivate.status()).toBe(200);\n\n const resumedDelivery = await sendTestDelivery(request, token, created.id, {\n eventType: 'catalog.product.created',\n });\n expect(resumedDelivery.delivery.status).toBe('delivered');\n expect(resumedDelivery.delivery.responseStatus).toBe(200);\n } finally {\n await deleteWebhookIfExists(request, token, webhookId);\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAWP,KAAK,SAAS,wDAAwD,MAAM;AAC1E,OAAK,qFAAqF,OAAO,EAAE,QAAQ,MAAM;AAC/G,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,QAAI,YAA2B;AAE/B,QAAI;AACF,YAAM,UAAU,MAAM,qBAAqB,SAAS,OAAO;AAAA,QACzD,MAAM,wBAAwB,KAAK,IAAI,CAAC;AAAA,QACxC,KAAK,oBAAoB;AAAA,QACzB,kBAAkB,CAAC,yBAAyB;AAAA,QAC5C,eAAe,EAAE,4BAA4B,QAAQ;AAAA,MACvD,CAAC;AACD,kBAAY,QAAQ;AAGpB,YAAM,iBAAiB,MAAM,iBAAiB,SAAS,OAAO,QAAQ,IAAI;AAAA,QACxE,WAAW;AAAA,MACb,CAAC;AACD,aAAO,eAAe,SAAS,MAAM,EAAE,KAAK,WAAW;AACvD,aAAO,eAAe,SAAS,cAAc,EAAE,KAAK,GAAG;AAGvD,YAAM,aAAa,MAAM,WAAW,SAAS,OAAO,iBAAiB,QAAQ,EAAE,IAAI;AAAA,QACjF;AAAA,QACA,MAAM,EAAE,UAAU,MAAM;AAAA,MAC1B,CAAC;AACD,aAAO,WAAW,OAAO,CAAC,EAAE,KAAK,GAAG;AAGpC,YAAM,kBAAkB,MAAM,iBAAiB,SAAS,OAAO,QAAQ,IAAI;AAAA,QACzE,WAAW;AAAA,MACb,CAAC;AACD,aAAO,gBAAgB,SAAS,MAAM,EAAE,KAAK,SAAS;AACtD,aAAO,gBAAgB,SAAS,YAAY,EAAE,KAAK,qBAAqB;AACxE,aAAO,gBAAgB,SAAS,cAAc,EAAE,SAAS;AAGzD,YAAM,oBAAoB,MAAM;AAAA,QAC9B;AAAA,QACA;AAAA,QACA,QAAQ;AAAA,QACR,sCAAsC,mBAAmB,QAAQ,EAAE,CAAC;AAAA,MACtE;AACA,aAAO,kBAAkB,MAAM,KAAK,CAAC,SAAS,KAAK,OAAO,gBAAgB,SAAS,EAAE,CAAC,EAAE,KAAK,IAAI;AACjG,aAAO,kBAAkB,MAAM,MAAM,CAAC,SAAS,KAAK,WAAW,SAAS,CAAC,EAAE,KAAK,IAAI;AAGpF,YAAM,aAAa,MAAM,WAAW,SAAS,OAAO,iBAAiB,QAAQ,EAAE,IAAI;AAAA,QACjF;AAAA,QACA,MAAM,EAAE,UAAU,KAAK;AAAA,MACzB,CAAC;AACD,aAAO,WAAW,OAAO,CAAC,EAAE,KAAK,GAAG;AAEpC,YAAM,kBAAkB,MAAM,iBAAiB,SAAS,OAAO,QAAQ,IAAI;AAAA,QACzE,WAAW;AAAA,MACb,CAAC;AACD,aAAO,gBAAgB,SAAS,MAAM,EAAE,KAAK,WAAW;AACxD,aAAO,gBAAgB,SAAS,cAAc,EAAE,KAAK,GAAG;AAAA,IAC1D,UAAE;AACA,YAAM,sBAAsB,SAAS,OAAO,SAAS;AAAA,IACvD;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import { getTokenScope } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import { createOrganizationInDb, deleteOrganizationInDb } from "@open-mercato/core/helpers/integration/dbFixtures";
|
|
5
|
+
import {
|
|
6
|
+
cleanupWebhooksUser,
|
|
7
|
+
createWebhookFixture,
|
|
8
|
+
deleteWebhookIfExists,
|
|
9
|
+
listWebhooks,
|
|
10
|
+
provisionWebhooksUser
|
|
11
|
+
} from "./helpers/fixtures.js";
|
|
12
|
+
const MANAGE_FEATURES = ["webhooks.view", "webhooks.manage"];
|
|
13
|
+
test.describe("TC-WEBHOOK-007: Tenant/organization scoping isolation", () => {
|
|
14
|
+
test("should isolate webhooks between organizations within the same tenant", async ({ request }) => {
|
|
15
|
+
const adminToken = await getAuthToken(request);
|
|
16
|
+
const { tenantId, organizationId: orgA } = getTokenScope(adminToken);
|
|
17
|
+
let orgB = null;
|
|
18
|
+
let userA = null;
|
|
19
|
+
let userB = null;
|
|
20
|
+
let webhookA = null;
|
|
21
|
+
let webhookB = null;
|
|
22
|
+
try {
|
|
23
|
+
orgB = await createOrganizationInDb({ name: `TC-WEBHOOK-007 Org B ${Date.now()}`, tenantId });
|
|
24
|
+
userA = await provisionWebhooksUser(request, adminToken, {
|
|
25
|
+
tenantId,
|
|
26
|
+
organizationId: orgA,
|
|
27
|
+
features: MANAGE_FEATURES,
|
|
28
|
+
organizations: [orgA],
|
|
29
|
+
slug: "org-a"
|
|
30
|
+
});
|
|
31
|
+
userB = await provisionWebhooksUser(request, adminToken, {
|
|
32
|
+
tenantId,
|
|
33
|
+
organizationId: orgB,
|
|
34
|
+
features: MANAGE_FEATURES,
|
|
35
|
+
organizations: [orgB],
|
|
36
|
+
slug: "org-b"
|
|
37
|
+
});
|
|
38
|
+
webhookA = await createWebhookFixture(request, userA.token, {
|
|
39
|
+
name: `TC-WEBHOOK-007 A ${Date.now()}`,
|
|
40
|
+
url: "https://example.com/webhook-org-a",
|
|
41
|
+
subscribedEvents: ["catalog.product.created"]
|
|
42
|
+
});
|
|
43
|
+
webhookB = await createWebhookFixture(request, userB.token, {
|
|
44
|
+
name: `TC-WEBHOOK-007 B ${Date.now()}`,
|
|
45
|
+
url: "https://example.com/webhook-org-b",
|
|
46
|
+
subscribedEvents: ["catalog.product.created"]
|
|
47
|
+
});
|
|
48
|
+
const listA = await listWebhooks(request, userA.token);
|
|
49
|
+
expect(listA.items.some((item) => item.id === webhookA.id)).toBe(true);
|
|
50
|
+
expect(listA.items.some((item) => item.id === webhookB.id)).toBe(false);
|
|
51
|
+
const listB = await listWebhooks(request, userB.token);
|
|
52
|
+
expect(listB.items.some((item) => item.id === webhookB.id)).toBe(true);
|
|
53
|
+
expect(listB.items.some((item) => item.id === webhookA.id)).toBe(false);
|
|
54
|
+
const crossGet = await apiRequest(request, "GET", `/api/webhooks/${webhookB.id}`, { token: userA.token });
|
|
55
|
+
expect(crossGet.status()).toBe(404);
|
|
56
|
+
const crossUpdate = await apiRequest(request, "PUT", `/api/webhooks/${webhookB.id}`, {
|
|
57
|
+
token: userA.token,
|
|
58
|
+
data: { name: `TC-WEBHOOK-007 cross-org hijack ${Date.now()}` }
|
|
59
|
+
});
|
|
60
|
+
expect(crossUpdate.status()).toBe(404);
|
|
61
|
+
const crossDelete = await apiRequest(request, "DELETE", `/api/webhooks/${webhookB.id}`, { token: userA.token });
|
|
62
|
+
expect(crossDelete.status()).toBe(404);
|
|
63
|
+
const crossGetReverse = await apiRequest(request, "GET", `/api/webhooks/${webhookA.id}`, { token: userB.token });
|
|
64
|
+
expect(crossGetReverse.status()).toBe(404);
|
|
65
|
+
const ownGet = await apiRequest(request, "GET", `/api/webhooks/${webhookA.id}`, { token: userA.token });
|
|
66
|
+
expect(ownGet.status()).toBe(200);
|
|
67
|
+
} finally {
|
|
68
|
+
await deleteWebhookIfExists(request, userA?.token ?? null, webhookA?.id ?? null);
|
|
69
|
+
await deleteWebhookIfExists(request, userB?.token ?? null, webhookB?.id ?? null);
|
|
70
|
+
await cleanupWebhooksUser(request, adminToken, userA);
|
|
71
|
+
await cleanupWebhooksUser(request, adminToken, userB);
|
|
72
|
+
await deleteOrganizationInDb(orgB);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
//# sourceMappingURL=TC-WEBHOOK-007.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { getTokenScope } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport { createOrganizationInDb, deleteOrganizationInDb } from '@open-mercato/core/helpers/integration/dbFixtures';\nimport {\n cleanupWebhooksUser,\n createWebhookFixture,\n deleteWebhookIfExists,\n listWebhooks,\n provisionWebhooksUser,\n type ProvisionedWebhooksUser,\n type WebhookCreateResponse,\n} from './helpers/fixtures';\n\n/**\n * TC-WEBHOOK-007: Tenant/organization scoping isolation\n * Source: https://github.com/open-mercato/open-mercato/issues/2482\n *\n * Webhooks are scoped by tenantId + organizationId. Within one tenant, two\n * organization-restricted users must not see or mutate each other's webhooks:\n * the list excludes out-of-scope rows and detail/update/delete on a foreign\n * webhook returns 404 (existence is not revealed across the org boundary).\n */\nconst MANAGE_FEATURES = ['webhooks.view', 'webhooks.manage'];\n\ntest.describe('TC-WEBHOOK-007: Tenant/organization scoping isolation', () => {\n test('should isolate webhooks between organizations within the same tenant', async ({ request }) => {\n const adminToken = await getAuthToken(request);\n const { tenantId, organizationId: orgA } = getTokenScope(adminToken);\n\n let orgB: string | null = null;\n let userA: ProvisionedWebhooksUser | null = null;\n let userB: ProvisionedWebhooksUser | null = null;\n let webhookA: WebhookCreateResponse | null = null;\n let webhookB: WebhookCreateResponse | null = null;\n\n try {\n orgB = await createOrganizationInDb({ name: `TC-WEBHOOK-007 Org B ${Date.now()}`, tenantId });\n\n userA = await provisionWebhooksUser(request, adminToken, {\n tenantId,\n organizationId: orgA,\n features: MANAGE_FEATURES,\n organizations: [orgA],\n slug: 'org-a',\n });\n userB = await provisionWebhooksUser(request, adminToken, {\n tenantId,\n organizationId: orgB,\n features: MANAGE_FEATURES,\n organizations: [orgB],\n slug: 'org-b',\n });\n\n webhookA = await createWebhookFixture(request, userA.token, {\n name: `TC-WEBHOOK-007 A ${Date.now()}`,\n url: 'https://example.com/webhook-org-a',\n subscribedEvents: ['catalog.product.created'],\n });\n webhookB = await createWebhookFixture(request, userB.token, {\n name: `TC-WEBHOOK-007 B ${Date.now()}`,\n url: 'https://example.com/webhook-org-b',\n subscribedEvents: ['catalog.product.created'],\n });\n\n // Lists are scoped: each user sees only their own organization's webhook.\n const listA = await listWebhooks(request, userA.token);\n expect(listA.items.some((item) => item.id === webhookA!.id)).toBe(true);\n expect(listA.items.some((item) => item.id === webhookB!.id)).toBe(false);\n\n const listB = await listWebhooks(request, userB.token);\n expect(listB.items.some((item) => item.id === webhookB!.id)).toBe(true);\n expect(listB.items.some((item) => item.id === webhookA!.id)).toBe(false);\n\n // Cross-org reads/writes are not found.\n const crossGet = await apiRequest(request, 'GET', `/api/webhooks/${webhookB.id}`, { token: userA.token });\n expect(crossGet.status()).toBe(404);\n\n const crossUpdate = await apiRequest(request, 'PUT', `/api/webhooks/${webhookB.id}`, {\n token: userA.token,\n data: { name: `TC-WEBHOOK-007 cross-org hijack ${Date.now()}` },\n });\n expect(crossUpdate.status()).toBe(404);\n\n const crossDelete = await apiRequest(request, 'DELETE', `/api/webhooks/${webhookB.id}`, { token: userA.token });\n expect(crossDelete.status()).toBe(404);\n\n const crossGetReverse = await apiRequest(request, 'GET', `/api/webhooks/${webhookA.id}`, { token: userB.token });\n expect(crossGetReverse.status()).toBe(404);\n\n // In-scope access still works after the cross-org attempts.\n const ownGet = await apiRequest(request, 'GET', `/api/webhooks/${webhookA.id}`, { token: userA.token });\n expect(ownGet.status()).toBe(200);\n } finally {\n await deleteWebhookIfExists(request, userA?.token ?? null, webhookA?.id ?? null);\n await deleteWebhookIfExists(request, userB?.token ?? null, webhookB?.id ?? null);\n await cleanupWebhooksUser(request, adminToken, userA);\n await cleanupWebhooksUser(request, adminToken, userB);\n await deleteOrganizationInDb(orgB);\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import {
|
|
4
|
+
buildMockInboundUrl,
|
|
5
|
+
createWebhookFixture,
|
|
6
|
+
deleteWebhookIfExists,
|
|
7
|
+
listWebhookDeliveries,
|
|
8
|
+
sendTestDelivery
|
|
9
|
+
} from "./helpers/fixtures.js";
|
|
10
|
+
const EVENT_CREATED = "catalog.product.created";
|
|
11
|
+
const EVENT_UPDATED = "catalog.product.updated";
|
|
12
|
+
function deliveriesPath(webhookId, extra = {}) {
|
|
13
|
+
const params = new URLSearchParams({ webhookId, ...extra });
|
|
14
|
+
return `/api/webhooks/deliveries?${params.toString()}`;
|
|
15
|
+
}
|
|
16
|
+
test.describe("TC-WEBHOOK-008: Delivery list filtering by status and event type", () => {
|
|
17
|
+
test("should filter delivery logs by status, event type, and their combination", async ({ request }) => {
|
|
18
|
+
const token = await getAuthToken(request);
|
|
19
|
+
let webhookId = null;
|
|
20
|
+
try {
|
|
21
|
+
const created = await createWebhookFixture(request, token, {
|
|
22
|
+
name: `Webhook Filter ${Date.now()}`,
|
|
23
|
+
url: buildMockInboundUrl(),
|
|
24
|
+
subscribedEvents: [EVENT_CREATED, EVENT_UPDATED],
|
|
25
|
+
customHeaders: { "x-mock-webhook-signature": "valid" }
|
|
26
|
+
});
|
|
27
|
+
webhookId = created.id;
|
|
28
|
+
const deliveredCreated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_CREATED });
|
|
29
|
+
expect(deliveredCreated.delivery.status).toBe("delivered");
|
|
30
|
+
const deliveredUpdated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_UPDATED });
|
|
31
|
+
expect(deliveredUpdated.delivery.status).toBe("delivered");
|
|
32
|
+
const invalidate = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
33
|
+
token,
|
|
34
|
+
data: { customHeaders: { "x-mock-webhook-signature": "invalid" } }
|
|
35
|
+
});
|
|
36
|
+
expect(invalidate.status()).toBe(200);
|
|
37
|
+
const expiredCreated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_CREATED });
|
|
38
|
+
expect(expiredCreated.delivery.status).toBe("expired");
|
|
39
|
+
const d1 = deliveredCreated.delivery.id;
|
|
40
|
+
const d2 = deliveredUpdated.delivery.id;
|
|
41
|
+
const d3 = expiredCreated.delivery.id;
|
|
42
|
+
const all = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id));
|
|
43
|
+
expect(all.total).toBeGreaterThanOrEqual(3);
|
|
44
|
+
for (const id of [d1, d2, d3]) {
|
|
45
|
+
expect(all.items.some((item) => item.id === id)).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
const delivered = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: "delivered" }));
|
|
48
|
+
expect(delivered.items.every((item) => item.status === "delivered")).toBe(true);
|
|
49
|
+
expect(delivered.items.some((item) => item.id === d1)).toBe(true);
|
|
50
|
+
expect(delivered.items.some((item) => item.id === d2)).toBe(true);
|
|
51
|
+
expect(delivered.items.some((item) => item.id === d3)).toBe(false);
|
|
52
|
+
const expired = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: "expired" }));
|
|
53
|
+
expect(expired.items.every((item) => item.status === "expired")).toBe(true);
|
|
54
|
+
expect(expired.items.some((item) => item.id === d3)).toBe(true);
|
|
55
|
+
expect(expired.items.some((item) => item.id === d1)).toBe(false);
|
|
56
|
+
const createdEvents = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { eventType: EVENT_CREATED }));
|
|
57
|
+
expect(createdEvents.items.every((item) => item.eventType === EVENT_CREATED)).toBe(true);
|
|
58
|
+
expect(createdEvents.items.some((item) => item.id === d1)).toBe(true);
|
|
59
|
+
expect(createdEvents.items.some((item) => item.id === d3)).toBe(true);
|
|
60
|
+
expect(createdEvents.items.some((item) => item.id === d2)).toBe(false);
|
|
61
|
+
const combined = await listWebhookDeliveries(
|
|
62
|
+
request,
|
|
63
|
+
token,
|
|
64
|
+
created.id,
|
|
65
|
+
deliveriesPath(created.id, { status: "delivered", eventType: EVENT_CREATED })
|
|
66
|
+
);
|
|
67
|
+
expect(combined.items.every((item) => item.status === "delivered" && item.eventType === EVENT_CREATED)).toBe(true);
|
|
68
|
+
expect(combined.items.some((item) => item.id === d1)).toBe(true);
|
|
69
|
+
expect(combined.items.some((item) => item.id === d2)).toBe(false);
|
|
70
|
+
expect(combined.items.some((item) => item.id === d3)).toBe(false);
|
|
71
|
+
const empty = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: "pending" }));
|
|
72
|
+
expect(empty.items).toEqual([]);
|
|
73
|
+
expect(empty.total).toBe(0);
|
|
74
|
+
expect(empty.page).toBe(1);
|
|
75
|
+
expect(empty.totalPages).toBe(0);
|
|
76
|
+
} finally {
|
|
77
|
+
await deleteWebhookIfExists(request, token, webhookId);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
//# sourceMappingURL=TC-WEBHOOK-008.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport {\n buildMockInboundUrl,\n createWebhookFixture,\n deleteWebhookIfExists,\n listWebhookDeliveries,\n sendTestDelivery,\n} from './helpers/fixtures';\n\n/**\n * TC-WEBHOOK-008: Delivery list filtering by status and event type\n * Source: https://github.com/open-mercato/open-mercato/issues/2482\n *\n * GET /api/webhooks/deliveries supports webhookId, status, and eventType filters.\n * Seeds three deliveries on one webhook \u2014 delivered(created), delivered(updated),\n * expired(created) \u2014 then verifies each filter and their intersection.\n */\nconst EVENT_CREATED = 'catalog.product.created';\nconst EVENT_UPDATED = 'catalog.product.updated';\n\nfunction deliveriesPath(webhookId: string, extra: Record<string, string> = {}): string {\n const params = new URLSearchParams({ webhookId, ...extra });\n return `/api/webhooks/deliveries?${params.toString()}`;\n}\n\ntest.describe('TC-WEBHOOK-008: Delivery list filtering by status and event type', () => {\n test('should filter delivery logs by status, event type, and their combination', async ({ request }) => {\n const token = await getAuthToken(request);\n let webhookId: string | null = null;\n\n try {\n const created = await createWebhookFixture(request, token, {\n name: `Webhook Filter ${Date.now()}`,\n url: buildMockInboundUrl(),\n subscribedEvents: [EVENT_CREATED, EVENT_UPDATED],\n customHeaders: { 'x-mock-webhook-signature': 'valid' },\n });\n webhookId = created.id;\n\n const deliveredCreated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_CREATED });\n expect(deliveredCreated.delivery.status).toBe('delivered');\n const deliveredUpdated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_UPDATED });\n expect(deliveredUpdated.delivery.status).toBe('delivered');\n\n // Flip the mock signature to invalid so the next attempt fails terminally (expired).\n const invalidate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token,\n data: { customHeaders: { 'x-mock-webhook-signature': 'invalid' } },\n });\n expect(invalidate.status()).toBe(200);\n\n const expiredCreated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_CREATED });\n expect(expiredCreated.delivery.status).toBe('expired');\n\n const d1 = deliveredCreated.delivery.id;\n const d2 = deliveredUpdated.delivery.id;\n const d3 = expiredCreated.delivery.id;\n\n // Unfiltered (by webhook): all three present.\n const all = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id));\n expect(all.total).toBeGreaterThanOrEqual(3);\n for (const id of [d1, d2, d3]) {\n expect(all.items.some((item) => item.id === id)).toBe(true);\n }\n\n // status=delivered \u2192 d1, d2 only.\n const delivered = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: 'delivered' }));\n expect(delivered.items.every((item) => item.status === 'delivered')).toBe(true);\n expect(delivered.items.some((item) => item.id === d1)).toBe(true);\n expect(delivered.items.some((item) => item.id === d2)).toBe(true);\n expect(delivered.items.some((item) => item.id === d3)).toBe(false);\n\n // status=expired \u2192 d3 only.\n const expired = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: 'expired' }));\n expect(expired.items.every((item) => item.status === 'expired')).toBe(true);\n expect(expired.items.some((item) => item.id === d3)).toBe(true);\n expect(expired.items.some((item) => item.id === d1)).toBe(false);\n\n // eventType=created \u2192 d1, d3 only.\n const createdEvents = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { eventType: EVENT_CREATED }));\n expect(createdEvents.items.every((item) => item.eventType === EVENT_CREATED)).toBe(true);\n expect(createdEvents.items.some((item) => item.id === d1)).toBe(true);\n expect(createdEvents.items.some((item) => item.id === d3)).toBe(true);\n expect(createdEvents.items.some((item) => item.id === d2)).toBe(false);\n\n // Combined status=delivered & eventType=created \u2192 d1 only.\n const combined = await listWebhookDeliveries(\n request,\n token,\n created.id,\n deliveriesPath(created.id, { status: 'delivered', eventType: EVENT_CREATED }),\n );\n expect(combined.items.every((item) => item.status === 'delivered' && item.eventType === EVENT_CREATED)).toBe(true);\n expect(combined.items.some((item) => item.id === d1)).toBe(true);\n expect(combined.items.some((item) => item.id === d2)).toBe(false);\n expect(combined.items.some((item) => item.id === d3)).toBe(false);\n\n // Non-matching filter \u2192 empty result set with pagination metadata.\n const empty = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: 'pending' }));\n expect(empty.items).toEqual([]);\n expect(empty.total).toBe(0);\n expect(empty.page).toBe(1);\n expect(empty.totalPages).toBe(0);\n } finally {\n await deleteWebhookIfExists(request, token, webhookId);\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import { createWebhookFixture, deleteWebhookIfExists } from "./helpers/fixtures.js";
|
|
5
|
+
const PRIVATE_URLS_ALLOWED = ["1", "true", "yes", "on"].includes(
|
|
6
|
+
(process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS ?? "").trim().toLowerCase()
|
|
7
|
+
);
|
|
8
|
+
const ALWAYS_UNSAFE_URLS = [
|
|
9
|
+
"ftp://example.com/webhook",
|
|
10
|
+
"file:///etc/passwd",
|
|
11
|
+
"http://user:pass@example.com/webhook",
|
|
12
|
+
"not-a-valid-url"
|
|
13
|
+
];
|
|
14
|
+
const PRIVATE_HOST_URLS = [
|
|
15
|
+
"http://127.0.0.1:9001/webhook",
|
|
16
|
+
"http://10.0.0.1/webhook",
|
|
17
|
+
"http://[::1]:9001/webhook"
|
|
18
|
+
];
|
|
19
|
+
function createBody(url) {
|
|
20
|
+
return {
|
|
21
|
+
name: `Webhook URL Safety ${Date.now()}`,
|
|
22
|
+
url,
|
|
23
|
+
subscribedEvents: ["catalog.product.created"]
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
test.describe("TC-WEBHOOK-009: Invalid and unsafe URL rejection", () => {
|
|
27
|
+
test("should reject unsafe webhook URLs on create and update and accept valid external URLs", async ({ request }) => {
|
|
28
|
+
const token = await getAuthToken(request);
|
|
29
|
+
let webhookId = null;
|
|
30
|
+
const strayPrivateWebhookIds = [];
|
|
31
|
+
try {
|
|
32
|
+
for (const url of ALWAYS_UNSAFE_URLS) {
|
|
33
|
+
const response = await apiRequest(request, "POST", "/api/webhooks", { token, data: createBody(url) });
|
|
34
|
+
expect(response.status(), `POST should reject unsafe url ${url}`).toBe(400);
|
|
35
|
+
}
|
|
36
|
+
const created = await createWebhookFixture(request, token, {
|
|
37
|
+
name: `Webhook URL Safety Valid ${Date.now()}`,
|
|
38
|
+
url: "https://example.com/webhook-url-safety",
|
|
39
|
+
subscribedEvents: ["catalog.product.created"]
|
|
40
|
+
});
|
|
41
|
+
webhookId = created.id;
|
|
42
|
+
for (const url of ALWAYS_UNSAFE_URLS) {
|
|
43
|
+
const response = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, { token, data: { url } });
|
|
44
|
+
expect(response.status(), `PUT should reject unsafe url ${url}`).toBe(400);
|
|
45
|
+
}
|
|
46
|
+
const validUpdate = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
47
|
+
token,
|
|
48
|
+
data: { url: "https://example.org/webhook-url-safety-updated" }
|
|
49
|
+
});
|
|
50
|
+
expect(validUpdate.status()).toBe(200);
|
|
51
|
+
for (const url of PRIVATE_HOST_URLS) {
|
|
52
|
+
const response = await apiRequest(request, "POST", "/api/webhooks", { token, data: createBody(url) });
|
|
53
|
+
if (PRIVATE_URLS_ALLOWED) {
|
|
54
|
+
expect(response.status(), `POST should accept private url ${url} when allowed`).toBe(201);
|
|
55
|
+
const body = await readJsonSafe(response);
|
|
56
|
+
if (body?.id) strayPrivateWebhookIds.push(body.id);
|
|
57
|
+
} else {
|
|
58
|
+
expect(response.status(), `POST should reject private url ${url} when not allowed`).toBe(400);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
await deleteWebhookIfExists(request, token, webhookId);
|
|
63
|
+
for (const id of strayPrivateWebhookIds) {
|
|
64
|
+
await deleteWebhookIfExists(request, token, id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
//# sourceMappingURL=TC-WEBHOOK-009.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport { createWebhookFixture, deleteWebhookIfExists } from './helpers/fixtures';\n\n/**\n * TC-WEBHOOK-009: Invalid and unsafe URL rejection\n * Source: https://github.com/open-mercato/open-mercato/issues/2482\n *\n * Webhook URLs go through the shared outbound-URL safety check. Forbidden schemes,\n * embedded credentials, and malformed URLs are rejected with 400 on both create and\n * update regardless of configuration. Private/loopback hosts are rejected unless the\n * deployment opts in via OM_WEBHOOKS_ALLOW_PRIVATE_URLS (the integration env enables\n * it so the loopback mock receiver works) \u2014 this spec asserts whichever behavior the\n * active flag dictates. Exhaustive private-IP coverage lives in the unit suites\n * (data/__tests__/validators.test.ts, lib/__tests__/url-safety.test.ts).\n */\nconst PRIVATE_URLS_ALLOWED = ['1', 'true', 'yes', 'on'].includes(\n (process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS ?? '').trim().toLowerCase(),\n);\n\nconst ALWAYS_UNSAFE_URLS = [\n 'ftp://example.com/webhook',\n 'file:///etc/passwd',\n 'http://user:pass@example.com/webhook',\n 'not-a-valid-url',\n];\n\nconst PRIVATE_HOST_URLS = [\n 'http://127.0.0.1:9001/webhook',\n 'http://10.0.0.1/webhook',\n 'http://[::1]:9001/webhook',\n];\n\nfunction createBody(url: string) {\n return {\n name: `Webhook URL Safety ${Date.now()}`,\n url,\n subscribedEvents: ['catalog.product.created'],\n };\n}\n\ntest.describe('TC-WEBHOOK-009: Invalid and unsafe URL rejection', () => {\n test('should reject unsafe webhook URLs on create and update and accept valid external URLs', async ({ request }) => {\n const token = await getAuthToken(request);\n let webhookId: string | null = null;\n const strayPrivateWebhookIds: string[] = [];\n\n try {\n // Always-unsafe URLs are rejected at create regardless of the private-URL flag.\n for (const url of ALWAYS_UNSAFE_URLS) {\n const response = await apiRequest(request, 'POST', '/api/webhooks', { token, data: createBody(url) });\n expect(response.status(), `POST should reject unsafe url ${url}`).toBe(400);\n }\n\n // A valid external https URL is accepted.\n const created = await createWebhookFixture(request, token, {\n name: `Webhook URL Safety Valid ${Date.now()}`,\n url: 'https://example.com/webhook-url-safety',\n subscribedEvents: ['catalog.product.created'],\n });\n webhookId = created.id;\n\n // Updating an existing webhook to an unsafe URL is rejected; the record is unchanged.\n for (const url of ALWAYS_UNSAFE_URLS) {\n const response = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, { token, data: { url } });\n expect(response.status(), `PUT should reject unsafe url ${url}`).toBe(400);\n }\n\n // Updating to another valid external URL succeeds.\n const validUpdate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token,\n data: { url: 'https://example.org/webhook-url-safety-updated' },\n });\n expect(validUpdate.status()).toBe(200);\n\n // Private/loopback hosts: behavior depends on the deployment flag.\n for (const url of PRIVATE_HOST_URLS) {\n const response = await apiRequest(request, 'POST', '/api/webhooks', { token, data: createBody(url) });\n if (PRIVATE_URLS_ALLOWED) {\n expect(response.status(), `POST should accept private url ${url} when allowed`).toBe(201);\n const body = await readJsonSafe<{ id?: string }>(response);\n if (body?.id) strayPrivateWebhookIds.push(body.id);\n } else {\n expect(response.status(), `POST should reject private url ${url} when not allowed`).toBe(400);\n }\n }\n } finally {\n await deleteWebhookIfExists(request, token, webhookId);\n for (const id of strayPrivateWebhookIds) {\n await deleteWebhookIfExists(request, token, id);\n }\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B,SAAS,sBAAsB,6BAA6B;AAc5D,MAAM,uBAAuB,CAAC,KAAK,QAAQ,OAAO,IAAI,EAAE;AAAA,GACrD,QAAQ,IAAI,kCAAkC,IAAI,KAAK,EAAE,YAAY;AACxE;AAEA,MAAM,qBAAqB;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,MAAM,oBAAoB;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AACF;AAEA,SAAS,WAAW,KAAa;AAC/B,SAAO;AAAA,IACL,MAAM,sBAAsB,KAAK,IAAI,CAAC;AAAA,IACtC;AAAA,IACA,kBAAkB,CAAC,yBAAyB;AAAA,EAC9C;AACF;AAEA,KAAK,SAAS,oDAAoD,MAAM;AACtE,OAAK,yFAAyF,OAAO,EAAE,QAAQ,MAAM;AACnH,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,QAAI,YAA2B;AAC/B,UAAM,yBAAmC,CAAC;AAE1C,QAAI;AAEF,iBAAW,OAAO,oBAAoB;AACpC,cAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,iBAAiB,EAAE,OAAO,MAAM,WAAW,GAAG,EAAE,CAAC;AACpG,eAAO,SAAS,OAAO,GAAG,iCAAiC,GAAG,EAAE,EAAE,KAAK,GAAG;AAAA,MAC5E;AAGA,YAAM,UAAU,MAAM,qBAAqB,SAAS,OAAO;AAAA,QACzD,MAAM,4BAA4B,KAAK,IAAI,CAAC;AAAA,QAC5C,KAAK;AAAA,QACL,kBAAkB,CAAC,yBAAyB;AAAA,MAC9C,CAAC;AACD,kBAAY,QAAQ;AAGpB,iBAAW,OAAO,oBAAoB;AACpC,cAAM,WAAW,MAAM,WAAW,SAAS,OAAO,iBAAiB,QAAQ,EAAE,IAAI,EAAE,OAAO,MAAM,EAAE,IAAI,EAAE,CAAC;AACzG,eAAO,SAAS,OAAO,GAAG,gCAAgC,GAAG,EAAE,EAAE,KAAK,GAAG;AAAA,MAC3E;AAGA,YAAM,cAAc,MAAM,WAAW,SAAS,OAAO,iBAAiB,QAAQ,EAAE,IAAI;AAAA,QAClF;AAAA,QACA,MAAM,EAAE,KAAK,iDAAiD;AAAA,MAChE,CAAC;AACD,aAAO,YAAY,OAAO,CAAC,EAAE,KAAK,GAAG;AAGrC,iBAAW,OAAO,mBAAmB;AACnC,cAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,iBAAiB,EAAE,OAAO,MAAM,WAAW,GAAG,EAAE,CAAC;AACpG,YAAI,sBAAsB;AACxB,iBAAO,SAAS,OAAO,GAAG,kCAAkC,GAAG,eAAe,EAAE,KAAK,GAAG;AACxF,gBAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,cAAI,MAAM,GAAI,wBAAuB,KAAK,KAAK,EAAE;AAAA,QACnD,OAAO;AACL,iBAAO,SAAS,OAAO,GAAG,kCAAkC,GAAG,mBAAmB,EAAE,KAAK,GAAG;AAAA,QAC9F;AAAA,MACF;AAAA,IACF,UAAE;AACA,YAAM,sBAAsB,SAAS,OAAO,SAAS;AACrD,iBAAW,MAAM,wBAAwB;AACvC,cAAM,sBAAsB,SAAS,OAAO,EAAE;AAAA,MAChD;AAAA,IACF;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|