@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,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
+ });
@@ -0,0 +1,109 @@
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-008: Delivery list filtering by status and event type
13
+ * Source: https://github.com/open-mercato/open-mercato/issues/2482
14
+ *
15
+ * GET /api/webhooks/deliveries supports webhookId, status, and eventType filters.
16
+ * Seeds three deliveries on one webhook — delivered(created), delivered(updated),
17
+ * expired(created) — then verifies each filter and their intersection.
18
+ */
19
+ const EVENT_CREATED = 'catalog.product.created';
20
+ const EVENT_UPDATED = 'catalog.product.updated';
21
+
22
+ function deliveriesPath(webhookId: string, extra: Record<string, string> = {}): string {
23
+ const params = new URLSearchParams({ webhookId, ...extra });
24
+ return `/api/webhooks/deliveries?${params.toString()}`;
25
+ }
26
+
27
+ test.describe('TC-WEBHOOK-008: Delivery list filtering by status and event type', () => {
28
+ test('should filter delivery logs by status, event type, and their combination', async ({ request }) => {
29
+ const token = await getAuthToken(request);
30
+ let webhookId: string | null = null;
31
+
32
+ try {
33
+ const created = await createWebhookFixture(request, token, {
34
+ name: `Webhook Filter ${Date.now()}`,
35
+ url: buildMockInboundUrl(),
36
+ subscribedEvents: [EVENT_CREATED, EVENT_UPDATED],
37
+ customHeaders: { 'x-mock-webhook-signature': 'valid' },
38
+ });
39
+ webhookId = created.id;
40
+
41
+ const deliveredCreated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_CREATED });
42
+ expect(deliveredCreated.delivery.status).toBe('delivered');
43
+ const deliveredUpdated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_UPDATED });
44
+ expect(deliveredUpdated.delivery.status).toBe('delivered');
45
+
46
+ // Flip the mock signature to invalid so the next attempt fails terminally (expired).
47
+ const invalidate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
48
+ token,
49
+ data: { customHeaders: { 'x-mock-webhook-signature': 'invalid' } },
50
+ });
51
+ expect(invalidate.status()).toBe(200);
52
+
53
+ const expiredCreated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_CREATED });
54
+ expect(expiredCreated.delivery.status).toBe('expired');
55
+
56
+ const d1 = deliveredCreated.delivery.id;
57
+ const d2 = deliveredUpdated.delivery.id;
58
+ const d3 = expiredCreated.delivery.id;
59
+
60
+ // Unfiltered (by webhook): all three present.
61
+ const all = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id));
62
+ expect(all.total).toBeGreaterThanOrEqual(3);
63
+ for (const id of [d1, d2, d3]) {
64
+ expect(all.items.some((item) => item.id === id)).toBe(true);
65
+ }
66
+
67
+ // status=delivered → d1, d2 only.
68
+ const delivered = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: 'delivered' }));
69
+ expect(delivered.items.every((item) => item.status === 'delivered')).toBe(true);
70
+ expect(delivered.items.some((item) => item.id === d1)).toBe(true);
71
+ expect(delivered.items.some((item) => item.id === d2)).toBe(true);
72
+ expect(delivered.items.some((item) => item.id === d3)).toBe(false);
73
+
74
+ // status=expired → d3 only.
75
+ const expired = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: 'expired' }));
76
+ expect(expired.items.every((item) => item.status === 'expired')).toBe(true);
77
+ expect(expired.items.some((item) => item.id === d3)).toBe(true);
78
+ expect(expired.items.some((item) => item.id === d1)).toBe(false);
79
+
80
+ // eventType=created → d1, d3 only.
81
+ const createdEvents = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { eventType: EVENT_CREATED }));
82
+ expect(createdEvents.items.every((item) => item.eventType === EVENT_CREATED)).toBe(true);
83
+ expect(createdEvents.items.some((item) => item.id === d1)).toBe(true);
84
+ expect(createdEvents.items.some((item) => item.id === d3)).toBe(true);
85
+ expect(createdEvents.items.some((item) => item.id === d2)).toBe(false);
86
+
87
+ // Combined status=delivered & eventType=created → d1 only.
88
+ const combined = await listWebhookDeliveries(
89
+ request,
90
+ token,
91
+ created.id,
92
+ deliveriesPath(created.id, { status: 'delivered', eventType: EVENT_CREATED }),
93
+ );
94
+ expect(combined.items.every((item) => item.status === 'delivered' && item.eventType === EVENT_CREATED)).toBe(true);
95
+ expect(combined.items.some((item) => item.id === d1)).toBe(true);
96
+ expect(combined.items.some((item) => item.id === d2)).toBe(false);
97
+ expect(combined.items.some((item) => item.id === d3)).toBe(false);
98
+
99
+ // Non-matching filter → empty result set with pagination metadata.
100
+ const empty = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: 'pending' }));
101
+ expect(empty.items).toEqual([]);
102
+ expect(empty.total).toBe(0);
103
+ expect(empty.page).toBe(1);
104
+ expect(empty.totalPages).toBe(0);
105
+ } finally {
106
+ await deleteWebhookIfExists(request, token, webhookId);
107
+ }
108
+ });
109
+ });
@@ -0,0 +1,94 @@
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';
6
+
7
+ /**
8
+ * TC-WEBHOOK-009: Invalid and unsafe URL rejection
9
+ * Source: https://github.com/open-mercato/open-mercato/issues/2482
10
+ *
11
+ * Webhook URLs go through the shared outbound-URL safety check. Forbidden schemes,
12
+ * embedded credentials, and malformed URLs are rejected with 400 on both create and
13
+ * update regardless of configuration. Private/loopback hosts are rejected unless the
14
+ * deployment opts in via OM_WEBHOOKS_ALLOW_PRIVATE_URLS (the integration env enables
15
+ * it so the loopback mock receiver works) — this spec asserts whichever behavior the
16
+ * active flag dictates. Exhaustive private-IP coverage lives in the unit suites
17
+ * (data/__tests__/validators.test.ts, lib/__tests__/url-safety.test.ts).
18
+ */
19
+ const PRIVATE_URLS_ALLOWED = readIntegrationEnvFlag('OM_WEBHOOKS_ALLOW_PRIVATE_URLS');
20
+
21
+ const ALWAYS_UNSAFE_URLS = [
22
+ 'ftp://example.com/webhook',
23
+ 'file:///etc/passwd',
24
+ 'http://user:pass@example.com/webhook',
25
+ 'not-a-valid-url',
26
+ ];
27
+
28
+ const PRIVATE_HOST_URLS = [
29
+ 'http://127.0.0.1:9001/webhook',
30
+ 'http://10.0.0.1/webhook',
31
+ 'http://[::1]:9001/webhook',
32
+ ];
33
+
34
+ function createBody(url: string) {
35
+ return {
36
+ name: `Webhook URL Safety ${Date.now()}`,
37
+ url,
38
+ subscribedEvents: ['catalog.product.created'],
39
+ };
40
+ }
41
+
42
+ test.describe('TC-WEBHOOK-009: Invalid and unsafe URL rejection', () => {
43
+ test('should reject unsafe webhook URLs on create and update and accept valid external URLs', async ({ request }) => {
44
+ const token = await getAuthToken(request);
45
+ let webhookId: string | null = null;
46
+ const strayPrivateWebhookIds: string[] = [];
47
+
48
+ try {
49
+ // Always-unsafe URLs are rejected at create regardless of the private-URL flag.
50
+ for (const url of ALWAYS_UNSAFE_URLS) {
51
+ const response = await apiRequest(request, 'POST', '/api/webhooks', { token, data: createBody(url) });
52
+ expect(response.status(), `POST should reject unsafe url ${url}`).toBe(400);
53
+ }
54
+
55
+ // A valid external https URL is accepted.
56
+ const created = await createWebhookFixture(request, token, {
57
+ name: `Webhook URL Safety Valid ${Date.now()}`,
58
+ url: 'https://example.com/webhook-url-safety',
59
+ subscribedEvents: ['catalog.product.created'],
60
+ });
61
+ webhookId = created.id;
62
+
63
+ // Updating an existing webhook to an unsafe URL is rejected; the record is unchanged.
64
+ for (const url of ALWAYS_UNSAFE_URLS) {
65
+ const response = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, { token, data: { url } });
66
+ expect(response.status(), `PUT should reject unsafe url ${url}`).toBe(400);
67
+ }
68
+
69
+ // Updating to another valid external URL succeeds.
70
+ const validUpdate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
71
+ token,
72
+ data: { url: 'https://example.org/webhook-url-safety-updated' },
73
+ });
74
+ expect(validUpdate.status()).toBe(200);
75
+
76
+ // Private/loopback hosts: behavior depends on the deployment flag.
77
+ for (const url of PRIVATE_HOST_URLS) {
78
+ const response = await apiRequest(request, 'POST', '/api/webhooks', { token, data: createBody(url) });
79
+ if (PRIVATE_URLS_ALLOWED) {
80
+ expect(response.status(), `POST should accept private url ${url} when allowed`).toBe(201);
81
+ const body = await readJsonSafe<{ id?: string }>(response);
82
+ if (body?.id) strayPrivateWebhookIds.push(body.id);
83
+ } else {
84
+ expect(response.status(), `POST should reject private url ${url} when not allowed`).toBe(400);
85
+ }
86
+ }
87
+ } finally {
88
+ await deleteWebhookIfExists(request, token, webhookId);
89
+ for (const id of strayPrivateWebhookIds) {
90
+ await deleteWebhookIfExists(request, token, id);
91
+ }
92
+ }
93
+ });
94
+ });
@@ -0,0 +1,89 @@
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';
10
+
11
+ /**
12
+ * TC-WEBHOOK-010: Delivery detail response includes full payload and response body
13
+ * Source: https://github.com/open-mercato/open-mercato/issues/2482
14
+ *
15
+ * The delivery detail endpoint must expose the complete sent payload and the full
16
+ * response captured from the endpoint (status, body, headers) plus attempt/timing
17
+ * metadata, so admins can debug a delivery end-to-end.
18
+ */
19
+ const EVENT_TYPE = 'webhooks.test.detail';
20
+
21
+ test.describe('TC-WEBHOOK-010: Delivery detail full payload and response body', () => {
22
+ test('should expose the complete payload and response details for a delivery', async ({ request }) => {
23
+ const token = await getAuthToken(request);
24
+ let webhookId: string | null = null;
25
+
26
+ try {
27
+ const created = await createWebhookFixture(request, token, {
28
+ name: `Webhook Detail ${Date.now()}`,
29
+ url: buildMockInboundUrl(),
30
+ subscribedEvents: ['catalog.product.created'],
31
+ customHeaders: { 'x-mock-webhook-signature': 'valid' },
32
+ });
33
+ webhookId = created.id;
34
+
35
+ const eventData = { sku: 'TEST-123', nested: { value: 42 } };
36
+
37
+ const testResult = await sendTestDelivery(request, token, created.id, {
38
+ eventType: EVENT_TYPE,
39
+ payload: eventData,
40
+ });
41
+ expect(testResult.delivery.status).toBe('delivered');
42
+ const deliveryId = testResult.delivery.id;
43
+
44
+ const detail = await getDeliveryDetail(request, token, deliveryId);
45
+
46
+ // Identity + routing
47
+ expect(detail.id).toBe(deliveryId);
48
+ expect(detail.webhookId).toBe(created.id);
49
+ expect(detail.eventType).toBe(EVENT_TYPE);
50
+ expect(detail.targetUrl).toBe(created.url);
51
+ expect(typeof detail.messageId).toBe('string');
52
+ expect(detail.messageId.length).toBeGreaterThan(0);
53
+
54
+ // Outcome
55
+ expect(detail.status).toBe('delivered');
56
+ expect(detail.responseStatus).toBe(200);
57
+ expect(detail.errorMessage).toBeNull();
58
+
59
+ // The delivery body wraps the event data in the Standard-Webhooks envelope
60
+ // { type, timestamp, data }; the sent payload is preserved under `data`.
61
+ expect(detail.payload).toMatchObject({
62
+ type: EVENT_TYPE,
63
+ data: { sku: 'TEST-123', nested: { value: 42 } },
64
+ });
65
+ const envelopeTimestamp = (detail.payload as { timestamp?: unknown }).timestamp;
66
+ expect(typeof envelopeTimestamp).toBe('string');
67
+ expect(Number.isNaN(Date.parse(envelopeTimestamp as string))).toBe(false);
68
+
69
+ // Full captured response (the mock receiver replies { ok: true } as JSON)
70
+ expect(detail.responseBody).toBe(JSON.stringify({ ok: true }));
71
+ expect(detail.responseHeaders).not.toBeNull();
72
+ expect(detail.responseHeaders?.['content-type']).toContain('application/json');
73
+
74
+ // Attempt + timing metadata
75
+ expect(detail.attemptNumber).toBeGreaterThanOrEqual(1);
76
+ expect(detail.maxAttempts).toBeGreaterThanOrEqual(1);
77
+ expect(typeof detail.durationMs).toBe('number');
78
+ expect(detail.durationMs as number).toBeGreaterThanOrEqual(0);
79
+ expect(detail.nextRetryAt).toBeNull();
80
+
81
+ for (const timestamp of [detail.createdAt, detail.enqueuedAt, detail.lastAttemptAt, detail.deliveredAt, detail.updatedAt]) {
82
+ expect(typeof timestamp).toBe('string');
83
+ expect(Number.isNaN(Date.parse(timestamp as string))).toBe(false);
84
+ }
85
+ } finally {
86
+ await deleteWebhookIfExists(request, token, webhookId);
87
+ }
88
+ });
89
+ });
@@ -0,0 +1,126 @@
1
+ import { expect, test, type APIRequestContext } 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
+ type CrudRecord,
8
+ } from '@open-mercato/core/helpers/integration/crudFormPersistence';
9
+
10
+ /**
11
+ * TC-WH-CRUDFORM-001: Webhook CrudForm persists scalars, the events multiselect, and the
12
+ * custom-headers JSON object (#2466, Tier B — hand-written / non-makeCrud saves).
13
+ *
14
+ * Webhooks is the first hand-written surface in the sweep. The backend CrudForm
15
+ * (`backend/webhooks`) submits through the canonical, hand-written routes — `POST /api/webhooks`,
16
+ * `PUT /api/webhooks/:id`, `DELETE /api/webhooks/:id` — NOT the makeCrud `/api/webhooks/webhooks`
17
+ * alias, so this spec drives those exact endpoints via the harness `recordPath` seam to prove the
18
+ * real form-submit path persists every field.
19
+ *
20
+ * Verified contract:
21
+ * - Responses are camelCase (hand-written serializer), so scalar assertions use camelCase keys.
22
+ * - `subscribedEvents` is a multiselect string[] (the form `tags` field); `customHeaders` is a JSON
23
+ * object (the form `JsonBuilder`). Both round-trip through deep equality.
24
+ * - `PUT /api/webhooks/:id` is a partial patch; the spec sends the full field set on update.
25
+ * - Read-back uses the detail GET `/api/webhooks/:id` — the list GET omits `customHeaders` and has no
26
+ * `id` filter. Cleanup soft-deletes via `DELETE /api/webhooks/:id` in the harness `finally`.
27
+ * - The webhook entity has no custom fields, so the rich coverage here is the multiselect + JSON.
28
+ * - Self-contained: a single webhook fixture, created and deleted within the round-trip.
29
+ *
30
+ * Gated by `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED` (default off → runs).
31
+ */
32
+ const WEBHOOKS_PATH = '/api/webhooks';
33
+
34
+ async function readWebhookById(
35
+ request: APIRequestContext,
36
+ token: string,
37
+ id: string,
38
+ ): Promise<CrudRecord | null> {
39
+ const response = await apiRequest(request, 'GET', `${WEBHOOKS_PATH}/${encodeURIComponent(id)}`, { token });
40
+ if (response.status() === 404) return null;
41
+ expect(response.status(), `read-back webhook failed: ${response.status()}`).toBe(200);
42
+ const body = await readJsonSafe<CrudRecord>(response);
43
+ return body && body.id === id ? body : null;
44
+ }
45
+
46
+ test.describe('TC-WH-CRUDFORM-001: Webhook CrudForm persists scalars, events multiselect + headers JSON', () => {
47
+ test.beforeAll(() => {
48
+ skipIfCrudFormExtensionTestsDisabled();
49
+ });
50
+
51
+ test('round-trips scalars, subscribedEvents array, and customHeaders JSON on create and update', async ({ request }) => {
52
+ const token = await getAuthToken(request, 'admin');
53
+ const stamp = Date.now();
54
+ const createEvents = ['catalog.product.created', 'catalog.product.updated'];
55
+ const updateEvents = ['sales.quote.created', 'catalog.product.deleted', 'sales.order.placed'];
56
+ const createHeaders = { 'X-Source': 'om-qa', 'X-Trace': `crudform-${stamp}` };
57
+ const updateHeaders = { 'X-Env': 'integration', 'X-Rotated': 'true' };
58
+
59
+ await runCrudFormRoundTrip({
60
+ request,
61
+ token,
62
+ collectionPath: WEBHOOKS_PATH,
63
+ recordPath: (id) => `${WEBHOOKS_PATH}/${encodeURIComponent(id)}`,
64
+ readById: (id) => readWebhookById(request, token, id),
65
+ create: {
66
+ payload: {
67
+ name: `QA CRUDFORM Webhook ${stamp}`,
68
+ description: 'Original webhook description',
69
+ url: `https://example.com/hooks/crudform-${stamp}`,
70
+ subscribedEvents: createEvents,
71
+ httpMethod: 'POST',
72
+ maxRetries: 3,
73
+ timeoutMs: 5000,
74
+ rateLimitPerMinute: 0,
75
+ autoDisableThreshold: 5,
76
+ customHeaders: createHeaders,
77
+ },
78
+ },
79
+ expectAfterCreate: {
80
+ scalars: {
81
+ name: `QA CRUDFORM Webhook ${stamp}`,
82
+ description: 'Original webhook description',
83
+ url: `https://example.com/hooks/crudform-${stamp}`,
84
+ subscribedEvents: createEvents,
85
+ httpMethod: 'POST',
86
+ isActive: true,
87
+ maxRetries: 3,
88
+ timeoutMs: 5000,
89
+ rateLimitPerMinute: 0,
90
+ autoDisableThreshold: 5,
91
+ customHeaders: createHeaders,
92
+ },
93
+ },
94
+ update: {
95
+ payload: () => ({
96
+ name: `QA CRUDFORM Webhook ${stamp} EDITED`,
97
+ description: 'Updated webhook description',
98
+ url: `https://example.com/hooks/crudform-${stamp}-edited`,
99
+ subscribedEvents: updateEvents,
100
+ httpMethod: 'PUT',
101
+ isActive: false,
102
+ maxRetries: 7,
103
+ timeoutMs: 20000,
104
+ rateLimitPerMinute: 120,
105
+ autoDisableThreshold: 25,
106
+ customHeaders: updateHeaders,
107
+ }),
108
+ },
109
+ expectAfterUpdate: {
110
+ scalars: {
111
+ name: `QA CRUDFORM Webhook ${stamp} EDITED`,
112
+ description: 'Updated webhook description',
113
+ url: `https://example.com/hooks/crudform-${stamp}-edited`,
114
+ subscribedEvents: updateEvents,
115
+ httpMethod: 'PUT',
116
+ isActive: false,
117
+ maxRetries: 7,
118
+ timeoutMs: 20000,
119
+ rateLimitPerMinute: 120,
120
+ autoDisableThreshold: 25,
121
+ customHeaders: updateHeaders,
122
+ },
123
+ },
124
+ });
125
+ });
126
+ });
@@ -1,6 +1,8 @@
1
1
  import { expect, type APIRequestContext } 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
 
