@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,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/__integration__/TC-WEBHOOK-007.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { getTokenScope } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport { createOrganizationInDb, deleteOrganizationInDb } from '@open-mercato/core/helpers/integration/dbFixtures';\nimport {\n cleanupWebhooksUser,\n createWebhookFixture,\n deleteWebhookIfExists,\n listWebhooks,\n provisionWebhooksUser,\n type ProvisionedWebhooksUser,\n type WebhookCreateResponse,\n} from './helpers/fixtures';\n\n/**\n * TC-WEBHOOK-007: Tenant/organization scoping isolation\n * Source: https://github.com/open-mercato/open-mercato/issues/2482\n *\n * Webhooks are scoped by tenantId + organizationId. Within one tenant, two\n * organization-restricted users must not see or mutate each other's webhooks:\n * the list excludes out-of-scope rows and detail/update/delete on a foreign\n * webhook returns 404 (existence is not revealed across the org boundary).\n */\nconst MANAGE_FEATURES = ['webhooks.view', 'webhooks.manage'];\n\ntest.describe('TC-WEBHOOK-007: Tenant/organization scoping isolation', () => {\n test('should isolate webhooks between organizations within the same tenant', async ({ request }) => {\n const adminToken = await getAuthToken(request);\n const { tenantId, organizationId: orgA } = getTokenScope(adminToken);\n\n let orgB: string | null = null;\n let userA: ProvisionedWebhooksUser | null = null;\n let userB: ProvisionedWebhooksUser | null = null;\n let webhookA: WebhookCreateResponse | null = null;\n let webhookB: WebhookCreateResponse | null = null;\n\n try {\n orgB = await createOrganizationInDb({ name: `TC-WEBHOOK-007 Org B ${Date.now()}`, tenantId });\n\n userA = await provisionWebhooksUser(request, adminToken, {\n tenantId,\n organizationId: orgA,\n features: MANAGE_FEATURES,\n organizations: [orgA],\n slug: 'org-a',\n });\n userB = await provisionWebhooksUser(request, adminToken, {\n tenantId,\n organizationId: orgB,\n features: MANAGE_FEATURES,\n organizations: [orgB],\n slug: 'org-b',\n });\n\n webhookA = await createWebhookFixture(request, userA.token, {\n name: `TC-WEBHOOK-007 A ${Date.now()}`,\n url: 'https://example.com/webhook-org-a',\n subscribedEvents: ['catalog.product.created'],\n });\n webhookB = await createWebhookFixture(request, userB.token, {\n name: `TC-WEBHOOK-007 B ${Date.now()}`,\n url: 'https://example.com/webhook-org-b',\n subscribedEvents: ['catalog.product.created'],\n });\n\n // Lists are scoped: each user sees only their own organization's webhook.\n const listA = await listWebhooks(request, userA.token);\n expect(listA.items.some((item) => item.id === webhookA!.id)).toBe(true);\n expect(listA.items.some((item) => item.id === webhookB!.id)).toBe(false);\n\n const listB = await listWebhooks(request, userB.token);\n expect(listB.items.some((item) => item.id === webhookB!.id)).toBe(true);\n expect(listB.items.some((item) => item.id === webhookA!.id)).toBe(false);\n\n // Cross-org reads/writes are not found.\n const crossGet = await apiRequest(request, 'GET', `/api/webhooks/${webhookB.id}`, { token: userA.token });\n expect(crossGet.status()).toBe(404);\n\n const crossUpdate = await apiRequest(request, 'PUT', `/api/webhooks/${webhookB.id}`, {\n token: userA.token,\n data: { name: `TC-WEBHOOK-007 cross-org hijack ${Date.now()}` },\n });\n expect(crossUpdate.status()).toBe(404);\n\n const crossDelete = await apiRequest(request, 'DELETE', `/api/webhooks/${webhookB.id}`, { token: userA.token });\n expect(crossDelete.status()).toBe(404);\n\n const crossGetReverse = await apiRequest(request, 'GET', `/api/webhooks/${webhookA.id}`, { token: userB.token });\n expect(crossGetReverse.status()).toBe(404);\n\n // In-scope access still works after the cross-org attempts.\n const ownGet = await apiRequest(request, 'GET', `/api/webhooks/${webhookA.id}`, { token: userA.token });\n expect(ownGet.status()).toBe(200);\n } finally {\n await deleteWebhookIfExists(request, userA?.token ?? null, webhookA?.id ?? null);\n await deleteWebhookIfExists(request, userB?.token ?? null, webhookB?.id ?? null);\n await cleanupWebhooksUser(request, adminToken, userA);\n await cleanupWebhooksUser(request, adminToken, userB);\n await deleteOrganizationInDb(orgB);\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import {
|
|
4
|
+
buildMockInboundUrl,
|
|
5
|
+
createWebhookFixture,
|
|
6
|
+
deleteWebhookIfExists,
|
|
7
|
+
listWebhookDeliveries,
|
|
8
|
+
sendTestDelivery
|
|
9
|
+
} from "./helpers/fixtures.js";
|
|
10
|
+
const EVENT_CREATED = "catalog.product.created";
|
|
11
|
+
const EVENT_UPDATED = "catalog.product.updated";
|
|
12
|
+
function deliveriesPath(webhookId, extra = {}) {
|
|
13
|
+
const params = new URLSearchParams({ webhookId, ...extra });
|
|
14
|
+
return `/api/webhooks/deliveries?${params.toString()}`;
|
|
15
|
+
}
|
|
16
|
+
test.describe("TC-WEBHOOK-008: Delivery list filtering by status and event type", () => {
|
|
17
|
+
test("should filter delivery logs by status, event type, and their combination", async ({ request }) => {
|
|
18
|
+
const token = await getAuthToken(request);
|
|
19
|
+
let webhookId = null;
|
|
20
|
+
try {
|
|
21
|
+
const created = await createWebhookFixture(request, token, {
|
|
22
|
+
name: `Webhook Filter ${Date.now()}`,
|
|
23
|
+
url: buildMockInboundUrl(),
|
|
24
|
+
subscribedEvents: [EVENT_CREATED, EVENT_UPDATED],
|
|
25
|
+
customHeaders: { "x-mock-webhook-signature": "valid" }
|
|
26
|
+
});
|
|
27
|
+
webhookId = created.id;
|
|
28
|
+
const deliveredCreated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_CREATED });
|
|
29
|
+
expect(deliveredCreated.delivery.status).toBe("delivered");
|
|
30
|
+
const deliveredUpdated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_UPDATED });
|
|
31
|
+
expect(deliveredUpdated.delivery.status).toBe("delivered");
|
|
32
|
+
const invalidate = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
33
|
+
token,
|
|
34
|
+
data: { customHeaders: { "x-mock-webhook-signature": "invalid" } }
|
|
35
|
+
});
|
|
36
|
+
expect(invalidate.status()).toBe(200);
|
|
37
|
+
const expiredCreated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_CREATED });
|
|
38
|
+
expect(expiredCreated.delivery.status).toBe("expired");
|
|
39
|
+
const d1 = deliveredCreated.delivery.id;
|
|
40
|
+
const d2 = deliveredUpdated.delivery.id;
|
|
41
|
+
const d3 = expiredCreated.delivery.id;
|
|
42
|
+
const all = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id));
|
|
43
|
+
expect(all.total).toBeGreaterThanOrEqual(3);
|
|
44
|
+
for (const id of [d1, d2, d3]) {
|
|
45
|
+
expect(all.items.some((item) => item.id === id)).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
const delivered = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: "delivered" }));
|
|
48
|
+
expect(delivered.items.every((item) => item.status === "delivered")).toBe(true);
|
|
49
|
+
expect(delivered.items.some((item) => item.id === d1)).toBe(true);
|
|
50
|
+
expect(delivered.items.some((item) => item.id === d2)).toBe(true);
|
|
51
|
+
expect(delivered.items.some((item) => item.id === d3)).toBe(false);
|
|
52
|
+
const expired = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: "expired" }));
|
|
53
|
+
expect(expired.items.every((item) => item.status === "expired")).toBe(true);
|
|
54
|
+
expect(expired.items.some((item) => item.id === d3)).toBe(true);
|
|
55
|
+
expect(expired.items.some((item) => item.id === d1)).toBe(false);
|
|
56
|
+
const createdEvents = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { eventType: EVENT_CREATED }));
|
|
57
|
+
expect(createdEvents.items.every((item) => item.eventType === EVENT_CREATED)).toBe(true);
|
|
58
|
+
expect(createdEvents.items.some((item) => item.id === d1)).toBe(true);
|
|
59
|
+
expect(createdEvents.items.some((item) => item.id === d3)).toBe(true);
|
|
60
|
+
expect(createdEvents.items.some((item) => item.id === d2)).toBe(false);
|
|
61
|
+
const combined = await listWebhookDeliveries(
|
|
62
|
+
request,
|
|
63
|
+
token,
|
|
64
|
+
created.id,
|
|
65
|
+
deliveriesPath(created.id, { status: "delivered", eventType: EVENT_CREATED })
|
|
66
|
+
);
|
|
67
|
+
expect(combined.items.every((item) => item.status === "delivered" && item.eventType === EVENT_CREATED)).toBe(true);
|
|
68
|
+
expect(combined.items.some((item) => item.id === d1)).toBe(true);
|
|
69
|
+
expect(combined.items.some((item) => item.id === d2)).toBe(false);
|
|
70
|
+
expect(combined.items.some((item) => item.id === d3)).toBe(false);
|
|
71
|
+
const empty = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: "pending" }));
|
|
72
|
+
expect(empty.items).toEqual([]);
|
|
73
|
+
expect(empty.total).toBe(0);
|
|
74
|
+
expect(empty.page).toBe(1);
|
|
75
|
+
expect(empty.totalPages).toBe(0);
|
|
76
|
+
} finally {
|
|
77
|
+
await deleteWebhookIfExists(request, token, webhookId);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
//# sourceMappingURL=TC-WEBHOOK-008.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/__integration__/TC-WEBHOOK-008.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport {\n buildMockInboundUrl,\n createWebhookFixture,\n deleteWebhookIfExists,\n listWebhookDeliveries,\n sendTestDelivery,\n} from './helpers/fixtures';\n\n/**\n * TC-WEBHOOK-008: Delivery list filtering by status and event type\n * Source: https://github.com/open-mercato/open-mercato/issues/2482\n *\n * GET /api/webhooks/deliveries supports webhookId, status, and eventType filters.\n * Seeds three deliveries on one webhook \u2014 delivered(created), delivered(updated),\n * expired(created) \u2014 then verifies each filter and their intersection.\n */\nconst EVENT_CREATED = 'catalog.product.created';\nconst EVENT_UPDATED = 'catalog.product.updated';\n\nfunction deliveriesPath(webhookId: string, extra: Record<string, string> = {}): string {\n const params = new URLSearchParams({ webhookId, ...extra });\n return `/api/webhooks/deliveries?${params.toString()}`;\n}\n\ntest.describe('TC-WEBHOOK-008: Delivery list filtering by status and event type', () => {\n test('should filter delivery logs by status, event type, and their combination', async ({ request }) => {\n const token = await getAuthToken(request);\n let webhookId: string | null = null;\n\n try {\n const created = await createWebhookFixture(request, token, {\n name: `Webhook Filter ${Date.now()}`,\n url: buildMockInboundUrl(),\n subscribedEvents: [EVENT_CREATED, EVENT_UPDATED],\n customHeaders: { 'x-mock-webhook-signature': 'valid' },\n });\n webhookId = created.id;\n\n const deliveredCreated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_CREATED });\n expect(deliveredCreated.delivery.status).toBe('delivered');\n const deliveredUpdated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_UPDATED });\n expect(deliveredUpdated.delivery.status).toBe('delivered');\n\n // Flip the mock signature to invalid so the next attempt fails terminally (expired).\n const invalidate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token,\n data: { customHeaders: { 'x-mock-webhook-signature': 'invalid' } },\n });\n expect(invalidate.status()).toBe(200);\n\n const expiredCreated = await sendTestDelivery(request, token, created.id, { eventType: EVENT_CREATED });\n expect(expiredCreated.delivery.status).toBe('expired');\n\n const d1 = deliveredCreated.delivery.id;\n const d2 = deliveredUpdated.delivery.id;\n const d3 = expiredCreated.delivery.id;\n\n // Unfiltered (by webhook): all three present.\n const all = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id));\n expect(all.total).toBeGreaterThanOrEqual(3);\n for (const id of [d1, d2, d3]) {\n expect(all.items.some((item) => item.id === id)).toBe(true);\n }\n\n // status=delivered \u2192 d1, d2 only.\n const delivered = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: 'delivered' }));\n expect(delivered.items.every((item) => item.status === 'delivered')).toBe(true);\n expect(delivered.items.some((item) => item.id === d1)).toBe(true);\n expect(delivered.items.some((item) => item.id === d2)).toBe(true);\n expect(delivered.items.some((item) => item.id === d3)).toBe(false);\n\n // status=expired \u2192 d3 only.\n const expired = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: 'expired' }));\n expect(expired.items.every((item) => item.status === 'expired')).toBe(true);\n expect(expired.items.some((item) => item.id === d3)).toBe(true);\n expect(expired.items.some((item) => item.id === d1)).toBe(false);\n\n // eventType=created \u2192 d1, d3 only.\n const createdEvents = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { eventType: EVENT_CREATED }));\n expect(createdEvents.items.every((item) => item.eventType === EVENT_CREATED)).toBe(true);\n expect(createdEvents.items.some((item) => item.id === d1)).toBe(true);\n expect(createdEvents.items.some((item) => item.id === d3)).toBe(true);\n expect(createdEvents.items.some((item) => item.id === d2)).toBe(false);\n\n // Combined status=delivered & eventType=created \u2192 d1 only.\n const combined = await listWebhookDeliveries(\n request,\n token,\n created.id,\n deliveriesPath(created.id, { status: 'delivered', eventType: EVENT_CREATED }),\n );\n expect(combined.items.every((item) => item.status === 'delivered' && item.eventType === EVENT_CREATED)).toBe(true);\n expect(combined.items.some((item) => item.id === d1)).toBe(true);\n expect(combined.items.some((item) => item.id === d2)).toBe(false);\n expect(combined.items.some((item) => item.id === d3)).toBe(false);\n\n // Non-matching filter \u2192 empty result set with pagination metadata.\n const empty = await listWebhookDeliveries(request, token, created.id, deliveriesPath(created.id, { status: 'pending' }));\n expect(empty.items).toEqual([]);\n expect(empty.total).toBe(0);\n expect(empty.page).toBe(1);\n expect(empty.totalPages).toBe(0);\n } finally {\n await deleteWebhookIfExists(request, token, webhookId);\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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.js";
|
|
5
|
+
const PRIVATE_URLS_ALLOWED = ["1", "true", "yes", "on"].includes(
|
|
6
|
+
(process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS ?? "").trim().toLowerCase()
|
|
7
|
+
);
|
|
8
|
+
const ALWAYS_UNSAFE_URLS = [
|
|
9
|
+
"ftp://example.com/webhook",
|
|
10
|
+
"file:///etc/passwd",
|
|
11
|
+
"http://user:pass@example.com/webhook",
|
|
12
|
+
"not-a-valid-url"
|
|
13
|
+
];
|
|
14
|
+
const PRIVATE_HOST_URLS = [
|
|
15
|
+
"http://127.0.0.1:9001/webhook",
|
|
16
|
+
"http://10.0.0.1/webhook",
|
|
17
|
+
"http://[::1]:9001/webhook"
|
|
18
|
+
];
|
|
19
|
+
function createBody(url) {
|
|
20
|
+
return {
|
|
21
|
+
name: `Webhook URL Safety ${Date.now()}`,
|
|
22
|
+
url,
|
|
23
|
+
subscribedEvents: ["catalog.product.created"]
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
test.describe("TC-WEBHOOK-009: Invalid and unsafe URL rejection", () => {
|
|
27
|
+
test("should reject unsafe webhook URLs on create and update and accept valid external URLs", async ({ request }) => {
|
|
28
|
+
const token = await getAuthToken(request);
|
|
29
|
+
let webhookId = null;
|
|
30
|
+
const strayPrivateWebhookIds = [];
|
|
31
|
+
try {
|
|
32
|
+
for (const url of ALWAYS_UNSAFE_URLS) {
|
|
33
|
+
const response = await apiRequest(request, "POST", "/api/webhooks", { token, data: createBody(url) });
|
|
34
|
+
expect(response.status(), `POST should reject unsafe url ${url}`).toBe(400);
|
|
35
|
+
}
|
|
36
|
+
const created = await createWebhookFixture(request, token, {
|
|
37
|
+
name: `Webhook URL Safety Valid ${Date.now()}`,
|
|
38
|
+
url: "https://example.com/webhook-url-safety",
|
|
39
|
+
subscribedEvents: ["catalog.product.created"]
|
|
40
|
+
});
|
|
41
|
+
webhookId = created.id;
|
|
42
|
+
for (const url of ALWAYS_UNSAFE_URLS) {
|
|
43
|
+
const response = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, { token, data: { url } });
|
|
44
|
+
expect(response.status(), `PUT should reject unsafe url ${url}`).toBe(400);
|
|
45
|
+
}
|
|
46
|
+
const validUpdate = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
47
|
+
token,
|
|
48
|
+
data: { url: "https://example.org/webhook-url-safety-updated" }
|
|
49
|
+
});
|
|
50
|
+
expect(validUpdate.status()).toBe(200);
|
|
51
|
+
for (const url of PRIVATE_HOST_URLS) {
|
|
52
|
+
const response = await apiRequest(request, "POST", "/api/webhooks", { token, data: createBody(url) });
|
|
53
|
+
if (PRIVATE_URLS_ALLOWED) {
|
|
54
|
+
expect(response.status(), `POST should accept private url ${url} when allowed`).toBe(201);
|
|
55
|
+
const body = await readJsonSafe(response);
|
|
56
|
+
if (body?.id) strayPrivateWebhookIds.push(body.id);
|
|
57
|
+
} else {
|
|
58
|
+
expect(response.status(), `POST should reject private url ${url} when not allowed`).toBe(400);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
await deleteWebhookIfExists(request, token, webhookId);
|
|
63
|
+
for (const id of strayPrivateWebhookIds) {
|
|
64
|
+
await deleteWebhookIfExists(request, token, id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
//# sourceMappingURL=TC-WEBHOOK-009.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/__integration__/TC-WEBHOOK-009.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport { createWebhookFixture, deleteWebhookIfExists } from './helpers/fixtures';\n\n/**\n * TC-WEBHOOK-009: Invalid and unsafe URL rejection\n * Source: https://github.com/open-mercato/open-mercato/issues/2482\n *\n * Webhook URLs go through the shared outbound-URL safety check. Forbidden schemes,\n * embedded credentials, and malformed URLs are rejected with 400 on both create and\n * update regardless of configuration. Private/loopback hosts are rejected unless the\n * deployment opts in via OM_WEBHOOKS_ALLOW_PRIVATE_URLS (the integration env enables\n * it so the loopback mock receiver works) \u2014 this spec asserts whichever behavior the\n * active flag dictates. Exhaustive private-IP coverage lives in the unit suites\n * (data/__tests__/validators.test.ts, lib/__tests__/url-safety.test.ts).\n */\nconst PRIVATE_URLS_ALLOWED = ['1', 'true', 'yes', 'on'].includes(\n (process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS ?? '').trim().toLowerCase(),\n);\n\nconst ALWAYS_UNSAFE_URLS = [\n 'ftp://example.com/webhook',\n 'file:///etc/passwd',\n 'http://user:pass@example.com/webhook',\n 'not-a-valid-url',\n];\n\nconst PRIVATE_HOST_URLS = [\n 'http://127.0.0.1:9001/webhook',\n 'http://10.0.0.1/webhook',\n 'http://[::1]:9001/webhook',\n];\n\nfunction createBody(url: string) {\n return {\n name: `Webhook URL Safety ${Date.now()}`,\n url,\n subscribedEvents: ['catalog.product.created'],\n };\n}\n\ntest.describe('TC-WEBHOOK-009: Invalid and unsafe URL rejection', () => {\n test('should reject unsafe webhook URLs on create and update and accept valid external URLs', async ({ request }) => {\n const token = await getAuthToken(request);\n let webhookId: string | null = null;\n const strayPrivateWebhookIds: string[] = [];\n\n try {\n // Always-unsafe URLs are rejected at create regardless of the private-URL flag.\n for (const url of ALWAYS_UNSAFE_URLS) {\n const response = await apiRequest(request, 'POST', '/api/webhooks', { token, data: createBody(url) });\n expect(response.status(), `POST should reject unsafe url ${url}`).toBe(400);\n }\n\n // A valid external https URL is accepted.\n const created = await createWebhookFixture(request, token, {\n name: `Webhook URL Safety Valid ${Date.now()}`,\n url: 'https://example.com/webhook-url-safety',\n subscribedEvents: ['catalog.product.created'],\n });\n webhookId = created.id;\n\n // Updating an existing webhook to an unsafe URL is rejected; the record is unchanged.\n for (const url of ALWAYS_UNSAFE_URLS) {\n const response = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, { token, data: { url } });\n expect(response.status(), `PUT should reject unsafe url ${url}`).toBe(400);\n }\n\n // Updating to another valid external URL succeeds.\n const validUpdate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token,\n data: { url: 'https://example.org/webhook-url-safety-updated' },\n });\n expect(validUpdate.status()).toBe(200);\n\n // Private/loopback hosts: behavior depends on the deployment flag.\n for (const url of PRIVATE_HOST_URLS) {\n const response = await apiRequest(request, 'POST', '/api/webhooks', { token, data: createBody(url) });\n if (PRIVATE_URLS_ALLOWED) {\n expect(response.status(), `POST should accept private url ${url} when allowed`).toBe(201);\n const body = await readJsonSafe<{ id?: string }>(response);\n if (body?.id) strayPrivateWebhookIds.push(body.id);\n } else {\n expect(response.status(), `POST should reject private url ${url} when not allowed`).toBe(400);\n }\n }\n } finally {\n await deleteWebhookIfExists(request, token, webhookId);\n for (const id of strayPrivateWebhookIds) {\n await deleteWebhookIfExists(request, token, id);\n }\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B,SAAS,sBAAsB,6BAA6B;AAc5D,MAAM,uBAAuB,CAAC,KAAK,QAAQ,OAAO,IAAI,EAAE;AAAA,GACrD,QAAQ,IAAI,kCAAkC,IAAI,KAAK,EAAE,YAAY;AACxE;AAEA,MAAM,qBAAqB;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,MAAM,oBAAoB;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AACF;AAEA,SAAS,WAAW,KAAa;AAC/B,SAAO;AAAA,IACL,MAAM,sBAAsB,KAAK,IAAI,CAAC;AAAA,IACtC;AAAA,IACA,kBAAkB,CAAC,yBAAyB;AAAA,EAC9C;AACF;AAEA,KAAK,SAAS,oDAAoD,MAAM;AACtE,OAAK,yFAAyF,OAAO,EAAE,QAAQ,MAAM;AACnH,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,QAAI,YAA2B;AAC/B,UAAM,yBAAmC,CAAC;AAE1C,QAAI;AAEF,iBAAW,OAAO,oBAAoB;AACpC,cAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,iBAAiB,EAAE,OAAO,MAAM,WAAW,GAAG,EAAE,CAAC;AACpG,eAAO,SAAS,OAAO,GAAG,iCAAiC,GAAG,EAAE,EAAE,KAAK,GAAG;AAAA,MAC5E;AAGA,YAAM,UAAU,MAAM,qBAAqB,SAAS,OAAO;AAAA,QACzD,MAAM,4BAA4B,KAAK,IAAI,CAAC;AAAA,QAC5C,KAAK;AAAA,QACL,kBAAkB,CAAC,yBAAyB;AAAA,MAC9C,CAAC;AACD,kBAAY,QAAQ;AAGpB,iBAAW,OAAO,oBAAoB;AACpC,cAAM,WAAW,MAAM,WAAW,SAAS,OAAO,iBAAiB,QAAQ,EAAE,IAAI,EAAE,OAAO,MAAM,EAAE,IAAI,EAAE,CAAC;AACzG,eAAO,SAAS,OAAO,GAAG,gCAAgC,GAAG,EAAE,EAAE,KAAK,GAAG;AAAA,MAC3E;AAGA,YAAM,cAAc,MAAM,WAAW,SAAS,OAAO,iBAAiB,QAAQ,EAAE,IAAI;AAAA,QAClF;AAAA,QACA,MAAM,EAAE,KAAK,iDAAiD;AAAA,MAChE,CAAC;AACD,aAAO,YAAY,OAAO,CAAC,EAAE,KAAK,GAAG;AAGrC,iBAAW,OAAO,mBAAmB;AACnC,cAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,iBAAiB,EAAE,OAAO,MAAM,WAAW,GAAG,EAAE,CAAC;AACpG,YAAI,sBAAsB;AACxB,iBAAO,SAAS,OAAO,GAAG,kCAAkC,GAAG,eAAe,EAAE,KAAK,GAAG;AACxF,gBAAM,OAAO,MAAM,aAA8B,QAAQ;AACzD,cAAI,MAAM,GAAI,wBAAuB,KAAK,KAAK,EAAE;AAAA,QACnD,OAAO;AACL,iBAAO,SAAS,OAAO,GAAG,kCAAkC,GAAG,mBAAmB,EAAE,KAAK,GAAG;AAAA,QAC9F;AAAA,MACF;AAAA,IACF,UAAE;AACA,YAAM,sBAAsB,SAAS,OAAO,SAAS;AACrD,iBAAW,MAAM,wBAAwB;AACvC,cAAM,sBAAsB,SAAS,OAAO,EAAE;AAAA,MAChD;AAAA,IACF;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import {
|
|
4
|
+
buildMockInboundUrl,
|
|
5
|
+
createWebhookFixture,
|
|
6
|
+
deleteWebhookIfExists,
|
|
7
|
+
getDeliveryDetail,
|
|
8
|
+
sendTestDelivery
|
|
9
|
+
} from "./helpers/fixtures.js";
|
|
10
|
+
const EVENT_TYPE = "webhooks.test.detail";
|
|
11
|
+
test.describe("TC-WEBHOOK-010: Delivery detail full payload and response body", () => {
|
|
12
|
+
test("should expose the complete payload and response details for a delivery", async ({ request }) => {
|
|
13
|
+
const token = await getAuthToken(request);
|
|
14
|
+
let webhookId = null;
|
|
15
|
+
try {
|
|
16
|
+
const created = await createWebhookFixture(request, token, {
|
|
17
|
+
name: `Webhook Detail ${Date.now()}`,
|
|
18
|
+
url: buildMockInboundUrl(),
|
|
19
|
+
subscribedEvents: ["catalog.product.created"],
|
|
20
|
+
customHeaders: { "x-mock-webhook-signature": "valid" }
|
|
21
|
+
});
|
|
22
|
+
webhookId = created.id;
|
|
23
|
+
const eventData = { sku: "TEST-123", nested: { value: 42 } };
|
|
24
|
+
const testResult = await sendTestDelivery(request, token, created.id, {
|
|
25
|
+
eventType: EVENT_TYPE,
|
|
26
|
+
payload: eventData
|
|
27
|
+
});
|
|
28
|
+
expect(testResult.delivery.status).toBe("delivered");
|
|
29
|
+
const deliveryId = testResult.delivery.id;
|
|
30
|
+
const detail = await getDeliveryDetail(request, token, deliveryId);
|
|
31
|
+
expect(detail.id).toBe(deliveryId);
|
|
32
|
+
expect(detail.webhookId).toBe(created.id);
|
|
33
|
+
expect(detail.eventType).toBe(EVENT_TYPE);
|
|
34
|
+
expect(detail.targetUrl).toBe(created.url);
|
|
35
|
+
expect(typeof detail.messageId).toBe("string");
|
|
36
|
+
expect(detail.messageId.length).toBeGreaterThan(0);
|
|
37
|
+
expect(detail.status).toBe("delivered");
|
|
38
|
+
expect(detail.responseStatus).toBe(200);
|
|
39
|
+
expect(detail.errorMessage).toBeNull();
|
|
40
|
+
expect(detail.payload).toMatchObject({
|
|
41
|
+
type: EVENT_TYPE,
|
|
42
|
+
data: { sku: "TEST-123", nested: { value: 42 } }
|
|
43
|
+
});
|
|
44
|
+
const envelopeTimestamp = detail.payload.timestamp;
|
|
45
|
+
expect(typeof envelopeTimestamp).toBe("string");
|
|
46
|
+
expect(Number.isNaN(Date.parse(envelopeTimestamp))).toBe(false);
|
|
47
|
+
expect(detail.responseBody).toBe(JSON.stringify({ ok: true }));
|
|
48
|
+
expect(detail.responseHeaders).not.toBeNull();
|
|
49
|
+
expect(detail.responseHeaders?.["content-type"]).toContain("application/json");
|
|
50
|
+
expect(detail.attemptNumber).toBeGreaterThanOrEqual(1);
|
|
51
|
+
expect(detail.maxAttempts).toBeGreaterThanOrEqual(1);
|
|
52
|
+
expect(typeof detail.durationMs).toBe("number");
|
|
53
|
+
expect(detail.durationMs).toBeGreaterThanOrEqual(0);
|
|
54
|
+
expect(detail.nextRetryAt).toBeNull();
|
|
55
|
+
for (const timestamp of [detail.createdAt, detail.enqueuedAt, detail.lastAttemptAt, detail.deliveredAt, detail.updatedAt]) {
|
|
56
|
+
expect(typeof timestamp).toBe("string");
|
|
57
|
+
expect(Number.isNaN(Date.parse(timestamp))).toBe(false);
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
await deleteWebhookIfExists(request, token, webhookId);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
//# sourceMappingURL=TC-WEBHOOK-010.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/__integration__/TC-WEBHOOK-010.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test';\nimport { getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport {\n buildMockInboundUrl,\n createWebhookFixture,\n deleteWebhookIfExists,\n getDeliveryDetail,\n sendTestDelivery,\n} from './helpers/fixtures';\n\n/**\n * TC-WEBHOOK-010: Delivery detail response includes full payload and response body\n * Source: https://github.com/open-mercato/open-mercato/issues/2482\n *\n * The delivery detail endpoint must expose the complete sent payload and the full\n * response captured from the endpoint (status, body, headers) plus attempt/timing\n * metadata, so admins can debug a delivery end-to-end.\n */\nconst EVENT_TYPE = 'webhooks.test.detail';\n\ntest.describe('TC-WEBHOOK-010: Delivery detail full payload and response body', () => {\n test('should expose the complete payload and response details for a delivery', async ({ request }) => {\n const token = await getAuthToken(request);\n let webhookId: string | null = null;\n\n try {\n const created = await createWebhookFixture(request, token, {\n name: `Webhook Detail ${Date.now()}`,\n url: buildMockInboundUrl(),\n subscribedEvents: ['catalog.product.created'],\n customHeaders: { 'x-mock-webhook-signature': 'valid' },\n });\n webhookId = created.id;\n\n const eventData = { sku: 'TEST-123', nested: { value: 42 } };\n\n const testResult = await sendTestDelivery(request, token, created.id, {\n eventType: EVENT_TYPE,\n payload: eventData,\n });\n expect(testResult.delivery.status).toBe('delivered');\n const deliveryId = testResult.delivery.id;\n\n const detail = await getDeliveryDetail(request, token, deliveryId);\n\n // Identity + routing\n expect(detail.id).toBe(deliveryId);\n expect(detail.webhookId).toBe(created.id);\n expect(detail.eventType).toBe(EVENT_TYPE);\n expect(detail.targetUrl).toBe(created.url);\n expect(typeof detail.messageId).toBe('string');\n expect(detail.messageId.length).toBeGreaterThan(0);\n\n // Outcome\n expect(detail.status).toBe('delivered');\n expect(detail.responseStatus).toBe(200);\n expect(detail.errorMessage).toBeNull();\n\n // The delivery body wraps the event data in the Standard-Webhooks envelope\n // { type, timestamp, data }; the sent payload is preserved under `data`.\n expect(detail.payload).toMatchObject({\n type: EVENT_TYPE,\n data: { sku: 'TEST-123', nested: { value: 42 } },\n });\n const envelopeTimestamp = (detail.payload as { timestamp?: unknown }).timestamp;\n expect(typeof envelopeTimestamp).toBe('string');\n expect(Number.isNaN(Date.parse(envelopeTimestamp as string))).toBe(false);\n\n // Full captured response (the mock receiver replies { ok: true } as JSON)\n expect(detail.responseBody).toBe(JSON.stringify({ ok: true }));\n expect(detail.responseHeaders).not.toBeNull();\n expect(detail.responseHeaders?.['content-type']).toContain('application/json');\n\n // Attempt + timing metadata\n expect(detail.attemptNumber).toBeGreaterThanOrEqual(1);\n expect(detail.maxAttempts).toBeGreaterThanOrEqual(1);\n expect(typeof detail.durationMs).toBe('number');\n expect(detail.durationMs as number).toBeGreaterThanOrEqual(0);\n expect(detail.nextRetryAt).toBeNull();\n\n for (const timestamp of [detail.createdAt, detail.enqueuedAt, detail.lastAttemptAt, detail.deliveredAt, detail.updatedAt]) {\n expect(typeof timestamp).toBe('string');\n expect(Number.isNaN(Date.parse(timestamp as string))).toBe(false);\n }\n } finally {\n await deleteWebhookIfExists(request, token, webhookId);\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAUP,MAAM,aAAa;AAEnB,KAAK,SAAS,kEAAkE,MAAM;AACpF,OAAK,0EAA0E,OAAO,EAAE,QAAQ,MAAM;AACpG,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,QAAI,YAA2B;AAE/B,QAAI;AACF,YAAM,UAAU,MAAM,qBAAqB,SAAS,OAAO;AAAA,QACzD,MAAM,kBAAkB,KAAK,IAAI,CAAC;AAAA,QAClC,KAAK,oBAAoB;AAAA,QACzB,kBAAkB,CAAC,yBAAyB;AAAA,QAC5C,eAAe,EAAE,4BAA4B,QAAQ;AAAA,MACvD,CAAC;AACD,kBAAY,QAAQ;AAEpB,YAAM,YAAY,EAAE,KAAK,YAAY,QAAQ,EAAE,OAAO,GAAG,EAAE;AAE3D,YAAM,aAAa,MAAM,iBAAiB,SAAS,OAAO,QAAQ,IAAI;AAAA,QACpE,WAAW;AAAA,QACX,SAAS;AAAA,MACX,CAAC;AACD,aAAO,WAAW,SAAS,MAAM,EAAE,KAAK,WAAW;AACnD,YAAM,aAAa,WAAW,SAAS;AAEvC,YAAM,SAAS,MAAM,kBAAkB,SAAS,OAAO,UAAU;AAGjE,aAAO,OAAO,EAAE,EAAE,KAAK,UAAU;AACjC,aAAO,OAAO,SAAS,EAAE,KAAK,QAAQ,EAAE;AACxC,aAAO,OAAO,SAAS,EAAE,KAAK,UAAU;AACxC,aAAO,OAAO,SAAS,EAAE,KAAK,QAAQ,GAAG;AACzC,aAAO,OAAO,OAAO,SAAS,EAAE,KAAK,QAAQ;AAC7C,aAAO,OAAO,UAAU,MAAM,EAAE,gBAAgB,CAAC;AAGjD,aAAO,OAAO,MAAM,EAAE,KAAK,WAAW;AACtC,aAAO,OAAO,cAAc,EAAE,KAAK,GAAG;AACtC,aAAO,OAAO,YAAY,EAAE,SAAS;AAIrC,aAAO,OAAO,OAAO,EAAE,cAAc;AAAA,QACnC,MAAM;AAAA,QACN,MAAM,EAAE,KAAK,YAAY,QAAQ,EAAE,OAAO,GAAG,EAAE;AAAA,MACjD,CAAC;AACD,YAAM,oBAAqB,OAAO,QAAoC;AACtE,aAAO,OAAO,iBAAiB,EAAE,KAAK,QAAQ;AAC9C,aAAO,OAAO,MAAM,KAAK,MAAM,iBAA2B,CAAC,CAAC,EAAE,KAAK,KAAK;AAGxE,aAAO,OAAO,YAAY,EAAE,KAAK,KAAK,UAAU,EAAE,IAAI,KAAK,CAAC,CAAC;AAC7D,aAAO,OAAO,eAAe,EAAE,IAAI,SAAS;AAC5C,aAAO,OAAO,kBAAkB,cAAc,CAAC,EAAE,UAAU,kBAAkB;AAG7E,aAAO,OAAO,aAAa,EAAE,uBAAuB,CAAC;AACrD,aAAO,OAAO,WAAW,EAAE,uBAAuB,CAAC;AACnD,aAAO,OAAO,OAAO,UAAU,EAAE,KAAK,QAAQ;AAC9C,aAAO,OAAO,UAAoB,EAAE,uBAAuB,CAAC;AAC5D,aAAO,OAAO,WAAW,EAAE,SAAS;AAEpC,iBAAW,aAAa,CAAC,OAAO,WAAW,OAAO,YAAY,OAAO,eAAe,OAAO,aAAa,OAAO,SAAS,GAAG;AACzH,eAAO,OAAO,SAAS,EAAE,KAAK,QAAQ;AACtC,eAAO,OAAO,MAAM,KAAK,MAAM,SAAmB,CAAC,CAAC,EAAE,KAAK,KAAK;AAAA,MAClE;AAAA,IACF,UAAE;AACA,YAAM,sBAAsB,SAAS,OAAO,SAAS;AAAA,IACvD;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { expect } from "@playwright/test";
|
|
2
|
-
import { apiRequest } from "@open-mercato/core/helpers/integration/api";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
3
|
import { expectId, readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import { createUserFixture, deleteUserIfExists } from "@open-mercato/core/helpers/integration/authFixtures";
|
|
5
|
+
import { deleteUserAclInDb, setUserAclInDb } from "@open-mercato/core/helpers/integration/dbFixtures";
|
|
4
6
|
const BASE_URL = process.env.BASE_URL?.trim() || "http://localhost:3000";
|
|
5
7
|
function buildMockInboundUrl(endpointId = "mock_inbound") {
|
|
6
8
|
return `${BASE_URL}/api/webhooks/inbound/${endpointId}`;
|
|
@@ -96,8 +98,52 @@ async function deleteWebhookIfExists(request, token, webhookId) {
|
|
|
96
98
|
return;
|
|
97
99
|
}
|
|
98
100
|
}
|
|
101
|
+
async function rotateWebhookSecret(request, token, webhookId, path = `/api/webhooks/${webhookId}/rotate-secret`) {
|
|
102
|
+
const response = await apiRequest(request, "POST", path, { token });
|
|
103
|
+
expect(response.status()).toBe(200);
|
|
104
|
+
const body = await readJsonSafe(response);
|
|
105
|
+
if (!body) {
|
|
106
|
+
throw new Error(`Expected rotate-secret response for ${webhookId}`);
|
|
107
|
+
}
|
|
108
|
+
return body;
|
|
109
|
+
}
|
|
110
|
+
async function sendTestDelivery(request, token, webhookId, input = {}, path = `/api/webhooks/${webhookId}/test`) {
|
|
111
|
+
const response = await apiRequest(request, "POST", path, { token, data: input });
|
|
112
|
+
expect(response.status()).toBe(200);
|
|
113
|
+
const body = await readJsonSafe(response);
|
|
114
|
+
if (!body) {
|
|
115
|
+
throw new Error(`Expected test delivery response for ${webhookId}`);
|
|
116
|
+
}
|
|
117
|
+
return body;
|
|
118
|
+
}
|
|
119
|
+
async function provisionWebhooksUser(request, adminToken, input) {
|
|
120
|
+
const unique = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
|
121
|
+
const email = `qa-webhooks-${input.slug}-${unique}@acme.com`;
|
|
122
|
+
const password = "Valid1!Webhooks";
|
|
123
|
+
const userId = await createUserFixture(request, adminToken, {
|
|
124
|
+
email,
|
|
125
|
+
password,
|
|
126
|
+
organizationId: input.organizationId,
|
|
127
|
+
roles: [],
|
|
128
|
+
name: `QA Webhooks ${input.slug}`
|
|
129
|
+
});
|
|
130
|
+
await setUserAclInDb({
|
|
131
|
+
userId,
|
|
132
|
+
tenantId: input.tenantId,
|
|
133
|
+
features: input.features,
|
|
134
|
+
organizations: input.organizations ?? null
|
|
135
|
+
});
|
|
136
|
+
const token = await getAuthToken(request, email, password);
|
|
137
|
+
return { userId, email, password, token };
|
|
138
|
+
}
|
|
139
|
+
async function cleanupWebhooksUser(request, adminToken, user) {
|
|
140
|
+
if (!user) return;
|
|
141
|
+
await deleteUserAclInDb(user.userId).catch(() => void 0);
|
|
142
|
+
await deleteUserIfExists(request, adminToken, user.userId).catch(() => void 0);
|
|
143
|
+
}
|
|
99
144
|
export {
|
|
100
145
|
buildMockInboundUrl,
|
|
146
|
+
cleanupWebhooksUser,
|
|
101
147
|
createWebhookFixture,
|
|
102
148
|
deleteWebhookIfExists,
|
|
103
149
|
getDeliveryDetail,
|
|
@@ -105,6 +151,9 @@ export {
|
|
|
105
151
|
listWebhookDeliveries,
|
|
106
152
|
listWebhookEvents,
|
|
107
153
|
listWebhooks,
|
|
154
|
+
provisionWebhooksUser,
|
|
155
|
+
rotateWebhookSecret,
|
|
156
|
+
sendTestDelivery,
|
|
108
157
|
waitForDeliveryStatus
|
|
109
158
|
};
|
|
110
159
|
//# sourceMappingURL=fixtures.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/webhooks/__integration__/helpers/fixtures.ts"],
|
|
4
|
-
"sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest } from '@open-mercato/core/helpers/integration/api';\nimport { expectId, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\n\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\n\nexport type WebhookCreateResponse = {\n id: string;\n name: string;\n url: string;\n secret: string;\n subscribedEvents: string[];\n isActive: boolean;\n};\n\nexport type WebhookDetailResponse = {\n id: string;\n name: string;\n description: string | null;\n url: string;\n subscribedEvents: string[];\n httpMethod: 'POST' | 'PUT' | 'PATCH';\n isActive: boolean;\n deliveryStrategy: string;\n maxRetries: number;\n consecutiveFailures: number;\n lastSuccessAt: string | null;\n lastFailureAt: string | null;\n createdAt: string;\n updatedAt: string;\n customHeaders: Record<string, string> | null;\n strategyConfig: Record<string, unknown> | null;\n timeoutMs: number;\n rateLimitPerMinute: number;\n autoDisableThreshold: number;\n integrationId: string | null;\n maskedSecret: string;\n previousSecretSetAt: string | null;\n};\n\nexport type WebhookDeliveryDetailResponse = {\n id: string;\n webhookId: string;\n eventType: string;\n messageId: string;\n status: string;\n responseStatus: number | null;\n errorMessage: string | null;\n attemptNumber: number;\n maxAttempts: number;\n targetUrl: string;\n durationMs: number | null;\n enqueuedAt: string;\n lastAttemptAt: string | null;\n deliveredAt: string | null;\n createdAt: string;\n payload: Record<string, unknown>;\n responseBody: string | null;\n responseHeaders: Record<string, string> | null;\n nextRetryAt: string | null;\n updatedAt: string;\n};\n\ntype DeliveryListResponse = {\n items: Array<{\n id: string;\n webhookId: string;\n webhookName: string | null;\n eventType: string;\n messageId: string;\n status: string;\n responseStatus: number | null;\n errorMessage: string | null;\n attemptNumber: number;\n maxAttempts: number;\n targetUrl: string;\n durationMs: number | null;\n enqueuedAt: string;\n lastAttemptAt: string | null;\n deliveredAt: string | null;\n createdAt: string;\n }>;\n total: number;\n page: number;\n pageSize: number;\n totalPages: number;\n};\n\ntype EventsResponse = {\n data: Array<{\n id: string;\n label: string;\n module?: string;\n category?: string;\n description?: string;\n }>;\n total: number;\n};\n\nexport function buildMockInboundUrl(endpointId = 'mock_inbound'): string {\n return `${BASE_URL}/api/webhooks/inbound/${endpointId}`;\n}\n\nexport async function createWebhookFixture(\n request: APIRequestContext,\n token: string,\n overrides: Partial<{\n name: string;\n description: string | null;\n url: string;\n subscribedEvents: string[];\n httpMethod: 'POST' | 'PUT' | 'PATCH';\n maxRetries: number;\n timeoutMs: number;\n rateLimitPerMinute: number;\n autoDisableThreshold: number;\n customHeaders: Record<string, string> | null;\n }> = {},\n): Promise<WebhookCreateResponse> {\n const response = await apiRequest(request, 'POST', '/api/webhooks', {\n token,\n data: {\n name: overrides.name ?? `Webhook ${Date.now()}`,\n description: overrides.description ?? 'Integration test webhook',\n url: overrides.url ?? buildMockInboundUrl(),\n subscribedEvents: overrides.subscribedEvents ?? ['catalog.product.created'],\n httpMethod: overrides.httpMethod ?? 'POST',\n maxRetries: overrides.maxRetries ?? 3,\n timeoutMs: overrides.timeoutMs ?? 5_000,\n rateLimitPerMinute: overrides.rateLimitPerMinute ?? 0,\n autoDisableThreshold: overrides.autoDisableThreshold ?? 5,\n customHeaders: overrides.customHeaders ?? null,\n },\n });\n\n expect(response.status()).toBe(201);\n const body = await readJsonSafe<WebhookCreateResponse>(response);\n if (!body) {\n throw new Error('Expected webhook create response body');\n }\n\n expectId(body.id, 'Expected webhook id');\n expect(typeof body.secret).toBe('string');\n expect(body.secret.startsWith('whsec_')).toBe(true);\n\n return body;\n}\n\nexport async function getWebhookDetail(\n request: APIRequestContext,\n token: string,\n webhookId: string,\n path = `/api/webhooks/${webhookId}`,\n): Promise<WebhookDetailResponse> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<WebhookDetailResponse>(response);\n if (!body) {\n throw new Error(`Expected webhook detail response for ${webhookId}`);\n }\n return body;\n}\n\nexport async function listWebhooks(\n request: APIRequestContext,\n token: string,\n path = '/api/webhooks',\n): Promise<{ items: Array<{ id: string; name: string; url: string }>; total: number }> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<{ items: Array<{ id: string; name: string; url: string }>; total: number }>(response);\n if (!body) {\n throw new Error('Expected webhook list response');\n }\n return body;\n}\n\nexport async function listWebhookEvents(request: APIRequestContext, token: string): Promise<EventsResponse> {\n const response = await apiRequest(request, 'GET', '/api/webhooks/events', { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<EventsResponse>(response);\n if (!body) {\n throw new Error('Expected webhook events response');\n }\n return body;\n}\n\nexport async function getDeliveryDetail(\n request: APIRequestContext,\n token: string,\n deliveryId: string,\n path = `/api/webhooks/deliveries/${deliveryId}`,\n): Promise<WebhookDeliveryDetailResponse> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<WebhookDeliveryDetailResponse>(response);\n if (!body) {\n throw new Error(`Expected webhook delivery detail for ${deliveryId}`);\n }\n return body;\n}\n\nexport async function listWebhookDeliveries(\n request: APIRequestContext,\n token: string,\n webhookId: string,\n path = `/api/webhooks/deliveries?webhookId=${encodeURIComponent(webhookId)}`,\n): Promise<DeliveryListResponse> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<DeliveryListResponse>(response);\n if (!body) {\n throw new Error(`Expected webhook deliveries for ${webhookId}`);\n }\n return body;\n}\n\nexport async function waitForDeliveryStatus(\n request: APIRequestContext,\n token: string,\n deliveryId: string,\n expectedStatus: string,\n): Promise<WebhookDeliveryDetailResponse> {\n for (let attempt = 0; attempt < 30; attempt += 1) {\n const detail = await getDeliveryDetail(request, token, deliveryId);\n if (detail.status === expectedStatus) {\n return detail;\n }\n await new Promise((resolve) => setTimeout(resolve, 200));\n }\n\n throw new Error(`Timed out waiting for delivery ${deliveryId} to reach status ${expectedStatus}`);\n}\n\nexport async function deleteWebhookIfExists(\n request: APIRequestContext,\n token: string | null,\n webhookId: string | null,\n): Promise<void> {\n if (!token || !webhookId) {\n return;\n }\n\n try {\n await apiRequest(request, 'DELETE', `/api/webhooks/${webhookId}`, { token });\n } catch {\n return;\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,cAAsC;AAC/C,SAAS,
|
|
4
|
+
"sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { expectId, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport { createUserFixture, deleteUserIfExists } from '@open-mercato/core/helpers/integration/authFixtures';\nimport { deleteUserAclInDb, setUserAclInDb } from '@open-mercato/core/helpers/integration/dbFixtures';\n\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\n\nexport type WebhookCreateResponse = {\n id: string;\n name: string;\n url: string;\n secret: string;\n subscribedEvents: string[];\n isActive: boolean;\n};\n\nexport type WebhookDetailResponse = {\n id: string;\n name: string;\n description: string | null;\n url: string;\n subscribedEvents: string[];\n httpMethod: 'POST' | 'PUT' | 'PATCH';\n isActive: boolean;\n deliveryStrategy: string;\n maxRetries: number;\n consecutiveFailures: number;\n lastSuccessAt: string | null;\n lastFailureAt: string | null;\n createdAt: string;\n updatedAt: string;\n customHeaders: Record<string, string> | null;\n strategyConfig: Record<string, unknown> | null;\n timeoutMs: number;\n rateLimitPerMinute: number;\n autoDisableThreshold: number;\n integrationId: string | null;\n maskedSecret: string;\n previousSecretSetAt: string | null;\n};\n\nexport type WebhookDeliveryDetailResponse = {\n id: string;\n webhookId: string;\n eventType: string;\n messageId: string;\n status: string;\n responseStatus: number | null;\n errorMessage: string | null;\n attemptNumber: number;\n maxAttempts: number;\n targetUrl: string;\n durationMs: number | null;\n enqueuedAt: string;\n lastAttemptAt: string | null;\n deliveredAt: string | null;\n createdAt: string;\n payload: Record<string, unknown>;\n responseBody: string | null;\n responseHeaders: Record<string, string> | null;\n nextRetryAt: string | null;\n updatedAt: string;\n};\n\ntype DeliveryListResponse = {\n items: Array<{\n id: string;\n webhookId: string;\n webhookName: string | null;\n eventType: string;\n messageId: string;\n status: string;\n responseStatus: number | null;\n errorMessage: string | null;\n attemptNumber: number;\n maxAttempts: number;\n targetUrl: string;\n durationMs: number | null;\n enqueuedAt: string;\n lastAttemptAt: string | null;\n deliveredAt: string | null;\n createdAt: string;\n }>;\n total: number;\n page: number;\n pageSize: number;\n totalPages: number;\n};\n\ntype EventsResponse = {\n data: Array<{\n id: string;\n label: string;\n module?: string;\n category?: string;\n description?: string;\n }>;\n total: number;\n};\n\nexport function buildMockInboundUrl(endpointId = 'mock_inbound'): string {\n return `${BASE_URL}/api/webhooks/inbound/${endpointId}`;\n}\n\nexport async function createWebhookFixture(\n request: APIRequestContext,\n token: string,\n overrides: Partial<{\n name: string;\n description: string | null;\n url: string;\n subscribedEvents: string[];\n httpMethod: 'POST' | 'PUT' | 'PATCH';\n maxRetries: number;\n timeoutMs: number;\n rateLimitPerMinute: number;\n autoDisableThreshold: number;\n customHeaders: Record<string, string> | null;\n }> = {},\n): Promise<WebhookCreateResponse> {\n const response = await apiRequest(request, 'POST', '/api/webhooks', {\n token,\n data: {\n name: overrides.name ?? `Webhook ${Date.now()}`,\n description: overrides.description ?? 'Integration test webhook',\n url: overrides.url ?? buildMockInboundUrl(),\n subscribedEvents: overrides.subscribedEvents ?? ['catalog.product.created'],\n httpMethod: overrides.httpMethod ?? 'POST',\n maxRetries: overrides.maxRetries ?? 3,\n timeoutMs: overrides.timeoutMs ?? 5_000,\n rateLimitPerMinute: overrides.rateLimitPerMinute ?? 0,\n autoDisableThreshold: overrides.autoDisableThreshold ?? 5,\n customHeaders: overrides.customHeaders ?? null,\n },\n });\n\n expect(response.status()).toBe(201);\n const body = await readJsonSafe<WebhookCreateResponse>(response);\n if (!body) {\n throw new Error('Expected webhook create response body');\n }\n\n expectId(body.id, 'Expected webhook id');\n expect(typeof body.secret).toBe('string');\n expect(body.secret.startsWith('whsec_')).toBe(true);\n\n return body;\n}\n\nexport async function getWebhookDetail(\n request: APIRequestContext,\n token: string,\n webhookId: string,\n path = `/api/webhooks/${webhookId}`,\n): Promise<WebhookDetailResponse> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<WebhookDetailResponse>(response);\n if (!body) {\n throw new Error(`Expected webhook detail response for ${webhookId}`);\n }\n return body;\n}\n\nexport async function listWebhooks(\n request: APIRequestContext,\n token: string,\n path = '/api/webhooks',\n): Promise<{ items: Array<{ id: string; name: string; url: string }>; total: number }> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<{ items: Array<{ id: string; name: string; url: string }>; total: number }>(response);\n if (!body) {\n throw new Error('Expected webhook list response');\n }\n return body;\n}\n\nexport async function listWebhookEvents(request: APIRequestContext, token: string): Promise<EventsResponse> {\n const response = await apiRequest(request, 'GET', '/api/webhooks/events', { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<EventsResponse>(response);\n if (!body) {\n throw new Error('Expected webhook events response');\n }\n return body;\n}\n\nexport async function getDeliveryDetail(\n request: APIRequestContext,\n token: string,\n deliveryId: string,\n path = `/api/webhooks/deliveries/${deliveryId}`,\n): Promise<WebhookDeliveryDetailResponse> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<WebhookDeliveryDetailResponse>(response);\n if (!body) {\n throw new Error(`Expected webhook delivery detail for ${deliveryId}`);\n }\n return body;\n}\n\nexport async function listWebhookDeliveries(\n request: APIRequestContext,\n token: string,\n webhookId: string,\n path = `/api/webhooks/deliveries?webhookId=${encodeURIComponent(webhookId)}`,\n): Promise<DeliveryListResponse> {\n const response = await apiRequest(request, 'GET', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<DeliveryListResponse>(response);\n if (!body) {\n throw new Error(`Expected webhook deliveries for ${webhookId}`);\n }\n return body;\n}\n\nexport async function waitForDeliveryStatus(\n request: APIRequestContext,\n token: string,\n deliveryId: string,\n expectedStatus: string,\n): Promise<WebhookDeliveryDetailResponse> {\n for (let attempt = 0; attempt < 30; attempt += 1) {\n const detail = await getDeliveryDetail(request, token, deliveryId);\n if (detail.status === expectedStatus) {\n return detail;\n }\n await new Promise((resolve) => setTimeout(resolve, 200));\n }\n\n throw new Error(`Timed out waiting for delivery ${deliveryId} to reach status ${expectedStatus}`);\n}\n\nexport async function deleteWebhookIfExists(\n request: APIRequestContext,\n token: string | null,\n webhookId: string | null,\n): Promise<void> {\n if (!token || !webhookId) {\n return;\n }\n\n try {\n await apiRequest(request, 'DELETE', `/api/webhooks/${webhookId}`, { token });\n } catch {\n return;\n }\n}\n\nexport type WebhookRotateSecretResponse = {\n success: true;\n secret: string;\n previousSecretSetAt: string | null;\n};\n\nexport async function rotateWebhookSecret(\n request: APIRequestContext,\n token: string,\n webhookId: string,\n path = `/api/webhooks/${webhookId}/rotate-secret`,\n): Promise<WebhookRotateSecretResponse> {\n const response = await apiRequest(request, 'POST', path, { token });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<WebhookRotateSecretResponse>(response);\n if (!body) {\n throw new Error(`Expected rotate-secret response for ${webhookId}`);\n }\n return body;\n}\n\nexport type WebhookTestDeliveryResponse = {\n success: true;\n delivery: WebhookDeliveryDetailResponse;\n};\n\nexport async function sendTestDelivery(\n request: APIRequestContext,\n token: string,\n webhookId: string,\n input: { eventType?: string; payload?: Record<string, unknown> } = {},\n path = `/api/webhooks/${webhookId}/test`,\n): Promise<WebhookTestDeliveryResponse> {\n const response = await apiRequest(request, 'POST', path, { token, data: input });\n expect(response.status()).toBe(200);\n const body = await readJsonSafe<WebhookTestDeliveryResponse>(response);\n if (!body) {\n throw new Error(`Expected test delivery response for ${webhookId}`);\n }\n return body;\n}\n\nexport type ProvisionedWebhooksUser = {\n userId: string;\n email: string;\n password: string;\n token: string;\n};\n\n/**\n * Creates a self-contained, role-less user and grants it an exact webhooks feature\n * set (and optional organization-visibility list) through a per-user ACL row, then\n * logs it in. The DB ACL path mirrors the org-scope fixtures: granting features via\n * the ACL API requires a super-admin actor, while `setUserAclInDb` keeps the spec\n * self-contained and deterministic. Login happens AFTER the ACL is written so the\n * fresh session resolves the new grants.\n */\nexport async function provisionWebhooksUser(\n request: APIRequestContext,\n adminToken: string,\n input: {\n tenantId: string;\n organizationId: string;\n features: string[];\n organizations?: string[] | null;\n slug: string;\n },\n): Promise<ProvisionedWebhooksUser> {\n const unique = `${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;\n const email = `qa-webhooks-${input.slug}-${unique}@acme.com`;\n const password = 'Valid1!Webhooks';\n const userId = await createUserFixture(request, adminToken, {\n email,\n password,\n organizationId: input.organizationId,\n roles: [],\n name: `QA Webhooks ${input.slug}`,\n });\n await setUserAclInDb({\n userId,\n tenantId: input.tenantId,\n features: input.features,\n organizations: input.organizations ?? null,\n });\n const token = await getAuthToken(request, email, password);\n return { userId, email, password, token };\n}\n\nexport async function cleanupWebhooksUser(\n request: APIRequestContext,\n adminToken: string | null,\n user: ProvisionedWebhooksUser | null,\n): Promise<void> {\n if (!user) return;\n await deleteUserAclInDb(user.userId).catch(() => undefined);\n await deleteUserIfExists(request, adminToken, user.userId).catch(() => undefined);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -3,6 +3,8 @@ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
|
3
3
|
import { emitWebhooksEvent } from "../../../events.js";
|
|
4
4
|
import { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from "../../helpers.js";
|
|
5
5
|
import { webhookUpdateSchema } from "../../../data/validators.js";
|
|
6
|
+
import { enforceCommandOptimisticLock } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
|
|
7
|
+
import { isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
6
8
|
const metadata = {
|
|
7
9
|
GET: { requireAuth: true, requireFeatures: ["webhooks.view"] },
|
|
8
10
|
PUT: { requireAuth: true, requireFeatures: ["webhooks.manage"] },
|
|
@@ -53,6 +55,17 @@ async function PUT(request, context) {
|
|
|
53
55
|
if (!webhook) {
|
|
54
56
|
return json({ error: "Webhook not found" }, { status: 404 });
|
|
55
57
|
}
|
|
58
|
+
try {
|
|
59
|
+
enforceCommandOptimisticLock({
|
|
60
|
+
resourceKind: "webhooks.endpoint",
|
|
61
|
+
resourceId: webhook.id,
|
|
62
|
+
current: webhook.updatedAt ?? null,
|
|
63
|
+
request
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (isCrudHttpError(err)) return json(err.body, { status: err.status });
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
56
69
|
const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null));
|
|
57
70
|
if (!parsed.success) {
|
|
58
71
|
return json({ error: "Invalid request payload" }, { status: 400 });
|
|
@@ -90,6 +103,17 @@ async function DELETE(request, context) {
|
|
|
90
103
|
if (!webhook) {
|
|
91
104
|
return json({ error: translate("webhooks.errors.notFound", "Webhook not found") }, { status: 404 });
|
|
92
105
|
}
|
|
106
|
+
try {
|
|
107
|
+
enforceCommandOptimisticLock({
|
|
108
|
+
resourceKind: "webhooks.endpoint",
|
|
109
|
+
resourceId: webhook.id,
|
|
110
|
+
current: webhook.updatedAt ?? null,
|
|
111
|
+
request
|
|
112
|
+
});
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (isCrudHttpError(err)) return json(err.body, { status: err.status });
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
93
117
|
webhook.deletedAt = /* @__PURE__ */ new Date();
|
|
94
118
|
await em.flush();
|
|
95
119
|
await emitWebhooksEvent("webhooks.webhook.deleted", {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/webhooks/api/webhooks/%5Bid%5D/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { emitWebhooksEvent } from '../../../events'\nimport { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from '../../helpers'\nimport { webhookUpdateSchema } from '../../../data/validators'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },\n PUT: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nconst webhookDetailResponseSchema = z.object({\n id: z.string(),\n name: z.string(),\n description: z.string().nullable(),\n url: z.string(),\n subscribedEvents: z.array(z.string()),\n httpMethod: z.string(),\n isActive: z.boolean(),\n deliveryStrategy: z.string(),\n maxRetries: z.number(),\n consecutiveFailures: z.number(),\n lastSuccessAt: z.string().nullable(),\n lastFailureAt: z.string().nullable(),\n createdAt: z.string(),\n updatedAt: z.string(),\n customHeaders: z.record(z.string(), z.string()).nullable(),\n strategyConfig: z.record(z.string(), z.unknown()).nullable(),\n timeoutMs: z.number(),\n rateLimitPerMinute: z.number(),\n autoDisableThreshold: z.number(),\n integrationId: z.string().nullable(),\n maskedSecret: z.string(),\n previousSecretSetAt: z.string().nullable(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\nconst deleteResponseSchema = z.object({ success: z.literal(true) })\n\nexport async function GET(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const webhook = await findScopedWebhook(scope.em.fork(), scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n return json(serializeWebhookDetail(webhook))\n}\n\nexport async function PUT(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null))\n if (!parsed.success) {\n return json({ error: 'Invalid request payload' }, { status: 400 })\n }\n\n const input = parsed.data\n if (input.name !== undefined) webhook.name = input.name\n if (input.description !== undefined) webhook.description = input.description\n if (input.url !== undefined) webhook.url = input.url\n if (input.subscribedEvents !== undefined) webhook.subscribedEvents = input.subscribedEvents\n if (input.httpMethod !== undefined) webhook.httpMethod = input.httpMethod\n if (input.customHeaders !== undefined) webhook.customHeaders = input.customHeaders\n if (input.deliveryStrategy !== undefined) webhook.deliveryStrategy = input.deliveryStrategy\n if (input.strategyConfig !== undefined) webhook.strategyConfig = input.strategyConfig\n if (input.maxRetries !== undefined) webhook.maxRetries = input.maxRetries\n if (input.timeoutMs !== undefined) webhook.timeoutMs = input.timeoutMs\n if (input.rateLimitPerMinute !== undefined) webhook.rateLimitPerMinute = input.rateLimitPerMinute\n if (input.autoDisableThreshold !== undefined) webhook.autoDisableThreshold = input.autoDisableThreshold\n if (input.integrationId !== undefined) webhook.integrationId = input.integrationId\n if (input.isActive !== undefined) webhook.isActive = input.isActive\n\n await em.flush()\n\n await emitWebhooksEvent('webhooks.webhook.updated', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n }, { persistent: true })\n\n return json(serializeWebhookDetail(webhook))\n}\n\nexport async function DELETE(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n const { translate } = await resolveTranslations()\n\n if (!webhook) {\n return json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })\n }\n\n webhook.deletedAt = new Date()\n await em.flush()\n\n await emitWebhooksEvent('webhooks.webhook.deleted', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n }, { persistent: true })\n\n return json({ success: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Get webhook detail',\n description: 'Returns a single webhook configuration for the current tenant scope.',\n methods: {\n GET: {\n summary: 'Get webhook',\n description: 'Returns webhook configuration, masked secret metadata, and delivery settings.',\n pathParams: z.object({ id: z.string().uuid() }),\n responses: [{ status: 200, description: 'Webhook detail', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n PUT: {\n summary: 'Update webhook',\n description: 'Updates a single webhook configuration.',\n pathParams: z.object({ id: z.string().uuid() }),\n requestBody: {\n contentType: 'application/json',\n schema: webhookUpdateSchema,\n description: 'Webhook fields to update.',\n },\n responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete webhook',\n description: 'Soft-deletes a webhook endpoint.',\n pathParams: z.object({ id: z.string().uuid() }),\n responses: [{ status: 200, description: 'Webhook deleted', schema: deleteResponseSchema }],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAElB,SAAS,2BAA2B;AACpC,SAAS,yBAAyB;AAClC,SAAS,mBAAmB,MAAM,4BAA4B,8BAA8B;AAC5F,SAAS,2BAA2B;
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { emitWebhooksEvent } from '../../../events'\nimport { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from '../../helpers'\nimport { webhookUpdateSchema } from '../../../data/validators'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },\n PUT: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nconst webhookDetailResponseSchema = z.object({\n id: z.string(),\n name: z.string(),\n description: z.string().nullable(),\n url: z.string(),\n subscribedEvents: z.array(z.string()),\n httpMethod: z.string(),\n isActive: z.boolean(),\n deliveryStrategy: z.string(),\n maxRetries: z.number(),\n consecutiveFailures: z.number(),\n lastSuccessAt: z.string().nullable(),\n lastFailureAt: z.string().nullable(),\n createdAt: z.string(),\n updatedAt: z.string(),\n customHeaders: z.record(z.string(), z.string()).nullable(),\n strategyConfig: z.record(z.string(), z.unknown()).nullable(),\n timeoutMs: z.number(),\n rateLimitPerMinute: z.number(),\n autoDisableThreshold: z.number(),\n integrationId: z.string().nullable(),\n maskedSecret: z.string(),\n previousSecretSetAt: z.string().nullable(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\nconst deleteResponseSchema = z.object({ success: z.literal(true) })\n\nexport async function GET(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const webhook = await findScopedWebhook(scope.em.fork(), scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n return json(serializeWebhookDetail(webhook))\n}\n\nexport async function PUT(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n try {\n enforceCommandOptimisticLock({\n resourceKind: 'webhooks.endpoint',\n resourceId: webhook.id,\n current: webhook.updatedAt ?? null,\n request,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return json(err.body, { status: err.status })\n throw err\n }\n\n const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null))\n if (!parsed.success) {\n return json({ error: 'Invalid request payload' }, { status: 400 })\n }\n\n const input = parsed.data\n if (input.name !== undefined) webhook.name = input.name\n if (input.description !== undefined) webhook.description = input.description\n if (input.url !== undefined) webhook.url = input.url\n if (input.subscribedEvents !== undefined) webhook.subscribedEvents = input.subscribedEvents\n if (input.httpMethod !== undefined) webhook.httpMethod = input.httpMethod\n if (input.customHeaders !== undefined) webhook.customHeaders = input.customHeaders\n if (input.deliveryStrategy !== undefined) webhook.deliveryStrategy = input.deliveryStrategy\n if (input.strategyConfig !== undefined) webhook.strategyConfig = input.strategyConfig\n if (input.maxRetries !== undefined) webhook.maxRetries = input.maxRetries\n if (input.timeoutMs !== undefined) webhook.timeoutMs = input.timeoutMs\n if (input.rateLimitPerMinute !== undefined) webhook.rateLimitPerMinute = input.rateLimitPerMinute\n if (input.autoDisableThreshold !== undefined) webhook.autoDisableThreshold = input.autoDisableThreshold\n if (input.integrationId !== undefined) webhook.integrationId = input.integrationId\n if (input.isActive !== undefined) webhook.isActive = input.isActive\n\n await em.flush()\n\n await emitWebhooksEvent('webhooks.webhook.updated', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n }, { persistent: true })\n\n return json(serializeWebhookDetail(webhook))\n}\n\nexport async function DELETE(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n const { translate } = await resolveTranslations()\n\n if (!webhook) {\n return json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })\n }\n\n try {\n enforceCommandOptimisticLock({\n resourceKind: 'webhooks.endpoint',\n resourceId: webhook.id,\n current: webhook.updatedAt ?? null,\n request,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return json(err.body, { status: err.status })\n throw err\n }\n\n webhook.deletedAt = new Date()\n await em.flush()\n\n await emitWebhooksEvent('webhooks.webhook.deleted', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n }, { persistent: true })\n\n return json({ success: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Get webhook detail',\n description: 'Returns a single webhook configuration for the current tenant scope.',\n methods: {\n GET: {\n summary: 'Get webhook',\n description: 'Returns webhook configuration, masked secret metadata, and delivery settings.',\n pathParams: z.object({ id: z.string().uuid() }),\n responses: [{ status: 200, description: 'Webhook detail', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n PUT: {\n summary: 'Update webhook',\n description: 'Updates a single webhook configuration.',\n pathParams: z.object({ id: z.string().uuid() }),\n requestBody: {\n contentType: 'application/json',\n schema: webhookUpdateSchema,\n description: 'Webhook fields to update.',\n },\n responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete webhook',\n description: 'Soft-deletes a webhook endpoint.',\n pathParams: z.object({ id: z.string().uuid() }),\n responses: [{ status: 200, description: 'Webhook deleted', schema: deleteResponseSchema }],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -16,6 +16,8 @@ import { FormHeader } from "@open-mercato/ui/backend/forms";
|
|
|
16
16
|
import { RowActions } from "@open-mercato/ui/backend/RowActions";
|
|
17
17
|
import { CrudForm } from "@open-mercato/ui/backend/CrudForm";
|
|
18
18
|
import { deleteCrud, updateCrud } from "@open-mercato/ui/backend/utils/crud";
|
|
19
|
+
import { buildOptimisticLockHeader } from "@open-mercato/ui/backend/utils/optimisticLock";
|
|
20
|
+
import { surfaceRecordConflict } from "@open-mercato/ui/backend/conflicts";
|
|
19
21
|
import { Alert, AlertDescription } from "@open-mercato/ui/primitives/alert";
|
|
20
22
|
import {
|
|
21
23
|
buildWebhookFormContentHeader,
|
|
@@ -268,10 +270,14 @@ function WebhookDetailPage() {
|
|
|
268
270
|
const handleDelete = React.useCallback(async () => {
|
|
269
271
|
if (!webhook) return;
|
|
270
272
|
try {
|
|
271
|
-
await deleteCrud(`webhooks/${encodeURIComponent(webhook.id)}`, {
|
|
273
|
+
await deleteCrud(`webhooks/${encodeURIComponent(webhook.id)}`, {
|
|
274
|
+
fallbackResult: null,
|
|
275
|
+
headers: buildOptimisticLockHeader(webhook.updatedAt)
|
|
276
|
+
});
|
|
272
277
|
flash(t("webhooks.list.deleteSuccess"), "success");
|
|
273
278
|
router.push("/backend/webhooks");
|
|
274
|
-
} catch {
|
|
279
|
+
} catch (error2) {
|
|
280
|
+
if (surfaceRecordConflict(error2, t)) return;
|
|
275
281
|
flash(t("webhooks.list.deleteError"), "error");
|
|
276
282
|
}
|
|
277
283
|
}, [router, t, webhook]);
|
|
@@ -396,6 +402,7 @@ function WebhookDetailPage() {
|
|
|
396
402
|
fields,
|
|
397
403
|
groups,
|
|
398
404
|
initialValues: createWebhookInitialValues(webhook),
|
|
405
|
+
optimisticLockUpdatedAt: webhook.updatedAt,
|
|
399
406
|
submitLabel: t("common.save"),
|
|
400
407
|
cancelHref: `/backend/webhooks/${webhook.id}`,
|
|
401
408
|
contentHeader,
|