@open-mercato/webhooks 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4

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 (50) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.js +238 -0
  3. package/dist/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.js.map +7 -0
  4. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js +46 -0
  5. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js.map +7 -0
  6. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js +92 -0
  7. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js.map +7 -0
  8. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js +61 -0
  9. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js.map +7 -0
  10. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js +76 -0
  11. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js.map +7 -0
  12. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js +81 -0
  13. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js.map +7 -0
  14. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js +68 -0
  15. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js.map +7 -0
  16. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js +64 -0
  17. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js.map +7 -0
  18. package/dist/modules/webhooks/__integration__/TC-WH-CRUDFORM-001.spec.js +95 -0
  19. package/dist/modules/webhooks/__integration__/TC-WH-CRUDFORM-001.spec.js.map +7 -0
  20. package/dist/modules/webhooks/__integration__/helpers/fixtures.js +50 -1
  21. package/dist/modules/webhooks/__integration__/helpers/fixtures.js.map +2 -2
  22. package/dist/modules/webhooks/api/webhooks/[id]/route.js +24 -0
  23. package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
  24. package/dist/modules/webhooks/backend/webhooks/[id]/page.js +9 -2
  25. package/dist/modules/webhooks/backend/webhooks/[id]/page.js.map +3 -3
  26. package/dist/modules/webhooks/backend/webhooks/page.js +13 -1
  27. package/dist/modules/webhooks/backend/webhooks/page.js.map +2 -2
  28. package/dist/modules/webhooks/subscribers/outbound-dispatch.js +1 -1
  29. package/dist/modules/webhooks/subscribers/outbound-dispatch.js.map +2 -2
  30. package/package.json +8 -9
  31. package/src/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.ts +352 -0
  32. package/src/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.ts +63 -0
  33. package/src/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.ts +126 -0
  34. package/src/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.ts +82 -0
  35. package/src/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.ts +102 -0
  36. package/src/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.ts +109 -0
  37. package/src/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.ts +94 -0
  38. package/src/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.ts +89 -0
  39. package/src/modules/webhooks/__integration__/TC-WH-CRUDFORM-001.spec.ts +126 -0
  40. package/src/modules/webhooks/__integration__/helpers/fixtures.ts +101 -1
  41. package/src/modules/webhooks/api/webhooks/[id]/__tests__/optimistic-lock.test.ts +101 -0
  42. package/src/modules/webhooks/api/webhooks/[id]/route.ts +26 -0
  43. package/src/modules/webhooks/backend/webhooks/[id]/page.tsx +9 -2
  44. package/src/modules/webhooks/backend/webhooks/page.tsx +12 -1
  45. package/src/modules/webhooks/i18n/de.json +1 -0
  46. package/src/modules/webhooks/i18n/en.json +1 -0
  47. package/src/modules/webhooks/i18n/es.json +1 -0
  48. package/src/modules/webhooks/i18n/pl.json +1 -0
  49. package/src/modules/webhooks/subscribers/__tests__/outbound-dispatch.test.ts +31 -0
  50. package/src/modules/webhooks/subscribers/outbound-dispatch.ts +1 -1
