@open-mercato/webhooks 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4559.1.839e136509

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) 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 +69 -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__/helpers/fixtures.js +50 -1
  19. package/dist/modules/webhooks/__integration__/helpers/fixtures.js.map +2 -2
  20. package/dist/modules/webhooks/api/webhooks/[id]/route.js +24 -0
  21. package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
  22. package/dist/modules/webhooks/backend/webhooks/[id]/page.js +9 -2
  23. package/dist/modules/webhooks/backend/webhooks/[id]/page.js.map +3 -3
  24. package/dist/modules/webhooks/backend/webhooks/page.js +4 -1
  25. package/dist/modules/webhooks/backend/webhooks/page.js.map +2 -2
  26. package/package.json +6 -6
  27. package/src/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.ts +352 -0
  28. package/src/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.ts +63 -0
  29. package/src/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.ts +126 -0
  30. package/src/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.ts +82 -0
  31. package/src/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.ts +102 -0
  32. package/src/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.ts +109 -0
  33. package/src/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.ts +95 -0
  34. package/src/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.ts +89 -0
  35. package/src/modules/webhooks/__integration__/helpers/fixtures.ts +101 -1
  36. package/src/modules/webhooks/api/webhooks/[id]/__tests__/optimistic-lock.test.ts +101 -0
  37. package/src/modules/webhooks/api/webhooks/[id]/route.ts +26 -0
  38. package/src/modules/webhooks/backend/webhooks/[id]/page.tsx +9 -2
  39. package/src/modules/webhooks/backend/webhooks/page.tsx +4 -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,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 { createWebhookFixture, deleteWebhookIfExists } from './helpers/fixtures';
