@checkstack/integration-webhook-backend 0.0.2
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/CHANGELOG.md +52 -0
- package/package.json +24 -0
- package/src/index.ts +32 -0
- package/src/plugin-metadata.ts +9 -0
- package/src/provider.test.ts +513 -0
- package/src/provider.ts +347 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @checkstack/integration-webhook-backend
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
|
|
8
|
+
- Updated dependencies [d20d274]
|
|
9
|
+
- @checkstack/backend-api@0.0.2
|
|
10
|
+
- @checkstack/common@0.0.2
|
|
11
|
+
- @checkstack/integration-backend@0.0.2
|
|
12
|
+
- @checkstack/integration-common@0.0.2
|
|
13
|
+
|
|
14
|
+
## 0.1.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- 4c5aa9e: Fix `IntegrationProvider.testConnection` generic type
|
|
19
|
+
|
|
20
|
+
- **Breaking**: `testConnection` now receives `TConnection` (connection config) instead of `TConfig` (subscription config)
|
|
21
|
+
- **Breaking**: `RegisteredIntegrationProvider` now includes `TConnection` generic parameter
|
|
22
|
+
- Removed `testConnection` from webhook provider (providers without `connectionSchema` cannot have `testConnection`)
|
|
23
|
+
- Fixed Jira provider to use `JiraConnectionConfig` directly in `testConnection`
|
|
24
|
+
|
|
25
|
+
This aligns the interface with the actual behavior: `testConnection` tests connection credentials, not subscription configuration.
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [4c5aa9e]
|
|
30
|
+
- Updated dependencies [b4eb432]
|
|
31
|
+
- Updated dependencies [a65e002]
|
|
32
|
+
- Updated dependencies [a65e002]
|
|
33
|
+
- @checkstack/integration-backend@0.1.0
|
|
34
|
+
- @checkstack/backend-api@1.1.0
|
|
35
|
+
- @checkstack/common@0.2.0
|
|
36
|
+
- @checkstack/integration-common@0.1.1
|
|
37
|
+
|
|
38
|
+
## 0.0.2
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- Updated dependencies [ffc28f6]
|
|
43
|
+
- Updated dependencies [4dd644d]
|
|
44
|
+
- Updated dependencies [71275dd]
|
|
45
|
+
- Updated dependencies [ae19ff6]
|
|
46
|
+
- Updated dependencies [b55fae6]
|
|
47
|
+
- Updated dependencies [b354ab3]
|
|
48
|
+
- Updated dependencies [81f3f85]
|
|
49
|
+
- @checkstack/common@0.1.0
|
|
50
|
+
- @checkstack/backend-api@1.0.0
|
|
51
|
+
- @checkstack/integration-common@0.1.0
|
|
52
|
+
- @checkstack/integration-backend@0.0.2
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/integration-webhook-backend",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"lint": "bun run lint:code",
|
|
9
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@checkstack/backend-api": "workspace:*",
|
|
13
|
+
"@checkstack/integration-backend": "workspace:*",
|
|
14
|
+
"@checkstack/integration-common": "workspace:*",
|
|
15
|
+
"@checkstack/common": "workspace:*",
|
|
16
|
+
"zod": "^4.2.1"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/bun": "^1.0.0",
|
|
20
|
+
"typescript": "^5.0.0",
|
|
21
|
+
"@checkstack/tsconfig": "workspace:*",
|
|
22
|
+
"@checkstack/scripts": "workspace:*"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBackendPlugin,
|
|
3
|
+
coreServices,
|
|
4
|
+
} from "@checkstack/backend-api";
|
|
5
|
+
import { integrationProviderExtensionPoint } from "@checkstack/integration-backend";
|
|
6
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
7
|
+
import { webhookProvider } from "./provider";
|
|
8
|
+
|
|
9
|
+
export default createBackendPlugin({
|
|
10
|
+
metadata: pluginMetadata,
|
|
11
|
+
|
|
12
|
+
register(env) {
|
|
13
|
+
env.registerInit({
|
|
14
|
+
deps: {
|
|
15
|
+
logger: coreServices.logger,
|
|
16
|
+
},
|
|
17
|
+
init: async ({ logger }) => {
|
|
18
|
+
logger.debug("🔌 Registering Webhook Integration Provider...");
|
|
19
|
+
|
|
20
|
+
// Get the integration provider extension point
|
|
21
|
+
const extensionPoint = env.getExtensionPoint(
|
|
22
|
+
integrationProviderExtensionPoint
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// Register the webhook provider
|
|
26
|
+
extensionPoint.addProvider(webhookProvider, pluginMetadata);
|
|
27
|
+
|
|
28
|
+
logger.debug("✅ Webhook Integration Provider registered.");
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { definePluginMetadata } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plugin metadata for the Webhook Integration Provider.
|
|
5
|
+
* This is the single source of truth for the plugin ID.
|
|
6
|
+
*/
|
|
7
|
+
export const pluginMetadata = definePluginMetadata({
|
|
8
|
+
pluginId: "integration-webhook",
|
|
9
|
+
});
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
webhookProvider,
|
|
4
|
+
webhookConfigSchemaV1,
|
|
5
|
+
type WebhookConfig,
|
|
6
|
+
} from "./provider";
|
|
7
|
+
import type { IntegrationDeliveryContext } from "@checkstack/integration-backend";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Unit tests for the Webhook Integration Provider.
|
|
11
|
+
*
|
|
12
|
+
* Tests cover:
|
|
13
|
+
* - Config schema validation
|
|
14
|
+
* - Successful webhook delivery
|
|
15
|
+
* - Authentication methods (bearer, basic, header)
|
|
16
|
+
* - Error handling and retry logic
|
|
17
|
+
* - Test connection functionality
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Mock logger
|
|
21
|
+
const mockLogger = {
|
|
22
|
+
debug: mock(() => {}),
|
|
23
|
+
info: mock(() => {}),
|
|
24
|
+
warn: mock(() => {}),
|
|
25
|
+
error: mock(() => {}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Create a test delivery context
|
|
29
|
+
function createTestContext(
|
|
30
|
+
configOverrides: Partial<WebhookConfig> = {}
|
|
31
|
+
): IntegrationDeliveryContext<WebhookConfig> {
|
|
32
|
+
const defaultConfig: WebhookConfig = {
|
|
33
|
+
url: "https://example.com/webhook",
|
|
34
|
+
method: "POST",
|
|
35
|
+
contentType: "application/json",
|
|
36
|
+
authType: "none",
|
|
37
|
+
timeout: 10_000,
|
|
38
|
+
...configOverrides,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
event: {
|
|
43
|
+
eventId: "test-plugin.incident.created",
|
|
44
|
+
payload: { incidentId: "inc-123", severity: "critical" },
|
|
45
|
+
timestamp: new Date().toISOString(),
|
|
46
|
+
deliveryId: "del-456",
|
|
47
|
+
},
|
|
48
|
+
subscription: {
|
|
49
|
+
id: "sub-789",
|
|
50
|
+
name: "Test Subscription",
|
|
51
|
+
},
|
|
52
|
+
providerConfig: defaultConfig,
|
|
53
|
+
logger: mockLogger,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("WebhookProvider", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
mockLogger.debug.mockClear();
|
|
60
|
+
mockLogger.info.mockClear();
|
|
61
|
+
mockLogger.warn.mockClear();
|
|
62
|
+
mockLogger.error.mockClear();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
66
|
+
// Provider Metadata
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
describe("metadata", () => {
|
|
70
|
+
it("has correct basic metadata", () => {
|
|
71
|
+
expect(webhookProvider.id).toBe("webhook");
|
|
72
|
+
expect(webhookProvider.displayName).toBe("Webhook");
|
|
73
|
+
expect(webhookProvider.description).toContain("HTTP POST");
|
|
74
|
+
expect(webhookProvider.icon).toBe("Webhook");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("has a versioned config schema", () => {
|
|
78
|
+
expect(webhookProvider.config).toBeDefined();
|
|
79
|
+
expect(webhookProvider.config.version).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
84
|
+
// Config Schema Validation
|
|
85
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("config schema", () => {
|
|
88
|
+
it("requires valid URL", () => {
|
|
89
|
+
expect(() => {
|
|
90
|
+
webhookConfigSchemaV1.parse({
|
|
91
|
+
url: "not-a-url",
|
|
92
|
+
});
|
|
93
|
+
}).toThrow();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("accepts valid URL", () => {
|
|
97
|
+
const result = webhookConfigSchemaV1.parse({
|
|
98
|
+
url: "https://example.com/webhook",
|
|
99
|
+
});
|
|
100
|
+
expect(result.url).toBe("https://example.com/webhook");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("applies default values", () => {
|
|
104
|
+
const result = webhookConfigSchemaV1.parse({
|
|
105
|
+
url: "https://example.com/webhook",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(result.method).toBe("POST");
|
|
109
|
+
expect(result.contentType).toBe("application/json");
|
|
110
|
+
expect(result.authType).toBe("none");
|
|
111
|
+
expect(result.timeout).toBe(10_000);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("validates method enum", () => {
|
|
115
|
+
expect(() => {
|
|
116
|
+
webhookConfigSchemaV1.parse({
|
|
117
|
+
url: "https://example.com",
|
|
118
|
+
method: "DELETE", // Not allowed
|
|
119
|
+
});
|
|
120
|
+
}).toThrow();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("allows valid methods", () => {
|
|
124
|
+
for (const method of ["POST", "PUT", "PATCH"] as const) {
|
|
125
|
+
const result = webhookConfigSchemaV1.parse({
|
|
126
|
+
url: "https://example.com",
|
|
127
|
+
method,
|
|
128
|
+
});
|
|
129
|
+
expect(result.method).toBe(method);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("validates timeout range", () => {
|
|
134
|
+
expect(() => {
|
|
135
|
+
webhookConfigSchemaV1.parse({
|
|
136
|
+
url: "https://example.com",
|
|
137
|
+
timeout: 500, // Too short
|
|
138
|
+
});
|
|
139
|
+
}).toThrow();
|
|
140
|
+
|
|
141
|
+
expect(() => {
|
|
142
|
+
webhookConfigSchemaV1.parse({
|
|
143
|
+
url: "https://example.com",
|
|
144
|
+
timeout: 100_000, // Too long
|
|
145
|
+
});
|
|
146
|
+
}).toThrow();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
151
|
+
// Delivery - Basic
|
|
152
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe("deliver - basic", () => {
|
|
155
|
+
it("makes HTTP request with correct payload structure", async () => {
|
|
156
|
+
let capturedBody: string | undefined;
|
|
157
|
+
let capturedHeaders: Record<string, string> | undefined;
|
|
158
|
+
|
|
159
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
160
|
+
_url: RequestInfo | URL,
|
|
161
|
+
options?: RequestInit
|
|
162
|
+
) => {
|
|
163
|
+
capturedBody = options?.body as string;
|
|
164
|
+
capturedHeaders = options?.headers as Record<string, string>;
|
|
165
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
166
|
+
}) as unknown as typeof fetch);
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const context = createTestContext();
|
|
170
|
+
const result = await webhookProvider.deliver(context);
|
|
171
|
+
|
|
172
|
+
expect(result.success).toBe(true);
|
|
173
|
+
expect(capturedBody).toBeDefined();
|
|
174
|
+
|
|
175
|
+
const parsedBody = JSON.parse(capturedBody!);
|
|
176
|
+
expect(parsedBody.id).toBe("del-456");
|
|
177
|
+
expect(parsedBody.eventType).toBe("test-plugin.incident.created");
|
|
178
|
+
expect(parsedBody.subscription.id).toBe("sub-789");
|
|
179
|
+
expect(parsedBody.data).toEqual({
|
|
180
|
+
incidentId: "inc-123",
|
|
181
|
+
severity: "critical",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(capturedHeaders?.["Content-Type"]).toBe("application/json");
|
|
185
|
+
expect(capturedHeaders?.["X-Delivery-Id"]).toBe("del-456");
|
|
186
|
+
expect(capturedHeaders?.["X-Event-Type"]).toBe(
|
|
187
|
+
"test-plugin.incident.created"
|
|
188
|
+
);
|
|
189
|
+
} finally {
|
|
190
|
+
mockFetch.mockRestore();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("returns external ID from response if present", async () => {
|
|
195
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
|
|
196
|
+
(async () => {
|
|
197
|
+
return new Response(JSON.stringify({ id: "external-id-123" }), {
|
|
198
|
+
status: 200,
|
|
199
|
+
});
|
|
200
|
+
}) as unknown as typeof fetch
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const context = createTestContext();
|
|
205
|
+
const result = await webhookProvider.deliver(context);
|
|
206
|
+
|
|
207
|
+
expect(result.success).toBe(true);
|
|
208
|
+
expect(result.externalId).toBe("external-id-123");
|
|
209
|
+
} finally {
|
|
210
|
+
mockFetch.mockRestore();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("handles non-JSON response gracefully", async () => {
|
|
215
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
|
|
216
|
+
(async () => {
|
|
217
|
+
return new Response("OK", { status: 200 });
|
|
218
|
+
}) as unknown as typeof fetch
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const context = createTestContext();
|
|
223
|
+
const result = await webhookProvider.deliver(context);
|
|
224
|
+
|
|
225
|
+
expect(result.success).toBe(true);
|
|
226
|
+
expect(result.externalId).toBeUndefined();
|
|
227
|
+
} finally {
|
|
228
|
+
mockFetch.mockRestore();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
234
|
+
// Delivery - Authentication
|
|
235
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
describe("deliver - authentication", () => {
|
|
238
|
+
it("adds Bearer token header", async () => {
|
|
239
|
+
let capturedHeaders: Record<string, string> | undefined;
|
|
240
|
+
|
|
241
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
242
|
+
_url: RequestInfo | URL,
|
|
243
|
+
options?: RequestInit
|
|
244
|
+
) => {
|
|
245
|
+
capturedHeaders = options?.headers as Record<string, string>;
|
|
246
|
+
return new Response("OK", { status: 200 });
|
|
247
|
+
}) as unknown as typeof fetch);
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const context = createTestContext({
|
|
251
|
+
authType: "bearer",
|
|
252
|
+
bearerToken: "my-secret-token",
|
|
253
|
+
});
|
|
254
|
+
await webhookProvider.deliver(context);
|
|
255
|
+
|
|
256
|
+
expect(capturedHeaders?.["Authorization"]).toBe(
|
|
257
|
+
"Bearer my-secret-token"
|
|
258
|
+
);
|
|
259
|
+
} finally {
|
|
260
|
+
mockFetch.mockRestore();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("adds Basic auth header", async () => {
|
|
265
|
+
let capturedHeaders: Record<string, string> | undefined;
|
|
266
|
+
|
|
267
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
268
|
+
_url: RequestInfo | URL,
|
|
269
|
+
options?: RequestInit
|
|
270
|
+
) => {
|
|
271
|
+
capturedHeaders = options?.headers as Record<string, string>;
|
|
272
|
+
return new Response("OK", { status: 200 });
|
|
273
|
+
}) as unknown as typeof fetch);
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const context = createTestContext({
|
|
277
|
+
authType: "basic",
|
|
278
|
+
basicUsername: "user",
|
|
279
|
+
basicPassword: "pass",
|
|
280
|
+
});
|
|
281
|
+
await webhookProvider.deliver(context);
|
|
282
|
+
|
|
283
|
+
// user:pass in base64
|
|
284
|
+
const expectedAuth = `Basic ${Buffer.from("user:pass").toString(
|
|
285
|
+
"base64"
|
|
286
|
+
)}`;
|
|
287
|
+
expect(capturedHeaders?.["Authorization"]).toBe(expectedAuth);
|
|
288
|
+
} finally {
|
|
289
|
+
mockFetch.mockRestore();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("adds custom auth header", async () => {
|
|
294
|
+
let capturedHeaders: Record<string, string> | undefined;
|
|
295
|
+
|
|
296
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
297
|
+
_url: RequestInfo | URL,
|
|
298
|
+
options?: RequestInit
|
|
299
|
+
) => {
|
|
300
|
+
capturedHeaders = options?.headers as Record<string, string>;
|
|
301
|
+
return new Response("OK", { status: 200 });
|
|
302
|
+
}) as unknown as typeof fetch);
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const context = createTestContext({
|
|
306
|
+
authType: "header",
|
|
307
|
+
authHeaderName: "X-API-Key",
|
|
308
|
+
authHeaderValue: "api-key-123",
|
|
309
|
+
});
|
|
310
|
+
await webhookProvider.deliver(context);
|
|
311
|
+
|
|
312
|
+
expect(capturedHeaders?.["X-API-Key"]).toBe("api-key-123");
|
|
313
|
+
} finally {
|
|
314
|
+
mockFetch.mockRestore();
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("adds custom headers", async () => {
|
|
319
|
+
let capturedHeaders: Record<string, string> | undefined;
|
|
320
|
+
|
|
321
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
322
|
+
_url: RequestInfo | URL,
|
|
323
|
+
options?: RequestInit
|
|
324
|
+
) => {
|
|
325
|
+
capturedHeaders = options?.headers as Record<string, string>;
|
|
326
|
+
return new Response("OK", { status: 200 });
|
|
327
|
+
}) as unknown as typeof fetch);
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const context = createTestContext({
|
|
331
|
+
customHeaders: [
|
|
332
|
+
{ name: "X-Custom-Header", value: "custom-value" },
|
|
333
|
+
{ name: "X-Another-Header", value: "another-value" },
|
|
334
|
+
],
|
|
335
|
+
});
|
|
336
|
+
await webhookProvider.deliver(context);
|
|
337
|
+
|
|
338
|
+
expect(capturedHeaders?.["X-Custom-Header"]).toBe("custom-value");
|
|
339
|
+
expect(capturedHeaders?.["X-Another-Header"]).toBe("another-value");
|
|
340
|
+
} finally {
|
|
341
|
+
mockFetch.mockRestore();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
347
|
+
// Delivery - Error Handling
|
|
348
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
describe("deliver - error handling", () => {
|
|
351
|
+
it("returns error for non-OK response", async () => {
|
|
352
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
|
|
353
|
+
(async () => {
|
|
354
|
+
return new Response("Not Found", { status: 404 });
|
|
355
|
+
}) as unknown as typeof fetch
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
const context = createTestContext();
|
|
360
|
+
const result = await webhookProvider.deliver(context);
|
|
361
|
+
|
|
362
|
+
expect(result.success).toBe(false);
|
|
363
|
+
expect(result.error).toContain("404");
|
|
364
|
+
} finally {
|
|
365
|
+
mockFetch.mockRestore();
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("returns retryable error for configured status codes", async () => {
|
|
370
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
|
|
371
|
+
(async () => {
|
|
372
|
+
return new Response("Too Many Requests", { status: 429 });
|
|
373
|
+
}) as unknown as typeof fetch
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
const context = createTestContext({
|
|
378
|
+
retryOnStatus: [429, 503],
|
|
379
|
+
});
|
|
380
|
+
const result = await webhookProvider.deliver(context);
|
|
381
|
+
|
|
382
|
+
expect(result.success).toBe(false);
|
|
383
|
+
expect(result.error).toContain("retryable");
|
|
384
|
+
expect(result.retryAfterMs).toBeDefined();
|
|
385
|
+
} finally {
|
|
386
|
+
mockFetch.mockRestore();
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("parses Retry-After header", async () => {
|
|
391
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
|
|
392
|
+
(async () => {
|
|
393
|
+
const headers = new Headers();
|
|
394
|
+
headers.set("Retry-After", "60");
|
|
395
|
+
return new Response("Too Many Requests", {
|
|
396
|
+
status: 429,
|
|
397
|
+
headers,
|
|
398
|
+
});
|
|
399
|
+
}) as unknown as typeof fetch
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
const context = createTestContext({
|
|
404
|
+
retryOnStatus: [429],
|
|
405
|
+
});
|
|
406
|
+
const result = await webhookProvider.deliver(context);
|
|
407
|
+
|
|
408
|
+
expect(result.retryAfterMs).toBe(60_000);
|
|
409
|
+
} finally {
|
|
410
|
+
mockFetch.mockRestore();
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("handles network errors with retry", async () => {
|
|
415
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
|
|
416
|
+
(async () => {
|
|
417
|
+
throw new Error("ECONNREFUSED");
|
|
418
|
+
}) as unknown as typeof fetch
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const context = createTestContext();
|
|
423
|
+
const result = await webhookProvider.deliver(context);
|
|
424
|
+
|
|
425
|
+
expect(result.success).toBe(false);
|
|
426
|
+
expect(result.error).toContain("ECONNREFUSED");
|
|
427
|
+
expect(result.retryAfterMs).toBe(30_000);
|
|
428
|
+
} finally {
|
|
429
|
+
mockFetch.mockRestore();
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("handles timeout errors with retry", async () => {
|
|
434
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
|
|
435
|
+
(async () => {
|
|
436
|
+
throw new Error("timeout");
|
|
437
|
+
}) as unknown as typeof fetch
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
const context = createTestContext();
|
|
442
|
+
const result = await webhookProvider.deliver(context);
|
|
443
|
+
|
|
444
|
+
expect(result.success).toBe(false);
|
|
445
|
+
expect(result.retryAfterMs).toBe(30_000);
|
|
446
|
+
} finally {
|
|
447
|
+
mockFetch.mockRestore();
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
453
|
+
// Content Types
|
|
454
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
describe("content types", () => {
|
|
457
|
+
it("sends JSON body for application/json", async () => {
|
|
458
|
+
let capturedBody: string | undefined;
|
|
459
|
+
let capturedContentType: string | undefined;
|
|
460
|
+
|
|
461
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
462
|
+
_url: RequestInfo | URL,
|
|
463
|
+
options?: RequestInit
|
|
464
|
+
) => {
|
|
465
|
+
capturedBody = options?.body as string;
|
|
466
|
+
capturedContentType = (options?.headers as Record<string, string>)?.[
|
|
467
|
+
"Content-Type"
|
|
468
|
+
];
|
|
469
|
+
return new Response("OK", { status: 200 });
|
|
470
|
+
}) as unknown as typeof fetch);
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
const context = createTestContext({
|
|
474
|
+
contentType: "application/json",
|
|
475
|
+
});
|
|
476
|
+
await webhookProvider.deliver(context);
|
|
477
|
+
|
|
478
|
+
expect(capturedContentType).toBe("application/json");
|
|
479
|
+
expect(() => JSON.parse(capturedBody!)).not.toThrow();
|
|
480
|
+
} finally {
|
|
481
|
+
mockFetch.mockRestore();
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("sends form-encoded body for application/x-www-form-urlencoded", async () => {
|
|
486
|
+
let capturedBody: string | undefined;
|
|
487
|
+
let capturedContentType: string | undefined;
|
|
488
|
+
|
|
489
|
+
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
490
|
+
_url: RequestInfo | URL,
|
|
491
|
+
options?: RequestInit
|
|
492
|
+
) => {
|
|
493
|
+
capturedBody = options?.body as string;
|
|
494
|
+
capturedContentType = (options?.headers as Record<string, string>)?.[
|
|
495
|
+
"Content-Type"
|
|
496
|
+
];
|
|
497
|
+
return new Response("OK", { status: 200 });
|
|
498
|
+
}) as unknown as typeof fetch);
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
const context = createTestContext({
|
|
502
|
+
contentType: "application/x-www-form-urlencoded",
|
|
503
|
+
});
|
|
504
|
+
await webhookProvider.deliver(context);
|
|
505
|
+
|
|
506
|
+
expect(capturedContentType).toBe("application/x-www-form-urlencoded");
|
|
507
|
+
expect(capturedBody).toContain("payload=");
|
|
508
|
+
} finally {
|
|
509
|
+
mockFetch.mockRestore();
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
});
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
Versioned,
|
|
4
|
+
configNumber,
|
|
5
|
+
configString,
|
|
6
|
+
} from "@checkstack/backend-api";
|
|
7
|
+
import type {
|
|
8
|
+
IntegrationProvider,
|
|
9
|
+
IntegrationDeliveryContext,
|
|
10
|
+
IntegrationDeliveryResult,
|
|
11
|
+
} from "@checkstack/integration-backend";
|
|
12
|
+
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Template Expansion Helper
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Expand mustache-style templates with context values.
|
|
19
|
+
* Supports {{path.to.value}} syntax with nested object access.
|
|
20
|
+
*/
|
|
21
|
+
function expandTemplate(
|
|
22
|
+
template: string,
|
|
23
|
+
context: Record<string, unknown>
|
|
24
|
+
): string {
|
|
25
|
+
return template.replaceAll(/\{\{([^}]+)\}\}/g, (_match, path: string) => {
|
|
26
|
+
const trimmedPath = path.trim();
|
|
27
|
+
const parts = trimmedPath.split(".");
|
|
28
|
+
|
|
29
|
+
let value: unknown = context;
|
|
30
|
+
for (const part of parts) {
|
|
31
|
+
if (value === null || value === undefined) {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
value = (value as Record<string, unknown>)[part];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Return JSON stringified for objects/arrays, raw string for primitives
|
|
38
|
+
if (value === null || value === undefined) {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
if (typeof value === "object") {
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
}
|
|
44
|
+
return String(value);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// Webhook Configuration Schema
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Header configuration for custom HTTP headers.
|
|
54
|
+
*/
|
|
55
|
+
const webhookHeaderSchema = z.object({
|
|
56
|
+
name: z.string().min(1).describe("Header name"),
|
|
57
|
+
value: z.string().describe("Header value"),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Webhook provider configuration schema.
|
|
62
|
+
* Supports various authentication methods and customization options.
|
|
63
|
+
*/
|
|
64
|
+
export const webhookConfigSchemaV1 = z.object({
|
|
65
|
+
url: configString({})
|
|
66
|
+
.url()
|
|
67
|
+
.describe("The webhook endpoint URL to send events to"),
|
|
68
|
+
method: z
|
|
69
|
+
.enum(["POST", "PUT", "PATCH"])
|
|
70
|
+
.default("POST")
|
|
71
|
+
.describe("HTTP method to use"),
|
|
72
|
+
contentType: z
|
|
73
|
+
.enum(["application/json", "application/x-www-form-urlencoded"])
|
|
74
|
+
.default("application/json")
|
|
75
|
+
.describe("Content-Type header for the request"),
|
|
76
|
+
|
|
77
|
+
// Authentication
|
|
78
|
+
authType: z
|
|
79
|
+
.enum(["none", "bearer", "basic", "header"])
|
|
80
|
+
.default("none")
|
|
81
|
+
.describe("Authentication method"),
|
|
82
|
+
bearerToken: configString({ "x-secret": true })
|
|
83
|
+
.describe("Bearer token for authentication")
|
|
84
|
+
.optional(),
|
|
85
|
+
basicUsername: configString({})
|
|
86
|
+
.optional()
|
|
87
|
+
.describe("Username for Basic auth"),
|
|
88
|
+
basicPassword: configString({ "x-secret": true })
|
|
89
|
+
.describe("Password for Basic auth")
|
|
90
|
+
.optional(),
|
|
91
|
+
authHeaderName: configString({})
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("Custom header name for token auth (e.g., X-API-Key)"),
|
|
94
|
+
authHeaderValue: configString({ "x-secret": true })
|
|
95
|
+
.describe("Custom header value")
|
|
96
|
+
.optional(),
|
|
97
|
+
|
|
98
|
+
// Additional options
|
|
99
|
+
customHeaders: z
|
|
100
|
+
.array(webhookHeaderSchema)
|
|
101
|
+
.optional()
|
|
102
|
+
.describe("Additional custom headers to include"),
|
|
103
|
+
|
|
104
|
+
/** Custom body template. Use {{payload.field}} syntax for templating. */
|
|
105
|
+
bodyTemplate: configString({})
|
|
106
|
+
.optional()
|
|
107
|
+
.describe(
|
|
108
|
+
"Custom request body template. Use {{payload.field}} syntax to include event data. Leave empty for default JSON payload."
|
|
109
|
+
),
|
|
110
|
+
|
|
111
|
+
timeout: configNumber({})
|
|
112
|
+
.min(1000)
|
|
113
|
+
.max(60_000)
|
|
114
|
+
.default(10_000)
|
|
115
|
+
.describe("Request timeout in milliseconds"),
|
|
116
|
+
retryOnStatus: z
|
|
117
|
+
.array(configNumber({}))
|
|
118
|
+
.optional()
|
|
119
|
+
.describe("HTTP status codes that should trigger a retry (e.g., 429, 503)"),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export type WebhookConfig = z.infer<typeof webhookConfigSchemaV1>;
|
|
123
|
+
|
|
124
|
+
// =============================================================================
|
|
125
|
+
// Webhook Provider Implementation
|
|
126
|
+
// =============================================================================
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Webhook integration provider.
|
|
130
|
+
* Delivers events as HTTP POST requests to configured endpoints.
|
|
131
|
+
*/
|
|
132
|
+
export const webhookProvider: IntegrationProvider<WebhookConfig> = {
|
|
133
|
+
id: "webhook",
|
|
134
|
+
displayName: "Webhook",
|
|
135
|
+
description: "Deliver events via HTTP POST to external endpoints",
|
|
136
|
+
icon: "Webhook",
|
|
137
|
+
|
|
138
|
+
config: new Versioned({
|
|
139
|
+
version: 1,
|
|
140
|
+
schema: webhookConfigSchemaV1,
|
|
141
|
+
}),
|
|
142
|
+
|
|
143
|
+
documentation: {
|
|
144
|
+
setupGuide: `Your endpoint will receive HTTP POST requests with JSON payloads.
|
|
145
|
+
|
|
146
|
+
Configure your server to:
|
|
147
|
+
1. Accept POST requests at your configured URL
|
|
148
|
+
2. Return a 2xx status code on success
|
|
149
|
+
3. Optionally return a JSON response with an \`id\` field for tracking`,
|
|
150
|
+
examplePayload: JSON.stringify(
|
|
151
|
+
{
|
|
152
|
+
id: "del_abc123",
|
|
153
|
+
eventType: "incident.created",
|
|
154
|
+
timestamp: "2024-01-15T10:30:00.000Z",
|
|
155
|
+
subscription: {
|
|
156
|
+
id: "sub_xyz",
|
|
157
|
+
name: "My Webhook",
|
|
158
|
+
},
|
|
159
|
+
data: {
|
|
160
|
+
incidentId: "inc_123",
|
|
161
|
+
title: "API degraded performance",
|
|
162
|
+
severity: "warning",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
// eslint-disable-next-line unicorn/no-null
|
|
166
|
+
null,
|
|
167
|
+
2
|
|
168
|
+
),
|
|
169
|
+
headers: [
|
|
170
|
+
{
|
|
171
|
+
name: "Content-Type",
|
|
172
|
+
description: "application/json (or application/x-www-form-urlencoded)",
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "X-Delivery-Id",
|
|
176
|
+
description: "Unique ID for this delivery attempt",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "X-Event-Type",
|
|
180
|
+
description: "The event type (e.g., incident.created)",
|
|
181
|
+
},
|
|
182
|
+
{ name: "User-Agent", description: "Checkstack-Integration/1.0" },
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async deliver(
|
|
187
|
+
context: IntegrationDeliveryContext<WebhookConfig>
|
|
188
|
+
): Promise<IntegrationDeliveryResult> {
|
|
189
|
+
const { event, subscription, providerConfig, logger } = context;
|
|
190
|
+
|
|
191
|
+
// Validate config
|
|
192
|
+
const config = webhookConfigSchemaV1.parse(providerConfig);
|
|
193
|
+
|
|
194
|
+
// Build template context for all template-able fields
|
|
195
|
+
const templateContext = {
|
|
196
|
+
deliveryId: event.deliveryId,
|
|
197
|
+
eventType: event.eventId,
|
|
198
|
+
eventId: event.eventId,
|
|
199
|
+
timestamp: event.timestamp,
|
|
200
|
+
subscriptionId: subscription.id,
|
|
201
|
+
subscriptionName: subscription.name,
|
|
202
|
+
payload: event.payload,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Expand URL template if it contains template syntax
|
|
206
|
+
const url = expandTemplate(config.url, templateContext);
|
|
207
|
+
|
|
208
|
+
// Build headers
|
|
209
|
+
const headers: Record<string, string> = {
|
|
210
|
+
"Content-Type": config.contentType,
|
|
211
|
+
"User-Agent": "Checkstack-Integration/1.0",
|
|
212
|
+
"X-Delivery-Id": event.deliveryId,
|
|
213
|
+
"X-Event-Type": event.eventId,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Add authentication headers
|
|
217
|
+
switch (config.authType) {
|
|
218
|
+
case "bearer": {
|
|
219
|
+
if (config.bearerToken) {
|
|
220
|
+
headers["Authorization"] = `Bearer ${config.bearerToken}`;
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case "basic": {
|
|
225
|
+
if (config.basicUsername && config.basicPassword) {
|
|
226
|
+
const credentials = Buffer.from(
|
|
227
|
+
`${config.basicUsername}:${config.basicPassword}`
|
|
228
|
+
).toString("base64");
|
|
229
|
+
headers["Authorization"] = `Basic ${credentials}`;
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case "header": {
|
|
234
|
+
if (config.authHeaderName && config.authHeaderValue) {
|
|
235
|
+
headers[config.authHeaderName] = config.authHeaderValue;
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Add custom headers (with template expansion)
|
|
242
|
+
if (config.customHeaders) {
|
|
243
|
+
for (const header of config.customHeaders) {
|
|
244
|
+
headers[header.name] = expandTemplate(header.value, templateContext);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Build request body
|
|
249
|
+
let body: string;
|
|
250
|
+
|
|
251
|
+
if (config.bodyTemplate) {
|
|
252
|
+
// Use custom template with context
|
|
253
|
+
body = expandTemplate(config.bodyTemplate, templateContext);
|
|
254
|
+
} else {
|
|
255
|
+
// Default payload format
|
|
256
|
+
const payload = {
|
|
257
|
+
id: event.deliveryId,
|
|
258
|
+
eventType: event.eventId,
|
|
259
|
+
timestamp: event.timestamp,
|
|
260
|
+
subscription: {
|
|
261
|
+
id: subscription.id,
|
|
262
|
+
name: subscription.name,
|
|
263
|
+
},
|
|
264
|
+
data: event.payload,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
body =
|
|
268
|
+
config.contentType === "application/json"
|
|
269
|
+
? JSON.stringify(payload)
|
|
270
|
+
: new URLSearchParams({
|
|
271
|
+
payload: JSON.stringify(payload),
|
|
272
|
+
}).toString();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
logger.debug(`Delivering webhook to ${url} for event ${event.eventId}`);
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const response = await fetch(url, {
|
|
279
|
+
method: config.method,
|
|
280
|
+
headers,
|
|
281
|
+
body,
|
|
282
|
+
signal: AbortSignal.timeout(config.timeout),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const responseText = await response.text();
|
|
286
|
+
|
|
287
|
+
// Check if we should retry based on status code
|
|
288
|
+
if (config.retryOnStatus?.includes(response.status)) {
|
|
289
|
+
// Check for Retry-After header
|
|
290
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
291
|
+
const retryAfterMs = retryAfter
|
|
292
|
+
? Number.parseInt(retryAfter, 10) * 1000
|
|
293
|
+
: 30_000;
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
success: false,
|
|
297
|
+
error: `Received status ${response.status} (retryable)`,
|
|
298
|
+
retryAfterMs,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!response.ok) {
|
|
303
|
+
return {
|
|
304
|
+
success: false,
|
|
305
|
+
error: `HTTP ${response.status}: ${responseText.slice(0, 200)}`,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
logger.debug(`Webhook delivered successfully: ${response.status}`);
|
|
310
|
+
|
|
311
|
+
// Try to extract external ID from response
|
|
312
|
+
let externalId: string | undefined;
|
|
313
|
+
try {
|
|
314
|
+
const json = JSON.parse(responseText);
|
|
315
|
+
externalId = json.id ?? json.externalId ?? json.messageId;
|
|
316
|
+
} catch {
|
|
317
|
+
// Response wasn't JSON, that's fine
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
success: true,
|
|
322
|
+
externalId,
|
|
323
|
+
};
|
|
324
|
+
} catch (error) {
|
|
325
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
326
|
+
logger.error(`Webhook delivery failed: ${message}`);
|
|
327
|
+
|
|
328
|
+
// Network errors should trigger retry
|
|
329
|
+
if (
|
|
330
|
+
message.includes("timeout") ||
|
|
331
|
+
message.includes("ECONNREFUSED") ||
|
|
332
|
+
message.includes("ENOTFOUND")
|
|
333
|
+
) {
|
|
334
|
+
return {
|
|
335
|
+
success: false,
|
|
336
|
+
error: message,
|
|
337
|
+
retryAfterMs: 30_000,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
success: false,
|
|
343
|
+
error: message,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
};
|