5
7
  const BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';
6
8
 
@@ -247,3 +249,101 @@ export async function deleteWebhookIfExists(
247
249
  return;
248
250
  }
249
251
  }
252
+
253
+ export type WebhookRotateSecretResponse = {
254
+ success: true;
255
+ secret: string;
256
+ previousSecretSetAt: string | null;
257
+ };
258
+
259
+ export async function rotateWebhookSecret(
260
+ request: APIRequestContext,
261
+ token: string,
262
+ webhookId: string,
263
+ path = `/api/webhooks/${webhookId}/rotate-secret`,
264
+ ): Promise<WebhookRotateSecretResponse> {
265
+ const response = await apiRequest(request, 'POST', path, { token });
266
+ expect(response.status()).toBe(200);
267
+ const body = await readJsonSafe<WebhookRotateSecretResponse>(response);
268
+ if (!body) {
269
+ throw new Error(`Expected rotate-secret response for ${webhookId}`);
270
+ }
271
+ return body;
272
+ }
273
+
274
+ export type WebhookTestDeliveryResponse = {
275
+ success: true;
276
+ delivery: WebhookDeliveryDetailResponse;
277
+ };
278
+
279
+ export async function sendTestDelivery(
280
+ request: APIRequestContext,
281
+ token: string,
282
+ webhookId: string,
283
+ input: { eventType?: string; payload?: Record<string, unknown> } = {},
284
+ path = `/api/webhooks/${webhookId}/test`,
285
+ ): Promise<WebhookTestDeliveryResponse> {
286
+ const response = await apiRequest(request, 'POST', path, { token, data: input });
287
+ expect(response.status()).toBe(200);
288
+ const body = await readJsonSafe<WebhookTestDeliveryResponse>(response);
289
+ if (!body) {
290
+ throw new Error(`Expected test delivery response for ${webhookId}`);
291
+ }
292
+ return body;
293
+ }
294
+
295
+ export type ProvisionedWebhooksUser = {
296
+ userId: string;
297
+ email: string;
298
+ password: string;
299
+ token: string;
300
+ };
301
+
302
+ /**
303
+ * Creates a self-contained, role-less user and grants it an exact webhooks feature
304
+ * set (and optional organization-visibility list) through a per-user ACL row, then
305
+ * logs it in. The DB ACL path mirrors the org-scope fixtures: granting features via
306
+ * the ACL API requires a super-admin actor, while `setUserAclInDb` keeps the spec
307
+ * self-contained and deterministic. Login happens AFTER the ACL is written so the
308
+ * fresh session resolves the new grants.
309
+ */
310
+ export async function provisionWebhooksUser(
311
+ request: APIRequestContext,
312
+ adminToken: string,
313
+ input: {
314
+ tenantId: string;
315
+ organizationId: string;
316
+ features: string[];
317
+ organizations?: string[] | null;
318
+ slug: string;
319
+ },
320
+ ): Promise<ProvisionedWebhooksUser> {
321
+ const unique = `${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
322
+ const email = `qa-webhooks-${input.slug}-${unique}@acme.com`;
323
+ const password = 'Valid1!Webhooks';
324
+ const userId = await createUserFixture(request, adminToken, {
325
+ email,
326
+ password,
327
+ organizationId: input.organizationId,
328
+ roles: [],
329
+ name: `QA Webhooks ${input.slug}`,
330
+ });
331
+ await setUserAclInDb({
332
+ userId,
333
+ tenantId: input.tenantId,
334
+ features: input.features,
335
+ organizations: input.organizations ?? null,
336
+ });
337
+ const token = await getAuthToken(request, email, password);
338
+ return { userId, email, password, token };
339
+ }
340
+
341
+ export async function cleanupWebhooksUser(
342
+ request: APIRequestContext,
343
+ adminToken: string | null,
344
+ user: ProvisionedWebhooksUser | null,
345
+ ): Promise<void> {
346
+ if (!user) return;
347
+ await deleteUserAclInDb(user.userId).catch(() => undefined);
348
+ await deleteUserIfExists(request, adminToken, user.userId).catch(() => undefined);
349
+ }