@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.js +238 -0
- package/dist/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js +46 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js +92 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js +61 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js +76 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js +81 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js +69 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js +64 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.js.map +7 -0
- package/dist/modules/webhooks/__integration__/helpers/fixtures.js +50 -1
- package/dist/modules/webhooks/__integration__/helpers/fixtures.js.map +2 -2
- package/dist/modules/webhooks/api/webhooks/[id]/route.js +24 -0
- package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
- package/dist/modules/webhooks/backend/webhooks/[id]/page.js +9 -2
- package/dist/modules/webhooks/backend/webhooks/[id]/page.js.map +3 -3
- package/dist/modules/webhooks/backend/webhooks/page.js +4 -1
- package/dist/modules/webhooks/backend/webhooks/page.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.ts +352 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-004.spec.ts +63 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-005.spec.ts +126 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-006.spec.ts +82 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.ts +102 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.ts +109 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.ts +95 -0
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.ts +89 -0
- package/src/modules/webhooks/__integration__/helpers/fixtures.ts +101 -1
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/optimistic-lock.test.ts +101 -0
- package/src/modules/webhooks/api/webhooks/[id]/route.ts +26 -0
- package/src/modules/webhooks/backend/webhooks/[id]/page.tsx +9 -2
- 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
|
|