@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.
Files changed (26) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js +46 -0
  3. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js.map +7 -0
  4. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js +92 -0
  5. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js.map +7 -0
  6. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js +61 -0
  7. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js.map +7 -0
  8. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js +76 -0
  9. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js.map +7 -0
  10. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js +81 -0
  11. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js.map +7 -0
  12. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js +69 -0
  13. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js.map +7 -0
  14. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js +64 -0
  15. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js.map +7 -0
  16. package/dist/modules/webhooks/__integration__/helpers/fixtures.js +50 -1
  17. package/dist/modules/webhooks/__integration__/helpers/fixtures.js.map +2 -2
  18. package/package.json +6 -6
  19. package/src/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.ts +63 -0
  20. package/src/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.ts +126 -0
  21. package/src/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.ts +82 -0
  22. package/src/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.ts +102 -0
  23. package/src/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.ts +109 -0
  24. package/src/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.ts +95 -0
  25. package/src/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.ts +89 -0
  26. package/src/modules/webhooks/__integration__/helpers/fixtures.ts +101 -1
@@ -0,0 +1,64 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ import {
4
+ buildMockInboundUrl,
5
+ createWebhookFixture,
6
+ deleteWebhookIfExists,
7
+ getDeliveryDetail,
8
+ sendTestDelivery
9
+ } from "./helpers/fixtures.js";
10
+ const EVENT_TYPE = "webhooks.test.detail";
11
+ test.describe("TC-WEBHOOK-010: Delivery detail full payload and response body", () => {
12
+ test("should expose the complete payload and response details for a delivery", async ({ request }) => {
13
+ const token = await getAuthToken(request);
14
+ let webhookId = null;
15
+ try {
16
+ const created = await createWebhookFixture(request, token, {
17
+ name: `Webhook Detail ${Date.now()}`,
18
+ url: buildMockInboundUrl(),
19
+ subscribedEvents: ["catalog.product.created"],
20
+ customHeaders: { "x-mock-webhook-signature": "valid" }
21
+ });
22
+ webhookId = created.id;
23
+ const eventData = { sku: "TEST-123", nested: { value: 42 } };
24
+ const testResult = await sendTestDelivery(request, token, created.id, {
25
+ eventType: EVENT_TYPE,
26
+ payload: eventData
27
+ });
28
+ expect(testResult.delivery.status).toBe("delivered");
29
+ const deliveryId = testResult.delivery.id;
30
+ const detail = await getDeliveryDetail(request, token, deliveryId);
31
+ expect(detail.id).toBe(deliveryId);
32
+ expect(detail.webhookId).toBe(created.id);
33
+ expect(detail.eventType).toBe(EVENT_TYPE);
34
+ expect(detail.targetUrl).toBe(created.url);
35
+ expect(typeof detail.messageId).toBe("string");
36
+ expect(detail.messageId.length).toBeGreaterThan(0);
37
+ expect(detail.status).toBe("delivered");
38
+ expect(detail.responseStatus).toBe(200);
39
+ expect(detail.errorMessage).toBeNull();
40
+ expect(detail.payload).toMatchObject({
41
+ type: EVENT_TYPE,
42
+ data: { sku: "TEST-123", nested: { value: 42 } }
43
+ });
44
+ const envelopeTimestamp = detail.payload.timestamp;
45
+ expect(typeof envelopeTimestamp).toBe("string");
46
+ expect(Number.isNaN(Date.parse(envelopeTimestamp))).toBe(false);
47
+ expect(detail.responseBody).toBe(JSON.stringify({ ok: true }));
48
+ expect(detail.responseHeaders).not.toBeNull();
49
+ expect(detail.responseHeaders?.["content-type"]).toContain("application/json");
50
+ expect(detail.attemptNumber).toBeGreaterThanOrEqual(1);
51
+ expect(detail.maxAttempts).toBeGreaterThanOrEqual(1);
52
+ expect(typeof detail.durationMs).toBe("number");
53
+ expect(detail.durationMs).toBeGreaterThanOrEqual(0);
54
+ expect(detail.nextRetryAt).toBeNull();
55
+ for (const timestamp of [detail.createdAt, detail.enqueuedAt, detail.lastAttemptAt, detail.deliveredAt, detail.updatedAt]) {
56
+ expect(typeof timestamp).toBe("string");
57
+ expect(Number.isNaN(Date.parse(timestamp))).toBe(false);
58
+ }
59
+ } finally {
60
+ await deleteWebhookIfExists(request, token, webhookId);
61
+ }
62
+ });
63
+ });
64
+ //# sourceMappingURL=TC-WEBHOOK-010.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test';\nimport { getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport {\n buildMockInboundUrl,\n createWebhookFixture,\n deleteWebhookIfExists,\n getDeliveryDetail,\n sendTestDelivery,\n} from './helpers/fixtures';\n\n/**\n * TC-WEBHOOK-010: Delivery detail response includes full payload and response body\n * Source: https://github.com/open-mercato/open-mercato/issues/2482\n *\n * The delivery detail endpoint must expose the complete sent payload and the full\n * response captured from the endpoint (status, body, headers) plus attempt/timing\n * metadata, so admins can debug a delivery end-to-end.\n */\nconst EVENT_TYPE = 'webhooks.test.detail';\n\ntest.describe('TC-WEBHOOK-010: Delivery detail full payload and response body', () => {\n test('should expose the complete payload and response details for a delivery', 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 Detail ${Date.now()}`,\n url: buildMockInboundUrl(),\n subscribedEvents: ['catalog.product.created'],\n customHeaders: { 'x-mock-webhook-signature': 'valid' },\n });\n webhookId = created.id;\n\n const eventData = { sku: 'TEST-123', nested: { value: 42 } };\n\n const testResult = await sendTestDelivery(request, token, created.id, {\n eventType: EVENT_TYPE,\n payload: eventData,\n });\n expect(testResult.delivery.status).toBe('delivered');\n const deliveryId = testResult.delivery.id;\n\n const detail = await getDeliveryDetail(request, token, deliveryId);\n\n // Identity + routing\n expect(detail.id).toBe(deliveryId);\n expect(detail.webhookId).toBe(created.id);\n expect(detail.eventType).toBe(EVENT_TYPE);\n expect(detail.targetUrl).toBe(created.url);\n expect(typeof detail.messageId).toBe('string');\n expect(detail.messageId.length).toBeGreaterThan(0);\n\n // Outcome\n expect(detail.status).toBe('delivered');\n expect(detail.responseStatus).toBe(200);\n expect(detail.errorMessage).toBeNull();\n\n // The delivery body wraps the event data in the Standard-Webhooks envelope\n // { type, timestamp, data }; the sent payload is preserved under `data`.\n expect(detail.payload).toMatchObject({\n type: EVENT_TYPE,\n data: { sku: 'TEST-123', nested: { value: 42 } },\n });\n const envelopeTimestamp = (detail.payload as { timestamp?: unknown }).timestamp;\n expect(typeof envelopeTimestamp).toBe('string');\n expect(Number.isNaN(Date.parse(envelopeTimestamp as string))).toBe(false);\n\n // Full captured response (the mock receiver replies { ok: true } as JSON)\n expect(detail.responseBody).toBe(JSON.stringify({ ok: true }));\n expect(detail.responseHeaders).not.toBeNull();\n expect(detail.responseHeaders?.['content-type']).toContain('application/json');\n\n // Attempt + timing metadata\n expect(detail.attemptNumber).toBeGreaterThanOrEqual(1);\n expect(detail.maxAttempts).toBeGreaterThanOrEqual(1);\n expect(typeof detail.durationMs).toBe('number');\n expect(detail.durationMs as number).toBeGreaterThanOrEqual(0);\n expect(detail.nextRetryAt).toBeNull();\n\n for (const timestamp of [detail.createdAt, detail.enqueuedAt, detail.lastAttemptAt, detail.deliveredAt, detail.updatedAt]) {\n expect(typeof timestamp).toBe('string');\n expect(Number.isNaN(Date.parse(timestamp as string))).toBe(false);\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,EACA;AAAA,OACK;AAUP,MAAM,aAAa;AAEnB,KAAK,SAAS,kEAAkE,MAAM;AACpF,OAAK,0EAA0E,OAAO,EAAE,QAAQ,MAAM;AACpG,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,oBAAoB;AAAA,QACzB,kBAAkB,CAAC,yBAAyB;AAAA,QAC5C,eAAe,EAAE,4BAA4B,QAAQ;AAAA,MACvD,CAAC;AACD,kBAAY,QAAQ;AAEpB,YAAM,YAAY,EAAE,KAAK,YAAY,QAAQ,EAAE,OAAO,GAAG,EAAE;AAE3D,YAAM,aAAa,MAAM,iBAAiB,SAAS,OAAO,QAAQ,IAAI;AAAA,QACpE,WAAW;AAAA,QACX,SAAS;AAAA,MACX,CAAC;AACD,aAAO,WAAW,SAAS,MAAM,EAAE,KAAK,WAAW;AACnD,YAAM,aAAa,WAAW,SAAS;AAEvC,YAAM,SAAS,MAAM,kBAAkB,SAAS,OAAO,UAAU;AAGjE,aAAO,OAAO,EAAE,EAAE,KAAK,UAAU;AACjC,aAAO,OAAO,SAAS,EAAE,KAAK,QAAQ,EAAE;AACxC,aAAO,OAAO,SAAS,EAAE,KAAK,UAAU;AACxC,aAAO,OAAO,SAAS,EAAE,KAAK,QAAQ,GAAG;AACzC,aAAO,OAAO,OAAO,SAAS,EAAE,KAAK,QAAQ;AAC7C,aAAO,OAAO,UAAU,MAAM,EAAE,gBAAgB,CAAC;AAGjD,aAAO,OAAO,MAAM,EAAE,KAAK,WAAW;AACtC,aAAO,OAAO,cAAc,EAAE,KAAK,GAAG;AACtC,aAAO,OAAO,YAAY,EAAE,SAAS;AAIrC,aAAO,OAAO,OAAO,EAAE,cAAc;AAAA,QACnC,MAAM;AAAA,QACN,MAAM,EAAE,KAAK,YAAY,QAAQ,EAAE,OAAO,GAAG,EAAE;AAAA,MACjD,CAAC;AACD,YAAM,oBAAqB,OAAO,QAAoC;AACtE,aAAO,OAAO,iBAAiB,EAAE,KAAK,QAAQ;AAC9C,aAAO,OAAO,MAAM,KAAK,MAAM,iBAA2B,CAAC,CAAC,EAAE,KAAK,KAAK;AAGxE,aAAO,OAAO,YAAY,EAAE,KAAK,KAAK,UAAU,EAAE,IAAI,KAAK,CAAC,CAAC;AAC7D,aAAO,OAAO,eAAe,EAAE,IAAI,SAAS;AAC5C,aAAO,OAAO,kBAAkB,cAAc,CAAC,EAAE,UAAU,kBAAkB;AAG7E,aAAO,OAAO,aAAa,EAAE,uBAAuB,CAAC;AACrD,aAAO,OAAO,WAAW,EAAE,uBAAuB,CAAC;AACnD,aAAO,OAAO,OAAO,UAAU,EAAE,KAAK,QAAQ;AAC9C,aAAO,OAAO,UAAoB,EAAE,uBAAuB,CAAC;AAC5D,aAAO,OAAO,WAAW,EAAE,SAAS;AAEpC,iBAAW,aAAa,CAAC,OAAO,WAAW,OAAO,YAAY,OAAO,eAAe,OAAO,aAAa,OAAO,SAAS,GAAG;AACzH,eAAO,OAAO,SAAS,EAAE,KAAK,QAAQ;AACtC,eAAO,OAAO,MAAM,KAAK,MAAM,SAAmB,CAAC,CAAC,EAAE,KAAK,KAAK;AAAA,MAClE;AAAA,IACF,UAAE;AACA,YAAM,sBAAsB,SAAS,OAAO,SAAS;AAAA,IACvD;AAAA,EACF,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -1,6 +1,8 @@
1
1
  import { expect } from "@playwright/test";