5
+
6
+ /**
7
+ * TC-WEBHOOK-009: Invalid and unsafe URL rejection
8
+ * Source: https://github.com/open-mercato/open-mercato/issues/2482
9
+ *
10
+ * Webhook URLs go through the shared outbound-URL safety check. Forbidden schemes,
11
+ * embedded credentials, and malformed URLs are rejected with 400 on both create and
12
+ * update regardless of configuration. Private/loopback hosts are rejected unless the
13
+ * deployment opts in via OM_WEBHOOKS_ALLOW_PRIVATE_URLS (the integration env enables
14
+ * it so the loopback mock receiver works) — this spec asserts whichever behavior the
15
+ * active flag dictates. Exhaustive private-IP coverage lives in the unit suites
16
+ * (data/__tests__/validators.test.ts, lib/__tests__/url-safety.test.ts).
17
+ */
18
+ const PRIVATE_URLS_ALLOWED = ['1', 'true', 'yes', 'on'].includes(
19
+ (process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS ?? '').trim().toLowerCase(),
20
+ );
21
+
22
+ const ALWAYS_UNSAFE_URLS = [
23
+ 'ftp://example.com/webhook',
24
+ 'file:///etc/passwd',
25
+ 'http://user:pass@example.com/webhook',
26
+ 'not-a-valid-url',
27
+ ];
28
+
29
+ const PRIVATE_HOST_URLS = [
30
+ 'http://127.0.0.1:9001/webhook',
31
+ 'http://10.0.0.1/webhook',
32
+ 'http://[::1]:9001/webhook',
33
+ ];
34
+
35
+ function createBody(url: string) {
36
+ return {
37
+ name: `Webhook URL Safety ${Date.now()}`,
38
+ url,
39
+ subscribedEvents: ['catalog.product.created'],
40
+ };
41
+ }
42
+
43
+ test.describe('TC-WEBHOOK-009: Invalid and unsafe URL rejection', () => {
44
+ test('should reject unsafe webhook URLs on create and update and accept valid external URLs', async ({ request }) => {
45
+ const token = await getAuthToken(request);
46
+ let webhookId: string | null = null;
47
+ const strayPrivateWebhookIds: string[] = [];
48
+
49
+ try {
50
+ // Always-unsafe URLs are rejected at create regardless of the private-URL flag.
51
+ for (const url of ALWAYS_UNSAFE_URLS) {
52
+ const response = await apiRequest(request, 'POST', '/api/webhooks', { token, data: createBody(url) });
53
+ expect(response.status(), `POST should reject unsafe url ${url}`).toBe(400);
54
+ }
55
+
56
+ // A valid external https URL is accepted.
57
+ const created = await createWebhookFixture(request, token, {
58
+ name: `Webhook URL Safety Valid ${Date.now()}`,
59
+ url: 'https://example.com/webhook-url-safety',
60
+ subscribedEvents: ['catalog.product.created'],
61
+ });
62
+ webhookId = created.id;
63
+
64
+ // Updating an existing webhook to an unsafe URL is rejected; the record is unchanged.
65
+ for (const url of ALWAYS_UNSAFE_URLS) {
66
+ const response = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, { token, data: { url } });
67
+ expect(response.status(), `PUT should reject unsafe url ${url}`).toBe(400);
68
+ }
69
+
70
+ // Updating to another valid external URL succeeds.
71
+ const validUpdate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
72
+ token,
73
+ data: { url: 'https://example.org/webhook-url-safety-updated' },
74
+ });
75
+ expect(validUpdate.status()).toBe(200);
76
+
77
+ // Private/loopback hosts: behavior depends on the deployment flag.
78
+ for (const url of PRIVATE_HOST_URLS) {
79
+ const response = await apiRequest(request, 'POST', '/api/webhooks', { token, data: createBody(url) });
80
+ if (PRIVATE_URLS_ALLOWED) {
81
+ expect(response.status(), `POST should accept private url ${url} when allowed`).toBe(201);
82
+ const body = await readJsonSafe<{ id?: string }>(response);
83
+ if (body?.id) strayPrivateWebhookIds.push(body.id);
84
+ } else {
85
+ expect(response.status(), `POST should reject private url ${url} when not allowed`).toBe(400);
86
+ }
87
+ }
88
+ } finally {
89
+ await deleteWebhookIfExists(request, token, webhookId);
90
+ for (const id of strayPrivateWebhookIds) {
91
+ await deleteWebhookIfExists(request, token, id);
92
+ }
93
+ }
94
+ });
95
+ });
@@ -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
+ });
@@ -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
+ }
@@ -0,0 +1,101 @@
1
+ /** @jest-environment node */
2
+
3
+ import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
4
+
5
+ const WEBHOOK_ID = '123e4567-e89b-12d3-a456-426614174070'
6
+ const CURRENT_VERSION = '2026-06-01T10:00:00.000Z'
7
+ const STALE_VERSION = '2026-06-01T09:00:00.000Z'
8
+
9
+ const webhookRecord = {
10
+ id: WEBHOOK_ID,
11
+ name: 'Hook',
12
+ description: null,
13
+ url: 'https://example.com/hook',
14
+ subscribedEvents: ['a.b.c'],
15
+ httpMethod: 'POST',
16
+ isActive: true,
17
+ organizationId: 'org-1',
18
+ tenantId: 'tenant-1',
19
+ updatedAt: new Date(CURRENT_VERSION),
20
+ deletedAt: null as Date | null,
21
+ }
22
+
23
+ const mockEm = {
24
+ fork: jest.fn(() => mockEm),
25
+ flush: jest.fn(async () => undefined),
26
+ }
27
+
28
+ const mockEmitWebhooksEvent = jest.fn(async () => undefined)
29
+
30
+ jest.mock('../../../../events', () => ({
31
+ emitWebhooksEvent: (...args: unknown[]) => mockEmitWebhooksEvent(...args),
32
+ }))
33
+
34
+ jest.mock('../../../helpers', () => ({
35
+ json: (payload: unknown, init: ResponseInit = { status: 200 }) =>
36
+ new Response(JSON.stringify(payload), {
37
+ ...init,
38
+ headers: { 'content-type': 'application/json' },
39
+ }),
40
+ resolveWebhookRequestScope: jest.fn(async () => ({ em: mockEm, tenantId: 'tenant-1', organizationId: 'org-1' })),
41
+ findScopedWebhook: jest.fn(async () => (webhookRecord.deletedAt ? null : webhookRecord)),
42
+ serializeWebhookDetail: (item: { id: string; updatedAt: Date }) => ({
43
+ id: item.id,
44
+ updatedAt: item.updatedAt.toISOString(),
45
+ }),
46
+ }))
47
+
48
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
49
+ resolveTranslations: jest.fn(async () => ({ translate: (_key: string, fallback?: string) => fallback ?? '' })),
50
+ }))
51
+
52
+ import { PUT, DELETE } from '../route'
53
+
54
+ function request(method: string, headerVersion: string | null, body?: unknown) {
55
+ const headers: Record<string, string> = { 'content-type': 'application/json' }
56
+ if (headerVersion) headers[OPTIMISTIC_LOCK_HEADER_NAME] = headerVersion
57
+ return new Request(`http://localhost/api/webhooks/${WEBHOOK_ID}`, {
58
+ method,
59
+ headers,
60
+ body: body !== undefined ? JSON.stringify(body) : undefined,
61
+ })
62
+ }
63
+
64
+ const context = { params: Promise.resolve({ id: WEBHOOK_ID }) }
65
+
66
+ describe('webhook endpoint PUT/DELETE optimistic locking', () => {
67
+ beforeEach(() => {
68
+ jest.clearAllMocks()
69
+ webhookRecord.deletedAt = null
70
+ delete process.env.OM_OPTIMISTIC_LOCK
71
+ })
72
+
73
+ it('PUT returns 409 with the structured conflict body when the expected version is stale', async () => {
74
+ const res = await PUT(request('PUT', STALE_VERSION, { name: 'X' }), context)
75
+ expect(res.status).toBe(409)
76
+ const body = await res.json()
77
+ expect(body.code).toBe('optimistic_lock_conflict')
78
+ expect(body.currentUpdatedAt).toBe(CURRENT_VERSION)
79
+ expect(mockEm.flush).not.toHaveBeenCalled()
80
+ })
81
+
82
+ it('PUT succeeds when the expected version matches', async () => {
83
+ const res = await PUT(request('PUT', CURRENT_VERSION, { name: 'X' }), context)
84
+ expect(res.status).toBe(200)
85
+ expect(mockEm.flush).toHaveBeenCalled()
86
+ })
87
+
88
+ it('PUT is a no-op (no 409) when the client sends no expected-version header', async () => {
89
+ const res = await PUT(request('PUT', null, { name: 'X' }), context)
90
+ expect(res.status).toBe(200)
91
+ expect(mockEm.flush).toHaveBeenCalled()
92
+ })
93
+
94
+ it('DELETE returns 409 when the expected version is stale', async () => {
95
+ const res = await DELETE(request('DELETE', STALE_VERSION), context)
96
+ expect(res.status).toBe(409)
97
+ const body = await res.json()
98
+ expect(body.code).toBe('optimistic_lock_conflict')
99
+ expect(mockEm.flush).not.toHaveBeenCalled()
100
+ })
101
+ })
@@ -4,6 +4,8 @@ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
4
4
  import { emitWebhooksEvent } from '../../../events'