@@ -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,68 @@
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 { readIntegrationEnvFlag } from "@open-mercato/core/helpers/integration/standaloneEnv";
5
+ import { createWebhookFixture, deleteWebhookIfExists } from "./helpers/fixtures.js";
6
+ const PRIVATE_URLS_ALLOWED = readIntegrationEnvFlag("OM_WEBHOOKS_ALLOW_PRIVATE_URLS");
7
+ const ALWAYS_UNSAFE_URLS = [
8
+ "ftp://example.com/webhook",
9
+ "file:///etc/passwd",
10
+ "http://user:pass@example.com/webhook",
11
+ "not-a-valid-url"
12
+ ];
13
+ const PRIVATE_HOST_URLS = [
14
+ "http://127.0.0.1:9001/webhook",
15
+ "http://10.0.0.1/webhook",
16
+ "http://[::1]:9001/webhook"
17
+ ];
18
+ function createBody(url) {
19
+ return {
20
+ name: `Webhook URL Safety ${Date.now()}`,
21
+ url,
22
+ subscribedEvents: ["catalog.product.created"]
23
+ };
24
+ }
25
+ test.describe("TC-WEBHOOK-009: Invalid and unsafe URL rejection", () => {
26
+ test("should reject unsafe webhook URLs on create and update and accept valid external URLs", async ({ request }) => {
27
+ const token = await getAuthToken(request);
28
+ let webhookId = null;
29
+ const strayPrivateWebhookIds = [];
30
+ try {
31
+ for (const url of ALWAYS_UNSAFE_URLS) {
32
+ const response = await apiRequest(request, "POST", "/api/webhooks", { token, data: createBody(url) });
33
+ expect(response.status(), `POST should reject unsafe url ${url}`).toBe(400);
34
+ }
35
+ const created = await createWebhookFixture(request, token, {
36
+ name: `Webhook URL Safety Valid ${Date.now()}`,
37
+ url: "https://example.com/webhook-url-safety",
38
+ subscribedEvents: ["catalog.product.created"]
39
+ });
40
+ webhookId = created.id;
41
+ for (const url of ALWAYS_UNSAFE_URLS) {
42
+ const response = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, { token, data: { url } });
43
+ expect(response.status(), `PUT should reject unsafe url ${url}`).toBe(400);
44
+ }
45
+ const validUpdate = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
46
+ token,
47
+ data: { url: "https://example.org/webhook-url-safety-updated" }
48
+ });
49
+ expect(validUpdate.status()).toBe(200);
50
+ for (const url of PRIVATE_HOST_URLS) {
51
+ const response = await apiRequest(request, "POST", "/api/webhooks", { token, data: createBody(url) });
52
+ if (PRIVATE_URLS_ALLOWED) {
53
+ expect(response.status(), `POST should accept private url ${url} when allowed`).toBe(201);
54
+ const body = await readJsonSafe(response);
55
+ if (body?.id) strayPrivateWebhookIds.push(body.id);
56
+ } else {
57
+ expect(response.status(), `POST should reject private url ${url} when not allowed`).toBe(400);
58
+ }
59
+ }
60
+ } finally {
61
+ await deleteWebhookIfExists(request, token, webhookId);
62
+ for (const id of strayPrivateWebhookIds) {
63
+ await deleteWebhookIfExists(request, token, id);
64
+ }
65
+ }
66
+ });
67
+ });
68
+ //# 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 { readIntegrationEnvFlag } from '@open-mercato/core/helpers/integration/standaloneEnv';\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 = readIntegrationEnvFlag('OM_WEBHOOKS_ALLOW_PRIVATE_URLS');\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,8BAA8B;AACvC,SAAS,sBAAsB,6BAA6B;AAc5D,MAAM,uBAAuB,uBAAuB,gCAAgC;AAEpF,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
+ }
@@ -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
+ }
@@ -0,0 +1,95 @@
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 {
5
+ runCrudFormRoundTrip,
6
+ skipIfCrudFormExtensionTestsDisabled
7
+ } from "@open-mercato/core/helpers/integration/crudFormPersistence";
8
+ const WEBHOOKS_PATH = "/api/webhooks";
9
+ async function readWebhookById(request, token, id) {
10
+ const response = await apiRequest(request, "GET", `${WEBHOOKS_PATH}/${encodeURIComponent(id)}`, { token });
11
+ if (response.status() === 404) return null;
12
+ expect(response.status(), `read-back webhook failed: ${response.status()}`).toBe(200);
13
+ const body = await readJsonSafe(response);
14
+ return body && body.id === id ? body : null;
15
+ }
16
+ test.describe("TC-WH-CRUDFORM-001: Webhook CrudForm persists scalars, events multiselect + headers JSON", () => {
17
+ test.beforeAll(() => {
18
+ skipIfCrudFormExtensionTestsDisabled();
19
+ });
20
+ test("round-trips scalars, subscribedEvents array, and customHeaders JSON on create and update", async ({ request }) => {
21
+ const token = await getAuthToken(request, "admin");
22
+ const stamp = Date.now();
23
+ const createEvents = ["catalog.product.created", "catalog.product.updated"];
24
+ const updateEvents = ["sales.quote.created", "catalog.product.deleted", "sales.order.placed"];
25
+ const createHeaders = { "X-Source": "om-qa", "X-Trace": `crudform-${stamp}` };
26
+ const updateHeaders = { "X-Env": "integration", "X-Rotated": "true" };
27
+ await runCrudFormRoundTrip({
28
+ request,
29
+ token,
30
+ collectionPath: WEBHOOKS_PATH,
31
+ recordPath: (id) => `${WEBHOOKS_PATH}/${encodeURIComponent(id)}`,
32
+ readById: (id) => readWebhookById(request, token, id),
33
+ create: {
34
+ payload: {
35
+ name: `QA CRUDFORM Webhook ${stamp}`,
36
+ description: "Original webhook description",
37
+ url: `https://example.com/hooks/crudform-${stamp}`,
38
+ subscribedEvents: createEvents,
39
+ httpMethod: "POST",
40
+ maxRetries: 3,
41
+ timeoutMs: 5e3,
42
+ rateLimitPerMinute: 0,
43
+ autoDisableThreshold: 5,
44
+ customHeaders: createHeaders
45
+ }
46
+ },
47
+ expectAfterCreate: {
48
+ scalars: {
49
+ name: `QA CRUDFORM Webhook ${stamp}`,
50
+ description: "Original webhook description",
51
+ url: `https://example.com/hooks/crudform-${stamp}`,
52
+ subscribedEvents: createEvents,
53
+ httpMethod: "POST",
54
+ isActive: true,
55
+ maxRetries: 3,
56
+ timeoutMs: 5e3,
57
+ rateLimitPerMinute: 0,
58
+ autoDisableThreshold: 5,
59
+ customHeaders: createHeaders
60
+ }
61
+ },
62
+ update: {
63
+ payload: () => ({
64
+ name: `QA CRUDFORM Webhook ${stamp} EDITED`,
65
+ description: "Updated webhook description",
66
+ url: `https://example.com/hooks/crudform-${stamp}-edited`,
67
+ subscribedEvents: updateEvents,
68
+ httpMethod: "PUT",
69
+ isActive: false,
70
+ maxRetries: 7,
71
+ timeoutMs: 2e4,
72
+ rateLimitPerMinute: 120,
73
+ autoDisableThreshold: 25,
74
+ customHeaders: updateHeaders
75
+ })
76
+ },
77
+ expectAfterUpdate: {
78
+ scalars: {
79
+ name: `QA CRUDFORM Webhook ${stamp} EDITED`,
80
+ description: "Updated webhook description",
81
+ url: `https://example.com/hooks/crudform-${stamp}-edited`,
82
+ subscribedEvents: updateEvents,
83
+ httpMethod: "PUT",
84
+ isActive: false,
85
+ maxRetries: 7,
86
+ timeoutMs: 2e4,
87
+ rateLimitPerMinute: 120,
88
+ autoDisableThreshold: 25,
89
+ customHeaders: updateHeaders
90
+ }
91
+ }
92
+ });
93
+ });
94
+ });
95
+ //# sourceMappingURL=TC-WH-CRUDFORM-001.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/webhooks/__integration__/TC-WH-CRUDFORM-001.spec.ts"],
4
+ "sourcesContent": ["import { expect, test, type APIRequestContext } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport {\n runCrudFormRoundTrip,\n skipIfCrudFormExtensionTestsDisabled,\n type CrudRecord,\n} from '@open-mercato/core/helpers/integration/crudFormPersistence';\n\n/**\n * TC-WH-CRUDFORM-001: Webhook CrudForm persists scalars, the events multiselect, and the\n * custom-headers JSON object (#2466, Tier B \u2014 hand-written / non-makeCrud saves).\n *\n * Webhooks is the first hand-written surface in the sweep. The backend CrudForm\n * (`backend/webhooks`) submits through the canonical, hand-written routes \u2014 `POST /api/webhooks`,\n * `PUT /api/webhooks/:id`, `DELETE /api/webhooks/:id` \u2014 NOT the makeCrud `/api/webhooks/webhooks`\n * alias, so this spec drives those exact endpoints via the harness `recordPath` seam to prove the\n * real form-submit path persists every field.\n *\n * Verified contract:\n * - Responses are camelCase (hand-written serializer), so scalar assertions use camelCase keys.\n * - `subscribedEvents` is a multiselect string[] (the form `tags` field); `customHeaders` is a JSON\n * object (the form `JsonBuilder`). Both round-trip through deep equality.\n * - `PUT /api/webhooks/:id` is a partial patch; the spec sends the full field set on update.\n * - Read-back uses the detail GET `/api/webhooks/:id` \u2014 the list GET omits `customHeaders` and has no\n * `id` filter. Cleanup soft-deletes via `DELETE /api/webhooks/:id` in the harness `finally`.\n * - The webhook entity has no custom fields, so the rich coverage here is the multiselect + JSON.\n * - Self-contained: a single webhook fixture, created and deleted within the round-trip.\n *\n * Gated by `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED` (default off \u2192 runs).\n */\nconst WEBHOOKS_PATH = '/api/webhooks';\n\nasync function readWebhookById(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<CrudRecord | null> {\n const response = await apiRequest(request, 'GET', `${WEBHOOKS_PATH}/${encodeURIComponent(id)}`, { token });\n if (response.status() === 404) return null;\n expect(response.status(), `read-back webhook failed: ${response.status()}`).toBe(200);\n const body = await readJsonSafe<CrudRecord>(response);\n return body && body.id === id ? body : null;\n}\n\ntest.describe('TC-WH-CRUDFORM-001: Webhook CrudForm persists scalars, events multiselect + headers JSON', () => {\n test.beforeAll(() => {\n skipIfCrudFormExtensionTestsDisabled();\n });\n\n test('round-trips scalars, subscribedEvents array, and customHeaders JSON on create and update', async ({ request }) => {\n const token = await getAuthToken(request, 'admin');\n const stamp = Date.now();\n const createEvents = ['catalog.product.created', 'catalog.product.updated'];\n const updateEvents = ['sales.quote.created', 'catalog.product.deleted', 'sales.order.placed'];\n const createHeaders = { 'X-Source': 'om-qa', 'X-Trace': `crudform-${stamp}` };\n const updateHeaders = { 'X-Env': 'integration', 'X-Rotated': 'true' };\n\n await runCrudFormRoundTrip({\n request,\n token,\n collectionPath: WEBHOOKS_PATH,\n recordPath: (id) => `${WEBHOOKS_PATH}/${encodeURIComponent(id)}`,\n readById: (id) => readWebhookById(request, token, id),\n create: {\n payload: {\n name: `QA CRUDFORM Webhook ${stamp}`,\n description: 'Original webhook description',\n url: `https://example.com/hooks/crudform-${stamp}`,\n subscribedEvents: createEvents,\n httpMethod: 'POST',\n maxRetries: 3,\n timeoutMs: 5000,\n rateLimitPerMinute: 0,\n autoDisableThreshold: 5,\n customHeaders: createHeaders,\n },\n },\n expectAfterCreate: {\n scalars: {\n name: `QA CRUDFORM Webhook ${stamp}`,\n description: 'Original webhook description',\n url: `https://example.com/hooks/crudform-${stamp}`,\n subscribedEvents: createEvents,\n httpMethod: 'POST',\n isActive: true,\n maxRetries: 3,\n timeoutMs: 5000,\n rateLimitPerMinute: 0,\n autoDisableThreshold: 5,\n customHeaders: createHeaders,\n },\n },\n update: {\n payload: () => ({\n name: `QA CRUDFORM Webhook ${stamp} EDITED`,\n description: 'Updated webhook description',\n url: `https://example.com/hooks/crudform-${stamp}-edited`,\n subscribedEvents: updateEvents,\n httpMethod: 'PUT',\n isActive: false,\n maxRetries: 7,\n timeoutMs: 20000,\n rateLimitPerMinute: 120,\n autoDisableThreshold: 25,\n customHeaders: updateHeaders,\n }),\n },\n expectAfterUpdate: {\n scalars: {\n name: `QA CRUDFORM Webhook ${stamp} EDITED`,\n description: 'Updated webhook description',\n url: `https://example.com/hooks/crudform-${stamp}-edited`,\n subscribedEvents: updateEvents,\n httpMethod: 'PUT',\n isActive: false,\n maxRetries: 7,\n timeoutMs: 20000,\n rateLimitPerMinute: 120,\n autoDisableThreshold: 25,\n customHeaders: updateHeaders,\n },\n },\n });\n });\n});\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAoC;AACrD,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AAwBP,MAAM,gBAAgB;AAEtB,eAAe,gBACb,SACA,OACA,IAC4B;AAC5B,QAAM,WAAW,MAAM,WAAW,SAAS,OAAO,GAAG,aAAa,IAAI,mBAAmB,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC;AACzG,MAAI,SAAS,OAAO,MAAM,IAAK,QAAO;AACtC,SAAO,SAAS,OAAO,GAAG,6BAA6B,SAAS,OAAO,CAAC,EAAE,EAAE,KAAK,GAAG;AACpF,QAAM,OAAO,MAAM,aAAyB,QAAQ;AACpD,SAAO,QAAQ,KAAK,OAAO,KAAK,OAAO;AACzC;AAEA,KAAK,SAAS,4FAA4F,MAAM;AAC9G,OAAK,UAAU,MAAM;AACnB,yCAAqC;AAAA,EACvC,CAAC;AAED,OAAK,4FAA4F,OAAO,EAAE,QAAQ,MAAM;AACtH,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,UAAM,QAAQ,KAAK,IAAI;AACvB,UAAM,eAAe,CAAC,2BAA2B,yBAAyB;AAC1E,UAAM,eAAe,CAAC,uBAAuB,2BAA2B,oBAAoB;AAC5F,UAAM,gBAAgB,EAAE,YAAY,SAAS,WAAW,YAAY,KAAK,GAAG;AAC5E,UAAM,gBAAgB,EAAE,SAAS,eAAe,aAAa,OAAO;AAEpE,UAAM,qBAAqB;AAAA,MACzB;AAAA,MACA;AAAA,MACA,gBAAgB;AAAA,MAChB,YAAY,CAAC,OAAO,GAAG,aAAa,IAAI,mBAAmB,EAAE,CAAC;AAAA,MAC9D,UAAU,CAAC,OAAO,gBAAgB,SAAS,OAAO,EAAE;AAAA,MACpD,QAAQ;AAAA,QACN,SAAS;AAAA,UACP,MAAM,uBAAuB,KAAK;AAAA,UAClC,aAAa;AAAA,UACb,KAAK,sCAAsC,KAAK;AAAA,UAChD,kBAAkB;AAAA,UAClB,YAAY;AAAA,UACZ,YAAY;AAAA,UACZ,WAAW;AAAA,UACX,oBAAoB;AAAA,UACpB,sBAAsB;AAAA,UACtB,eAAe;AAAA,QACjB;AAAA,MACF;AAAA,MACA,mBAAmB;AAAA,QACjB,SAAS;AAAA,UACP,MAAM,uBAAuB,KAAK;AAAA,UAClC,aAAa;AAAA,UACb,KAAK,sCAAsC,KAAK;AAAA,UAChD,kBAAkB;AAAA,UAClB,YAAY;AAAA,UACZ,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,WAAW;AAAA,UACX,oBAAoB;AAAA,UACpB,sBAAsB;AAAA,UACtB,eAAe;AAAA,QACjB;AAAA,MACF;AAAA,MACA,QAAQ;AAAA,QACN,SAAS,OAAO;AAAA,UACd,MAAM,uBAAuB,KAAK;AAAA,UAClC,aAAa;AAAA,UACb,KAAK,sCAAsC,KAAK;AAAA,UAChD,kBAAkB;AAAA,UAClB,YAAY;AAAA,UACZ,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,WAAW;AAAA,UACX,oBAAoB;AAAA,UACpB,sBAAsB;AAAA,UACtB,eAAe;AAAA,QACjB;AAAA,MACF;AAAA,MACA,mBAAmB;AAAA,QACjB,SAAS;AAAA,UACP,MAAM,uBAAuB,KAAK;AAAA,UAClC,aAAa;AAAA,UACb,KAAK,sCAAsC,KAAK;AAAA,UAChD,kBAAkB;AAAA,UAClB,YAAY;AAAA,UACZ,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,WAAW;AAAA,UACX,oBAAoB;AAAA,UACpB,sBAAsB;AAAA,UACtB,eAAe;AAAA,QACjB;AAAA,MACF;AAAA,IACF,CAAC;AAAA,EACH,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
  }