2
- import { apiRequest } from "@open-mercato/core/helpers/integration/api";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
3
  import { expectId, readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
+ import { createUserFixture, deleteUserIfExists } from "@open-mercato/core/helpers/integration/authFixtures";
5
+ import { deleteUserAclInDb, setUserAclInDb } from "@open-mercato/core/helpers/integration/dbFixtures";
4
6
  const BASE_URL = process.env.BASE_URL?.trim() || "http://localhost:3000";
5
7
  function buildMockInboundUrl(endpointId = "mock_inbound") {
6
8
  return `${BASE_URL}/api/webhooks/inbound/${endpointId}`;
@@ -96,8 +98,52 @@ async function deleteWebhookIfExists(request, token, webhookId) {
96
98
  return;
97
99
  }
98
100
  }
101
+ async function rotateWebhookSecret(request, token, webhookId, path = `/api/webhooks/${webhookId}/rotate-secret`) {
102
+ const response = await apiRequest(request, "POST", path, { token });
103
+ expect(response.status()).toBe(200);
104
+ const body = await readJsonSafe(response);
105
+ if (!body) {
106
+ throw new Error(`Expected rotate-secret response for ${webhookId}`);
107
+ }
108
+ return body;
109
+ }
110
+ async function sendTestDelivery(request, token, webhookId, input = {}, path = `/api/webhooks/${webhookId}/test`) {
111
+ const response = await apiRequest(request, "POST", path, { token, data: input });
112
+ expect(response.status()).toBe(200);
113
+ const body = await readJsonSafe(response);
114
+ if (!body) {
115
+ throw new Error(`Expected test delivery response for ${webhookId}`);
116
+ }
117
+ return body;
118
+ }
119
+ async function provisionWebhooksUser(request, adminToken, input) {
120
+ const unique = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
121
+ const email = `qa-webhooks-${input.slug}-${unique}@acme.com`;
122
+ const password = "Valid1!Webhooks";
123
+ const userId = await createUserFixture(request, adminToken, {
124
+ email,
125
+ password,
126
+ organizationId: input.organizationId,
127
+ roles: [],
128
+ name: `QA Webhooks ${input.slug}`
129
+ });
130
+ await setUserAclInDb({
131
+ userId,
132
+ tenantId: input.tenantId,
133
+ features: input.features,
134
+ organizations: input.organizations ?? null
135
+ });
136
+ const token = await getAuthToken(request, email, password);
137
+ return { userId, email, password, token };
138
+ }
139
+ async function cleanupWebhooksUser(request, adminToken, user) {
140
+ if (!user) return;
141
+ await deleteUserAclInDb(user.userId).catch(() => void 0);
142
+ await deleteUserIfExists(request, adminToken, user.userId).catch(() => void 0);
143
+ }
99
144
  export {
100
145
  buildMockInboundUrl,
146
+ cleanupWebhooksUser,
101
147
  createWebhookFixture,
102
148
  deleteWebhookIfExists,
103
149
  getDeliveryDetail,
@@ -105,6 +151,9 @@ export {
105
151
  listWebhookDeliveries,
106
152
  listWebhookEvents,
107
153
  listWebhooks,
154
+ provisionWebhooksUser,
155
+ rotateWebhookSecret,
156
+ sendTestDelivery,
108
157
  waitForDeliveryStatus
109
158
  };
110
159
  //# sourceMappingURL=fixtures.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/webhooks/__integration__/helpers/fixtures.ts"],
4
- "sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest } from '@open-mercato/core/helpers/integration/api';\nimport { expectId, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\n\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\n\nexport type WebhookCreateResponse = {\n id: string;\n name: string;\n url: string;\n secret: string;\n subscribedEvents: string[];\n isActive: boolean;\n};\n\nexport type WebhookDetailResponse = {\n id: string;\n name: string;\n description: string | null;\n url: string;\n subscribedEvents: string[];\n httpMethod: 'POST' | 'PUT' | 'PATCH';\n isActive: boolean;\n deliveryStrategy: string;\n maxRetries: number;\n consecutiveFailures: number;\n lastSuccessAt: string | null;\n lastFailureAt: string | null;\n createdAt: string;\n updatedAt: string;\n customHeaders: Record<string, string> | null;\n strategyConfig: Record<string, unknown> | null;\n timeoutMs: number;\n rateLimitPerMinute: number;\n autoDisableThreshold: number;\n integrationId: string | null;\n maskedSecret: string;\n previousSecretSetAt: string | null;\n};\n\nexport type WebhookDeliveryDetailResponse = {\n id: string;\n webhookId: string;\n eventType: string;\n messageId: string;\n status: string;\n responseStatus: number | null;\n errorMessage: string | null;\n attemptNumber: number;\n maxAttempts: number;\n targetUrl: string;\n durationMs: number | null;\n enqueuedAt: string;\n lastAttemptAt: string | null;\n deliveredAt: string | null;\n createdAt: string;\n payload: Record<string, unknown>;\n responseBody: string | null;\n responseHeaders: Record<string, string> | null;\n nextRetryAt: string | null;\n updatedAt: string;\n};\n\ntype DeliveryListResponse = {\n items: Array<{\n id: string;\n webhookId: string;\n webhookName: string | null;\n eventType: string;\n messageId: string;\n status: string;\n responseStatus: number | null;\n errorMessage: string | null;\n attemptNumber: number;\n maxAttempts: number;\n targetUrl: string;\n durationMs: number | null;\n enqueuedAt: string;\n lastAttemptAt: string | null;\n deliveredAt: string | null;\n createdAt: string;\n }>;\n total: number;\n page: number;\n pageSize: number;\n totalPages: number;\n};\n\ntype EventsResponse = {\n data: Array<{\n id: string;\n label: string;\n module?: string;\n category?: string;\n description?: string;\n }>;\n total: number;\n};\n\nexport function buildMockInboundUrl(endpointId = 'mock_inbound'): string {\n return `${BASE_URL}/api/webhooks/inbound/${endpointId}`;\n}\n\nexport async function createWebhookFixture(\n request: APIRequestContext,\n token: string,\n overrides: Partial<{\n name: string;\n description: string | null;\n url: string;\n subscribedEvents: string[];\n httpMethod: 'POST' | 'PUT' | 'PATCH';\n maxRetries: number;\n timeoutMs: number;\n rateLimitPerMinute: number;\n autoDisableThreshold: number;\n customHeaders: Record<string, string> | null;\n }> = {},\n): Promise<WebhookCreateResponse> {\n const response = await apiRequest(request, 'POST', '/api/webhooks', {\n token,\n data: {\n name: overrides.name ?? `Webhook ${Date.now()}`,\n description: overrides.description ?? 'Integration test webhook',\n url: overrides.url ?? buildMockInboundUrl(),\n subscribedEvents: overrides.subscribedEvents ?? ['catalog.product.created'],\n httpMethod: overrides.httpMethod ?? 'POST',\n maxRetries: overrides.maxRetries ?? 3,\n timeoutMs: overrides.timeoutMs ?? 5_000,\n rateLimitPerMinute: overrides.rateLimitPerMinute ?? 0,\n autoDisableThreshold: overrides.autoDisableThreshold ?? 5,\n customHeaders: overrides.customHeaders ?? null,\n },\n });\n\n expect(response.status()).toBe(201);\n const body = await readJsonSafe<WebhookCreateResponse>(response);\n if (!body) {\n throw new Error('Expected webhook create response body');\n }\n\n expectId(body.id, 'Expected webhook id');\n expect(typeof body.secret).toBe('string');\n expect(body.secret.startsWith('whsec_')).toBe(true);\n\n return body;\n}\n\nexport async function getWebhookDetail(\n request: APIRequestContext,\n token: string,\n webhookId: string,\n path = `/api/webhooks/${webhookId}`,\n): Promise<WebhookDetailResponse> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<WebhookDetailResponse>(response);\n if (!body) {\n throw new Error(`Expected webhook detail response for ${webhookId}`);\n }\n return body;\n}\n\nexport async function listWebhooks(\n request: APIRequestContext,\n token: string,\n path = '/api/webhooks',\n): Promise<{ items: Array<{ id: string; name: string; url: string }>; total: number }> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<{ items: Array<{ id: string; name: string; url: string }>; total: number }>(response);\n if (!body) {\n throw new Error('Expected webhook list response');\n }\n return body;\n}\n\nexport async function listWebhookEvents(request: APIRequestContext, token: string): Promise<EventsResponse> {\n const response = await apiRequest(request, 'GET', '/api/webhooks/events', { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<EventsResponse>(response);\n if (!body) {\n throw new Error('Expected webhook events response');\n }\n return body;\n}\n\nexport async function getDeliveryDetail(\n request: APIRequestContext,\n token: string,\n deliveryId: string,\n path = `/api/webhooks/deliveries/${deliveryId}`,\n): Promise<WebhookDeliveryDetailResponse> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<WebhookDeliveryDetailResponse>(response);\n if (!body) {\n throw new Error(`Expected webhook delivery detail for ${deliveryId}`);\n }\n return body;\n}\n\nexport async function listWebhookDeliveries(\n request: APIRequestContext,\n token: string,\n webhookId: string,\n path = `/api/webhooks/deliveries?webhookId=${encodeURIComponent(webhookId)}`,\n): Promise<DeliveryListResponse> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<DeliveryListResponse>(response);\n if (!body) {\n throw new Error(`Expected webhook deliveries for ${webhookId}`);\n }\n return body;\n}\n\nexport async function waitForDeliveryStatus(\n request: APIRequestContext,\n token: string,\n deliveryId: string,\n expectedStatus: string,\n): Promise<WebhookDeliveryDetailResponse> {\n for (let attempt = 0; attempt < 30; attempt += 1) {\n const detail = await getDeliveryDetail(request, token, deliveryId);\n if (detail.status === expectedStatus) {\n return detail;\n }\n await new Promise((resolve) => setTimeout(resolve, 200));\n }\n\n throw new Error(`Timed out waiting for delivery ${deliveryId} to reach status ${expectedStatus}`);\n}\n\nexport async function deleteWebhookIfExists(\n request: APIRequestContext,\n token: string | null,\n webhookId: string | null,\n): Promise<void> {\n if (!token || !webhookId) {\n return;\n }\n\n try {\n await apiRequest(request, 'DELETE', `/api/webhooks/${webhookId}`, { token });\n } catch {\n return;\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { expectId, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport { createUserFixture, deleteUserIfExists } from '@open-mercato/core/helpers/integration/authFixtures';\nimport { deleteUserAclInDb, setUserAclInDb } from '@open-mercato/core/helpers/integration/dbFixtures';\n\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\n\nexport type WebhookCreateResponse = {\n id: string;\n name: string;\n url: string;\n secret: string;\n subscribedEvents: string[];\n isActive: boolean;\n};\n\nexport type WebhookDetailResponse = {\n id: string;\n name: string;\n description: string | null;\n url: string;\n subscribedEvents: string[];\n httpMethod: 'POST' | 'PUT' | 'PATCH';\n isActive: boolean;\n deliveryStrategy: string;\n maxRetries: number;\n consecutiveFailures: number;\n lastSuccessAt: string | null;\n lastFailureAt: string | null;\n createdAt: string;\n updatedAt: string;\n customHeaders: Record<string, string> | null;\n strategyConfig: Record<string, unknown> | null;\n timeoutMs: number;\n rateLimitPerMinute: number;\n autoDisableThreshold: number;\n integrationId: string | null;\n maskedSecret: string;\n previousSecretSetAt: string | null;\n};\n\nexport type WebhookDeliveryDetailResponse = {\n id: string;\n webhookId: string;\n eventType: string;\n messageId: string;\n status: string;\n responseStatus: number | null;\n errorMessage: string | null;\n attemptNumber: number;\n maxAttempts: number;\n targetUrl: string;\n durationMs: number | null;\n enqueuedAt: string;\n lastAttemptAt: string | null;\n deliveredAt: string | null;\n createdAt: string;\n payload: Record<string, unknown>;\n responseBody: string | null;\n responseHeaders: Record<string, string> | null;\n nextRetryAt: string | null;\n updatedAt: string;\n};\n\ntype DeliveryListResponse = {\n items: Array<{\n id: string;\n webhookId: string;\n webhookName: string | null;\n eventType: string;\n messageId: string;\n status: string;\n responseStatus: number | null;\n errorMessage: string | null;\n attemptNumber: number;\n maxAttempts: number;\n targetUrl: string;\n durationMs: number | null;\n enqueuedAt: string;\n lastAttemptAt: string | null;\n deliveredAt: string | null;\n createdAt: string;\n }>;\n total: number;\n page: number;\n pageSize: number;\n totalPages: number;\n};\n\ntype EventsResponse = {\n data: Array<{\n id: string;\n label: string;\n module?: string;\n category?: string;\n description?: string;\n }>;\n total: number;\n};\n\nexport function buildMockInboundUrl(endpointId = 'mock_inbound'): string {\n return `${BASE_URL}/api/webhooks/inbound/${endpointId}`;\n}\n\nexport async function createWebhookFixture(\n request: APIRequestContext,\n token: string,\n overrides: Partial<{\n name: string;\n description: string | null;\n url: string;\n subscribedEvents: string[];\n httpMethod: 'POST' | 'PUT' | 'PATCH';\n maxRetries: number;\n timeoutMs: number;\n rateLimitPerMinute: number;\n autoDisableThreshold: number;\n customHeaders: Record<string, string> | null;\n }> = {},\n): Promise<WebhookCreateResponse> {\n const response = await apiRequest(request, 'POST', '/api/webhooks', {\n token,\n data: {\n name: overrides.name ?? `Webhook ${Date.now()}`,\n description: overrides.description ?? 'Integration test webhook',\n url: overrides.url ?? buildMockInboundUrl(),\n subscribedEvents: overrides.subscribedEvents ?? ['catalog.product.created'],\n httpMethod: overrides.httpMethod ?? 'POST',\n maxRetries: overrides.maxRetries ?? 3,\n timeoutMs: overrides.timeoutMs ?? 5_000,\n rateLimitPerMinute: overrides.rateLimitPerMinute ?? 0,\n autoDisableThreshold: overrides.autoDisableThreshold ?? 5,\n customHeaders: overrides.customHeaders ?? null,\n },\n });\n\n expect(response.status()).toBe(201);\n const body = await readJsonSafe<WebhookCreateResponse>(response);\n if (!body) {\n throw new Error('Expected webhook create response body');\n }\n\n expectId(body.id, 'Expected webhook id');\n expect(typeof body.secret).toBe('string');\n expect(body.secret.startsWith('whsec_')).toBe(true);\n\n return body;\n}\n\nexport async function getWebhookDetail(\n request: APIRequestContext,\n token: string,\n webhookId: string,\n path = `/api/webhooks/${webhookId}`,\n): Promise<WebhookDetailResponse> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<WebhookDetailResponse>(response);\n if (!body) {\n throw new Error(`Expected webhook detail response for ${webhookId}`);\n }\n return body;\n}\n\nexport async function listWebhooks(\n request: APIRequestContext,\n token: string,\n path = '/api/webhooks',\n): Promise<{ items: Array<{ id: string; name: string; url: string }>; total: number }> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<{ items: Array<{ id: string; name: string; url: string }>; total: number }>(response);\n if (!body) {\n throw new Error('Expected webhook list response');\n }\n return body;\n}\n\nexport async function listWebhookEvents(request: APIRequestContext, token: string): Promise<EventsResponse> {\n const response = await apiRequest(request, 'GET', '/api/webhooks/events', { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<EventsResponse>(response);\n if (!body) {\n throw new Error('Expected webhook events response');\n }\n return body;\n}\n\nexport async function getDeliveryDetail(\n request: APIRequestContext,\n token: string,\n deliveryId: string,\n path = `/api/webhooks/deliveries/${deliveryId}`,\n): Promise<WebhookDeliveryDetailResponse> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<WebhookDeliveryDetailResponse>(response);\n if (!body) {\n throw new Error(`Expected webhook delivery detail for ${deliveryId}`);\n }\n return body;\n}\n\nexport async function listWebhookDeliveries(\n request: APIRequestContext,\n token: string,\n webhookId: string,\n path = `/api/webhooks/deliveries?webhookId=${encodeURIComponent(webhookId)}`,\n): Promise<DeliveryListResponse> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<DeliveryListResponse>(response);\n if (!body) {\n throw new Error(`Expected webhook deliveries for ${webhookId}`);\n }\n return body;\n}\n\nexport async function waitForDeliveryStatus(\n request: APIRequestContext,\n token: string,\n deliveryId: string,\n expectedStatus: string,\n): Promise<WebhookDeliveryDetailResponse> {\n for (let attempt = 0; attempt < 30; attempt += 1) {\n const detail = await getDeliveryDetail(request, token, deliveryId);\n if (detail.status === expectedStatus) {\n return detail;\n }\n await new Promise((resolve) => setTimeout(resolve, 200));\n }\n\n throw new Error(`Timed out waiting for delivery ${deliveryId} to reach status ${expectedStatus}`);\n}\n\nexport async function deleteWebhookIfExists(\n request: APIRequestContext,\n token: string | null,\n webhookId: string | null,\n): Promise<void> {\n if (!token || !webhookId) {\n return;\n }\n\n try {\n await apiRequest(request, 'DELETE', `/api/webhooks/${webhookId}`, { token });\n } catch {\n return;\n }\n}\n\nexport type WebhookRotateSecretResponse = {\n success: true;\n secret: string;\n previousSecretSetAt: string | null;\n};\n\nexport async function rotateWebhookSecret(\n request: APIRequestContext,\n token: string,\n webhookId: string,\n path = `/api/webhooks/${webhookId}/rotate-secret`,\n): Promise<WebhookRotateSecretResponse> {\n const response = await apiRequest(request, 'POST', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<WebhookRotateSecretResponse>(response);\n if (!body) {\n throw new Error(`Expected rotate-secret response for ${webhookId}`);\n }\n return body;\n}\n\nexport type WebhookTestDeliveryResponse = {\n success: true;\n delivery: WebhookDeliveryDetailResponse;\n};\n\nexport async function sendTestDelivery(\n request: APIRequestContext,\n token: string,\n webhookId: string,\n input: { eventType?: string; payload?: Record<string, unknown> } = {},\n path = `/api/webhooks/${webhookId}/test`,\n): Promise<WebhookTestDeliveryResponse> {\n const response = await apiRequest(request, 'POST', path, { token, data: input });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<WebhookTestDeliveryResponse>(response);\n if (!body) {\n throw new Error(`Expected test delivery response for ${webhookId}`);\n }\n return body;\n}\n\nexport type ProvisionedWebhooksUser = {\n userId: string;\n email: string;\n password: string;\n token: string;\n};\n\n/**\n * Creates a self-contained, role-less user and grants it an exact webhooks feature\n * set (and optional organization-visibility list) through a per-user ACL row, then\n * logs it in. The DB ACL path mirrors the org-scope fixtures: granting features via\n * the ACL API requires a super-admin actor, while `setUserAclInDb` keeps the spec\n * self-contained and deterministic. Login happens AFTER the ACL is written so the\n * fresh session resolves the new grants.\n */\nexport async function provisionWebhooksUser(\n request: APIRequestContext,\n adminToken: string,\n input: {\n tenantId: string;\n organizationId: string;\n features: string[];\n organizations?: string[] | null;\n slug: string;\n },\n): Promise<ProvisionedWebhooksUser> {\n const unique = `${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;\n const email = `qa-webhooks-${input.slug}-${unique}@acme.com`;\n const password = 'Valid1!Webhooks';\n const userId = await createUserFixture(request, adminToken, {\n email,\n password,\n organizationId: input.organizationId,\n roles: [],\n name: `QA Webhooks ${input.slug}`,\n });\n await setUserAclInDb({\n userId,\n tenantId: input.tenantId,\n features: input.features,\n organizations: input.organizations ?? null,\n });\n const token = await getAuthToken(request, email, password);\n return { userId, email, password, token };\n}\n\nexport async function cleanupWebhooksUser(\n request: APIRequestContext,\n adminToken: string | null,\n user: ProvisionedWebhooksUser | null,\n): Promise<void> {\n if (!user) return;\n await deleteUserAclInDb(user.userId).catch(() => undefined);\n await deleteUserIfExists(request, adminToken, user.userId).catch(() => undefined);\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/webhooks",
3
- "version": "0.6.5-develop.4544.1.71c003c861",
3
+ "version": "0.6.5-develop.4559.1.839e136509",
4
4
  "description": "Webhooks module for Open Mercato — Standard Webhooks compliant outbound/inbound delivery",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -69,19 +69,19 @@
69
69
  }
70
70
  },