5
5
  import { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from '../../helpers'
6
6
  import { webhookUpdateSchema } from '../../../data/validators'
7
+ import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
8
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
9
 
8
10
  export const metadata = {
9
11
  GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },
@@ -69,6 +71,18 @@ export async function PUT(request: Request, context: RouteContext): Promise<Resp
69
71
  return json({ error: 'Webhook not found' }, { status: 404 })
70
72
  }
71
73
 
74
+ try {
75
+ enforceCommandOptimisticLock({
76
+ resourceKind: 'webhooks.endpoint',
77
+ resourceId: webhook.id,
78
+ current: webhook.updatedAt ?? null,
79
+ request,
80
+ })
81
+ } catch (err) {
82
+ if (isCrudHttpError(err)) return json(err.body, { status: err.status })
83
+ throw err
84
+ }
85
+
72
86
  const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null))
73
87
  if (!parsed.success) {
74
88
  return json({ error: 'Invalid request payload' }, { status: 400 })
@@ -114,6 +128,18 @@ export async function DELETE(request: Request, context: RouteContext): Promise<R
114
128
  return json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })
115
129
  }
116
130
 
131
+ try {
132
+ enforceCommandOptimisticLock({
133
+ resourceKind: 'webhooks.endpoint',
134
+ resourceId: webhook.id,
135
+ current: webhook.updatedAt ?? null,
136
+ request,
137
+ })
138
+ } catch (err) {
139
+ if (isCrudHttpError(err)) return json(err.body, { status: err.status })
140
+ throw err
141
+ }
142
+
117
143
  webhook.deletedAt = new Date()
118
144
  await em.flush()
119
145