@@ -3,6 +3,8 @@ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
3
3
  import { emitWebhooksEvent } from "../../../events.js";
4
4
  import { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from "../../helpers.js";
5
5
  import { webhookUpdateSchema } from "../../../data/validators.js";
6
+ import { enforceCommandOptimisticLock } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
7
+ import { isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
6
8
  const metadata = {
7
9
  GET: { requireAuth: true, requireFeatures: ["webhooks.view"] },
8
10
  PUT: { requireAuth: true, requireFeatures: ["webhooks.manage"] },
@@ -53,6 +55,17 @@ async function PUT(request, context) {
53
55
  if (!webhook) {
54
56
  return json({ error: "Webhook not found" }, { status: 404 });
55
57
  }
58
+ try {
59
+ enforceCommandOptimisticLock({
60
+ resourceKind: "webhooks.endpoint",
61
+ resourceId: webhook.id,
62
+ current: webhook.updatedAt ?? null,
63
+ request
64
+ });
65
+ } catch (err) {
66
+ if (isCrudHttpError(err)) return json(err.body, { status: err.status });
67
+ throw err;
68
+ }
56
69
  const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null));
57
70
  if (!parsed.success) {
58
71
  return json({ error: "Invalid request payload" }, { status: 400 });
@@ -90,6 +103,17 @@ async function DELETE(request, context) {
90
103
  if (!webhook) {
91
104
  return json({ error: translate("webhooks.errors.notFound", "Webhook not found") }, { status: 404 });
92
105
  }
106
+ try {
107
+ enforceCommandOptimisticLock({
108
+ resourceKind: "webhooks.endpoint",
109
+ resourceId: webhook.id,
110
+ current: webhook.updatedAt ?? null,
111
+ request
112
+ });
113
+ } catch (err) {
114
+ if (isCrudHttpError(err)) return json(err.body, { status: err.status });
115
+ throw err;
116
+ }
93
117
  webhook.deletedAt = /* @__PURE__ */ new Date();
94
118
  await em.flush();
95
119
  await emitWebhooksEvent("webhooks.webhook.deleted", {