71
71
  "dependencies": {
72
- "@open-mercato/core": "0.6.5-develop.4544.1.71c003c861",
73
- "@open-mercato/queue": "0.6.5-develop.4544.1.71c003c861",
74
- "@open-mercato/ui": "0.6.5-develop.4544.1.71c003c861",
72
+ "@open-mercato/core": "0.6.5-develop.4559.1.839e136509",
73
+ "@open-mercato/queue": "0.6.5-develop.4559.1.839e136509",
74
+ "@open-mercato/ui": "0.6.5-develop.4559.1.839e136509",
75
75
  "svix": "^1.95.1"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "@mikro-orm/postgresql": "^7.0.14",
79
- "@open-mercato/shared": "0.6.5-develop.4544.1.71c003c861",
79
+ "@open-mercato/shared": "0.6.5-develop.4559.1.839e136509",
80
80
  "react": "^19.0.0",
81
81
  "react-dom": "^19.0.0"
82
82
  },
83
83
  "devDependencies": {
84
- "@open-mercato/shared": "0.6.5-develop.4544.1.71c003c861",
84
+ "@open-mercato/shared": "0.6.5-develop.4559.1.839e136509",
85
85
  "@types/jest": "^30.0.0",
86
86
  "@types/react": "^19.2.16",
87
87
  "@types/react-dom": "^19.2.3",
@@ -0,0 +1,63 @@
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';
9
+
10
+ /**
11
+ * TC-WEBHOOK-004: Secret rotation with previous secret retention
12
+ * Source: https://github.com/open-mercato/open-mercato/issues/2482
13
+ *
14
+ * Verifies the security-critical rotate-secret flow: a new whsec_ secret is issued,
15
+ * the previous secret is retained (so deliveries can dual-sign during rollout), and
16
+ * the detail endpoint reflects the masked new secret plus the rotation timestamp.
17
+ */
18
+ test.describe('TC-WEBHOOK-004: Secret rotation with previous secret retention', () => {
19
+ test('should rotate the signing secret, retain the previous one, and mask it on read', async ({ request }) => {
20
+ const token = await getAuthToken(request);
21
+ let webhookId: string | null = null;
22
+
23
+ try {
24
+ const created = await createWebhookFixture(request, token, {
25
+ name: `Webhook Rotate ${Date.now()}`,
26
+ url: 'https://example.com/webhook-rotate',
27
+ subscribedEvents: ['sales.quote.created'],
28
+ });
29
+ webhookId = created.id;
30
+ expect(created.secret.startsWith('whsec_')).toBe(true);
31
+
32
+ const beforeRotation = await getWebhookDetail(request, token, created.id);
33
+ expect(beforeRotation.previousSecretSetAt).toBeNull();
34
+
35
+ const rotation = await rotateWebhookSecret(request, token, created.id);
36
+ expect(rotation.success).toBe(true);
37
+ expect(rotation.secret.startsWith('whsec_')).toBe(true);
38
+ expect(rotation.secret).not.toBe(created.secret);
39
+ expect(typeof rotation.previousSecretSetAt).toBe('string');
40
+ expect(Number.isNaN(Date.parse(rotation.previousSecretSetAt as string))).toBe(false);
41
+
42
+ const afterRotation = await getWebhookDetail(request, token, created.id);
43
+ // The detail endpoint never exposes the raw secret — only a masked form that
44
+ // reveals the constant "whsec_" prefix (maskSecret => `${secret.slice(0, 6)}••••••••`).
45
+ expect(afterRotation.maskedSecret.startsWith('whsec_')).toBe(true);
46
+ expect(afterRotation.maskedSecret).not.toBe(rotation.secret);
47
+ expect(afterRotation.maskedSecret).not.toBe(created.secret);
48
+ expect(afterRotation.maskedSecret.length).toBeLessThan(rotation.secret.length);
49
+ // previousSecretSetAt proves the prior secret is retained for dual-sign verification
50
+ // (delivery signing reads webhook.previousSecret; covered at unit level in lib/__tests__).
51
+ expect(afterRotation.previousSecretSetAt).toBe(rotation.previousSecretSetAt);
52
+
53
+ const secondRotation = await rotateWebhookSecret(request, token, created.id);
54
+ expect(secondRotation.secret).not.toBe(rotation.secret);
55
+ expect(typeof secondRotation.previousSecretSetAt).toBe('string');
56
+ expect(Date.parse(secondRotation.previousSecretSetAt as string)).toBeGreaterThanOrEqual(
57
+ Date.parse(rotation.previousSecretSetAt as string),
58
+ );
59
+ } finally {
60
+ await deleteWebhookIfExists(request, token, webhookId);
61
+ }
62
+ });
63
+ });
@@ -0,0 +1,126 @@
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
+ type ProvisionedWebhooksUser,
10
+ } from './helpers/fixtures';
11
+
12
+ /**
13
+ * TC-WEBHOOK-005: RBAC permission gates — unauthorized access by feature flag
14
+ * Source: https://github.com/open-mercato/open-mercato/issues/2482
15
+ *
16
+ * Verifies every webhooks API surface is gated by its declared feature:
17
+ * webhooks.view → GET list + GET detail + deliveries
18
+ * webhooks.manage → POST + PUT + DELETE
19
+ * webhooks.secrets → POST rotate-secret
20
+ * webhooks.test → POST test
21
+ * A viewer holding only webhooks.view passes reads but is denied every write/secret/test
22
+ * surface; a user holding no webhooks features is denied reads. Admin (webhooks.*) proves
23
+ * the positive paths. Denials accept 401 or 403 per the issue's acceptance criteria.
24
+ */
25
+ test.describe('TC-WEBHOOK-005: RBAC permission gates by feature flag', () => {
26
+ test('should gate each webhooks surface behind its declared feature', async ({ request }) => {
27
+ const adminToken = await getAuthToken(request);
28
+ const scope = getTokenScope(adminToken);
29
+
30
+ let webhookId: string | null = null;
31
+ let viewer: ProvisionedWebhooksUser | null = null;
32
+ let noAccess: ProvisionedWebhooksUser | null = null;
33
+
34
+ try {
35
+ viewer = await provisionWebhooksUser(request, adminToken, {
36
+ tenantId: scope.tenantId,
37
+ organizationId: scope.organizationId,
38
+ features: ['webhooks.view'],
39
+ slug: 'viewer',
40
+ });
41
+ noAccess = await provisionWebhooksUser(request, adminToken, {
42
+ tenantId: scope.tenantId,
43
+ organizationId: scope.organizationId,
44
+ features: [],
45
+ slug: 'noaccess',
46
+ });
47
+
48
+ // --- Positive: admin (webhooks.*) can exercise every surface ---
49
+ const created = await createWebhookFixture(request, adminToken, {
50
+ name: `Webhook RBAC ${Date.now()}`,
51
+ url: 'https://example.com/webhook-rbac',
52
+ subscribedEvents: ['catalog.product.created'],
53
+ customHeaders: { 'x-mock-webhook-signature': 'valid' },
54
+ });
55
+ webhookId = created.id;
56
+
57
+ const adminList = await apiRequest(request, 'GET', '/api/webhooks', { token: adminToken });
58
+ expect(adminList.status()).toBe(200);
59
+ const adminDetail = await apiRequest(request, 'GET', `/api/webhooks/${created.id}`, { token: adminToken });
60
+ expect(adminDetail.status()).toBe(200);
61
+
62
+ // --- webhooks.view gate ---
63
+ const viewerList = await apiRequest(request, 'GET', '/api/webhooks', { token: viewer.token });
64
+ expect(viewerList.status()).toBe(200);
65
+ const viewerDetail = await apiRequest(request, 'GET', `/api/webhooks/${created.id}`, { token: viewer.token });
66
+ expect(viewerDetail.status()).toBe(200);
67
+
68
+ const noAccessList = await apiRequest(request, 'GET', '/api/webhooks', { token: noAccess.token });
69
+ expect([401, 403]).toContain(noAccessList.status());
70
+ const noAccessDetail = await apiRequest(request, 'GET', `/api/webhooks/${created.id}`, { token: noAccess.token });
71
+ expect([401, 403]).toContain(noAccessDetail.status());
72
+
73
+ // --- webhooks.manage gate (viewer lacks manage) ---
74
+ const viewerCreate = await apiRequest(request, 'POST', '/api/webhooks', {
75
+ token: viewer.token,
76
+ data: {
77
+ name: `Webhook RBAC Denied ${Date.now()}`,
78
+ url: 'https://example.com/webhook-denied',
79
+ subscribedEvents: ['catalog.product.created'],
80
+ },
81
+ });
82
+ expect([401, 403]).toContain(viewerCreate.status());
83
+
84
+ const viewerUpdate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
85
+ token: viewer.token,
86
+ data: { name: `Webhook RBAC Hijack ${Date.now()}` },
87
+ });
88
+ expect([401, 403]).toContain(viewerUpdate.status());
89
+
90
+ const viewerDelete = await apiRequest(request, 'DELETE', `/api/webhooks/${created.id}`, { token: viewer.token });
91
+ expect([401, 403]).toContain(viewerDelete.status());
92
+
93
+ // --- webhooks.secrets gate (viewer lacks secrets) ---
94
+ const viewerRotate = await apiRequest(request, 'POST', `/api/webhooks/${created.id}/rotate-secret`, {
95
+ token: viewer.token,
96
+ });
97
+ expect([401, 403]).toContain(viewerRotate.status());
98
+
99
+ // --- webhooks.test gate (viewer lacks test) ---
100
+ const viewerTest = await apiRequest(request, 'POST', `/api/webhooks/${created.id}/test`, {
101
+ token: viewer.token,
102
+ data: { eventType: 'catalog.product.created' },
103
+ });
104
+ expect([401, 403]).toContain(viewerTest.status());
105
+
106
+ // The webhook must still exist — none of the denied writes mutated it.
107
+ const stillThere = await apiRequest(request, 'GET', `/api/webhooks/${created.id}`, { token: adminToken });
108
+ expect(stillThere.status()).toBe(200);
109
+
110
+ // --- Positive secrets + test paths (admin holds both features) ---
111
+ const adminRotate = await apiRequest(request, 'POST', `/api/webhooks/${created.id}/rotate-secret`, {
112
+ token: adminToken,
113
+ });
114
+ expect(adminRotate.status()).toBe(200);
115
+ const adminTest = await apiRequest(request, 'POST', `/api/webhooks/${created.id}/test`, {
116
+ token: adminToken,
117
+ data: { eventType: 'catalog.product.created' },
118
+ });
119
+ expect(adminTest.status()).toBe(200);
120
+ } finally {
121
+ await deleteWebhookIfExists(request, adminToken, webhookId);
122
+ await cleanupWebhooksUser(request, adminToken, viewer);
123
+ await cleanupWebhooksUser(request, adminToken, noAccess);
124
+ }
125
+ });
126
+ });
@@ -0,0 +1,82 @@
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';
10
+
11
+ /**
12
+ * TC-WEBHOOK-006: Webhook deactivation blocks delivery
13
+ * Source: https://github.com/open-mercato/open-mercato/issues/2482
14
+ *
15
+ * The isActive flag must prevent delivery. The synchronous test route still records a
16
+ * delivery attempt, but the delivery engine short-circuits inactive webhooks: the
17
+ * delivery is marked `expired` with "Webhook is inactive" and no HTTP request is made
18
+ * (responseStatus stays null). Re-activating restores successful delivery.
19
+ */
20
+ test.describe('TC-WEBHOOK-006: Webhook deactivation blocks delivery', () => {
21
+ test('should deliver while active, block while inactive, and resume after re-activation', async ({ request }) => {
22
+ const token = await getAuthToken(request);
23
+ let webhookId: string | null = null;
24
+
25
+ try {
26
+ const created = await createWebhookFixture(request, token, {
27
+ name: `Webhook Deactivation ${Date.now()}`,
28
+ url: buildMockInboundUrl(),
29
+ subscribedEvents: ['catalog.product.created'],
30
+ customHeaders: { 'x-mock-webhook-signature': 'valid' },
31
+ });
32
+ webhookId = created.id;
33
+
34
+ // 1) Active webhook delivers successfully.
35
+ const activeDelivery = await sendTestDelivery(request, token, created.id, {
36
+ eventType: 'catalog.product.created',
37
+ });
38
+ expect(activeDelivery.delivery.status).toBe('delivered');
39
+ expect(activeDelivery.delivery.responseStatus).toBe(200);
40
+
41
+ // 2) Deactivate.
42
+ const deactivate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
43
+ token,
44
+ data: { isActive: false },
45
+ });
46
+ expect(deactivate.status()).toBe(200);
47
+
48
+ // 3) Inactive webhook does not reach the endpoint — delivery is expired, no HTTP attempt.
49
+ const blockedDelivery = await sendTestDelivery(request, token, created.id, {
50
+ eventType: 'catalog.product.created',
51
+ });
52
+ expect(blockedDelivery.delivery.status).toBe('expired');
53
+ expect(blockedDelivery.delivery.errorMessage).toBe('Webhook is inactive');
54
+ expect(blockedDelivery.delivery.responseStatus).toBeNull();
55
+
56
+ // The blocked attempt is visible in the delivery log with a non-delivered status.
57
+ const expiredDeliveries = await listWebhookDeliveries(
58
+ request,
59
+ token,
60
+ created.id,
61
+ `/api/webhooks/deliveries?webhookId=${encodeURIComponent(created.id)}&status=expired`,
62
+ );
63
+ expect(expiredDeliveries.items.some((item) => item.id === blockedDelivery.delivery.id)).toBe(true);
64
+ expect(expiredDeliveries.items.every((item) => item.status === 'expired')).toBe(true);
65
+
66
+ // 4) Re-activate and confirm delivery resumes.
67
+ const reactivate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
68
+ token,
69
+ data: { isActive: true },
70
+ });
71
+ expect(reactivate.status()).toBe(200);
72
+
73
+ const resumedDelivery = await sendTestDelivery(request, token, created.id, {
74
+ eventType: 'catalog.product.created',
75
+ });
76
+ expect(resumedDelivery.delivery.status).toBe('delivered');
77
+ expect(resumedDelivery.delivery.responseStatus).toBe(200);
78
+ } finally {
79
+ await deleteWebhookIfExists(request, token, webhookId);
80
+ }
81
+ });
82
+ });
@@ -0,0 +1,102 @@
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
+ type ProvisionedWebhooksUser,
12
+ type WebhookCreateResponse,
13
+ } from './helpers/fixtures';
14
+
15
+ /**
16
+ * TC-WEBHOOK-007: Tenant/organization scoping isolation
17
+ * Source: https://github.com/open-mercato/open-mercato/issues/2482
18
+ *
19
+ * Webhooks are scoped by tenantId + organizationId. Within one tenant, two
20
+ * organization-restricted users must not see or mutate each other's webhooks:
21
+ * the list excludes out-of-scope rows and detail/update/delete on a foreign
22
+ * webhook returns 404 (existence is not revealed across the org boundary).
23
+ */
24
+ const MANAGE_FEATURES = ['webhooks.view', 'webhooks.manage'];
25
+
26
+ test.describe('TC-WEBHOOK-007: Tenant/organization scoping isolation', () => {
27
+ test('should isolate webhooks between organizations within the same tenant', async ({ request }) => {
28
+ const adminToken = await getAuthToken(request);
29
+ const { tenantId, organizationId: orgA } = getTokenScope(adminToken);
30
+
31
+ let orgB: string | null = null;
32
+ let userA: ProvisionedWebhooksUser | null = null;
33
+ let userB: ProvisionedWebhooksUser | null = null;
34
+ let webhookA: WebhookCreateResponse | null = null;
35
+ let webhookB: WebhookCreateResponse | null = null;
36
+
37
+ try {
38
+ orgB = await createOrganizationInDb({ name: `TC-WEBHOOK-007 Org B ${Date.now()}`, tenantId });
39
+
40
+ userA = await provisionWebhooksUser(request, adminToken, {
41
+ tenantId,
42
+ organizationId: orgA,
43
+ features: MANAGE_FEATURES,
44
+ organizations: [orgA],
45
+ slug: 'org-a',
46
+ });
47
+ userB = await provisionWebhooksUser(request, adminToken, {
48
+ tenantId,
49
+ organizationId: orgB,
50
+ features: MANAGE_FEATURES,
51
+ organizations: [orgB],
52
+ slug: 'org-b',
53
+ });
54
+
55
+ webhookA = await createWebhookFixture(request, userA.token, {
56
+ name: `TC-WEBHOOK-007 A ${Date.now()}`,
57
+ url: 'https://example.com/webhook-org-a',
58
+ subscribedEvents: ['catalog.product.created'],
59
+ });
60
+ webhookB = await createWebhookFixture(request, userB.token, {
61
+ name: `TC-WEBHOOK-007 B ${Date.now()}`,
62
+ url: 'https://example.com/webhook-org-b',
63
+ subscribedEvents: ['catalog.product.created'],
64
+ });
65
+
66
+ // Lists are scoped: each user sees only their own organization's webhook.
67
+ const listA = await listWebhooks(request, userA.token);
68
+ expect(listA.items.some((item) => item.id === webhookA!.id)).toBe(true);
69
+ expect(listA.items.some((item) => item.id === webhookB!.id)).toBe(false);
70
+
71
+ const listB = await listWebhooks(request, userB.token);
72
+ expect(listB.items.some((item) => item.id === webhookB!.id)).toBe(true);
73
+ expect(listB.items.some((item) => item.id === webhookA!.id)).toBe(false);
74
+
75
+ // Cross-org reads/writes are not found.
76
+ const crossGet = await apiRequest(request, 'GET', `/api/webhooks/${webhookB.id}`, { token: userA.token });
77
+ expect(crossGet.status()).toBe(404);
78
+
79
+ const crossUpdate = await apiRequest(request, 'PUT', `/api/webhooks/${webhookB.id}`, {
80
+ token: userA.token,
81
+ data: { name: `TC-WEBHOOK-007 cross-org hijack ${Date.now()}` },
82
+ });
83
+ expect(crossUpdate.status()).toBe(404);
84
+
85
+ const crossDelete = await apiRequest(request, 'DELETE', `/api/webhooks/${webhookB.id}`, { token: userA.token });
86
+ expect(crossDelete.status()).toBe(404);
87
+
88
+ const crossGetReverse = await apiRequest(request, 'GET', `/api/webhooks/${webhookA.id}`, { token: userB.token });
89
+ expect(crossGetReverse.status()).toBe(404);
90
+
91
+ // In-scope access still works after the cross-org attempts.
92
+ const ownGet = await apiRequest(request, 'GET', `/api/webhooks/${webhookA.id}`, { token: userA.token });
93
+ expect(ownGet.status()).toBe(200);
94
+ } finally {
95
+ await deleteWebhookIfExists(request, userA?.token ?? null, webhookA?.id ?? null);
96
+ await deleteWebhookIfExists(request, userB?.token ?? null, webhookB?.id ?? null);
97
+ await cleanupWebhooksUser(request, adminToken, userA);
98
+ await cleanupWebhooksUser(request, adminToken, userB);
99
+ await deleteOrganizationInDb(orgB);
100
+ }
101
+ });
102
+ });