@multiplier-labs/stepflow 0.3.1
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/.github/workflows/publish.yml +30 -0
- package/README.md +408 -0
- package/dist/chunk-FQCT3I2A.js +1512 -0
- package/dist/chunk-FQCT3I2A.js.map +1 -0
- package/dist/chunk-GXY66V7Q.js +111 -0
- package/dist/chunk-GXY66V7Q.js.map +1 -0
- package/dist/chunk-U5QGN3RT.js +715 -0
- package/dist/chunk-U5QGN3RT.js.map +1 -0
- package/dist/chunk-XCF4PLN7.js +575 -0
- package/dist/chunk-XCF4PLN7.js.map +1 -0
- package/dist/events/index.d.ts +346 -0
- package/dist/events/index.js +11 -0
- package/dist/events/index.js.map +1 -0
- package/dist/index-CPfGsmrp.d.ts +567 -0
- package/dist/index.d.ts +748 -0
- package/dist/index.js +1707 -0
- package/dist/index.js.map +1 -0
- package/dist/scheduler/index.d.ts +6 -0
- package/dist/scheduler/index.js +12 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/storage/index.d.ts +402 -0
- package/dist/storage/index.js +13 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/types-BVShq7zT.d.ts +150 -0
- package/dist/types-BlwFEGzj.d.ts +276 -0
- package/dist/types-DzkEqnQJ.d.ts +75 -0
- package/docs/github-packages-setup.md +97 -0
- package/docs/stepflow-api-reference.md +1662 -0
- package/package.json +85 -0
- package/src/core/engine.test.ts +1421 -0
- package/src/core/engine.ts +717 -0
- package/src/core/index.ts +7 -0
- package/src/core/orchestrator.ts +590 -0
- package/src/core/types.ts +242 -0
- package/src/events/index.ts +8 -0
- package/src/events/memory.test.ts +109 -0
- package/src/events/memory.ts +95 -0
- package/src/events/socketio.test.ts +428 -0
- package/src/events/socketio.ts +269 -0
- package/src/events/types.ts +121 -0
- package/src/events/webhook.test.ts +695 -0
- package/src/events/webhook.ts +591 -0
- package/src/index.ts +270 -0
- package/src/planning/index.ts +52 -0
- package/src/planning/planner.ts +601 -0
- package/src/planning/planning.test.ts +790 -0
- package/src/planning/registry.test.ts +445 -0
- package/src/planning/registry.ts +369 -0
- package/src/planning/types.ts +570 -0
- package/src/scheduler/cron.test.ts +480 -0
- package/src/scheduler/cron.ts +449 -0
- package/src/scheduler/index.ts +18 -0
- package/src/scheduler/postgres-persistence.test.ts +906 -0
- package/src/scheduler/postgres-persistence.ts +546 -0
- package/src/scheduler/sqlite-persistence.test.ts +235 -0
- package/src/scheduler/sqlite-persistence.ts +268 -0
- package/src/scheduler/types.ts +78 -0
- package/src/storage/index.ts +27 -0
- package/src/storage/memory.test.ts +399 -0
- package/src/storage/memory.ts +248 -0
- package/src/storage/postgres.ts +1550 -0
- package/src/storage/sqlite.test.ts +593 -0
- package/src/storage/sqlite.ts +662 -0
- package/src/storage/types.ts +368 -0
- package/src/utils/errors.test.ts +152 -0
- package/src/utils/errors.ts +191 -0
- package/src/utils/id.ts +21 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/logger.test.ts +127 -0
- package/src/utils/logger.ts +96 -0
- package/src/utils/postgres-deps.ts +63 -0
- package/src/utils/retry.test.ts +197 -0
- package/src/utils/retry.ts +121 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
2
|
+
import * as dns from "node:dns";
|
|
3
|
+
import { WebhookEventTransport, WebhookEndpoint } from "./webhook";
|
|
4
|
+
import type { WorkflowEvent } from "./types";
|
|
5
|
+
|
|
6
|
+
vi.mock("node:dns", () => ({
|
|
7
|
+
promises: {
|
|
8
|
+
lookup: vi.fn().mockResolvedValue({ address: "93.184.216.34", family: 4 }),
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
function createTestEvent(
|
|
13
|
+
overrides: Partial<WorkflowEvent> = {},
|
|
14
|
+
): WorkflowEvent {
|
|
15
|
+
return {
|
|
16
|
+
eventType: "run.started",
|
|
17
|
+
runId: "run-123",
|
|
18
|
+
kind: "test.workflow",
|
|
19
|
+
timestamp: new Date("2024-01-01T12:00:00Z"),
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("WebhookEventTransport", () => {
|
|
25
|
+
let transport: WebhookEventTransport;
|
|
26
|
+
let mockFetch: ReturnType<typeof vi.fn>;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
|
30
|
+
transport = new WebhookEventTransport({
|
|
31
|
+
fetchFn: mockFetch,
|
|
32
|
+
defaultTimeout: 1000,
|
|
33
|
+
defaultRetries: 0, // Disable retries for faster tests
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
transport.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("addEndpoint / removeEndpoint", () => {
|
|
42
|
+
it("should add an endpoint", () => {
|
|
43
|
+
const endpoint: WebhookEndpoint = {
|
|
44
|
+
id: "test-1",
|
|
45
|
+
url: "https://example.com/webhook",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
transport.addEndpoint(endpoint);
|
|
49
|
+
|
|
50
|
+
expect(transport.getEndpoints()).toHaveLength(1);
|
|
51
|
+
expect(transport.getEndpoints()[0].id).toBe("test-1");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should add endpoint with enabled=true by default", () => {
|
|
55
|
+
transport.addEndpoint({
|
|
56
|
+
id: "test-1",
|
|
57
|
+
url: "https://example.com/webhook",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(transport.getEndpoints()[0].enabled).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should remove an endpoint", () => {
|
|
64
|
+
transport.addEndpoint({
|
|
65
|
+
id: "test-1",
|
|
66
|
+
url: "https://example.com/webhook",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const removed = transport.removeEndpoint("test-1");
|
|
70
|
+
|
|
71
|
+
expect(removed).toBe(true);
|
|
72
|
+
expect(transport.getEndpoints()).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should return false when removing non-existent endpoint", () => {
|
|
76
|
+
const removed = transport.removeEndpoint("nonexistent");
|
|
77
|
+
expect(removed).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("setEndpointEnabled", () => {
|
|
82
|
+
it("should enable/disable an endpoint", () => {
|
|
83
|
+
transport.addEndpoint({
|
|
84
|
+
id: "test-1",
|
|
85
|
+
url: "https://example.com/webhook",
|
|
86
|
+
enabled: true,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
transport.setEndpointEnabled("test-1", false);
|
|
90
|
+
expect(transport.getEndpoints()[0].enabled).toBe(false);
|
|
91
|
+
|
|
92
|
+
transport.setEndpointEnabled("test-1", true);
|
|
93
|
+
expect(transport.getEndpoints()[0].enabled).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should do nothing for non-existent endpoint", () => {
|
|
97
|
+
transport.setEndpointEnabled("nonexistent", false);
|
|
98
|
+
// Should not throw
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("emit", () => {
|
|
103
|
+
it("should send event to enabled endpoints", async () => {
|
|
104
|
+
transport.addEndpoint({
|
|
105
|
+
id: "test-1",
|
|
106
|
+
url: "https://example.com/webhook",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const event = createTestEvent();
|
|
110
|
+
transport.emit(event);
|
|
111
|
+
|
|
112
|
+
// Wait for async webhook
|
|
113
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
114
|
+
|
|
115
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
117
|
+
"https://example.com/webhook",
|
|
118
|
+
expect.objectContaining({
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: expect.objectContaining({
|
|
121
|
+
"Content-Type": "application/json",
|
|
122
|
+
}),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should not send to disabled endpoints", async () => {
|
|
128
|
+
transport.addEndpoint({
|
|
129
|
+
id: "test-1",
|
|
130
|
+
url: "https://example.com/webhook",
|
|
131
|
+
enabled: false,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const event = createTestEvent();
|
|
135
|
+
transport.emit(event);
|
|
136
|
+
|
|
137
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
138
|
+
|
|
139
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should include custom headers", async () => {
|
|
143
|
+
transport.addEndpoint({
|
|
144
|
+
id: "test-1",
|
|
145
|
+
url: "https://example.com/webhook",
|
|
146
|
+
headers: { "X-Custom-Header": "custom-value" },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
transport.emit(createTestEvent());
|
|
150
|
+
|
|
151
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
152
|
+
|
|
153
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
154
|
+
expect(callArgs[1].headers["X-Custom-Header"]).toBe("custom-value");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should serialize payload correctly", async () => {
|
|
158
|
+
transport.addEndpoint({
|
|
159
|
+
id: "test-1",
|
|
160
|
+
url: "https://example.com/webhook",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const event = createTestEvent({
|
|
164
|
+
runId: "run-456",
|
|
165
|
+
kind: "my.workflow",
|
|
166
|
+
});
|
|
167
|
+
transport.emit(event);
|
|
168
|
+
|
|
169
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
170
|
+
|
|
171
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
172
|
+
const body = JSON.parse(callArgs[1].body);
|
|
173
|
+
|
|
174
|
+
expect(body.event.runId).toBe("run-456");
|
|
175
|
+
expect(body.event.kind).toBe("my.workflow");
|
|
176
|
+
expect(body.event.timestamp).toBe("2024-01-01T12:00:00.000Z");
|
|
177
|
+
expect(body.webhookId).toBe("test-1");
|
|
178
|
+
expect(body.deliveredAt).toBeDefined();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("event filtering", () => {
|
|
183
|
+
it("should filter by event type", async () => {
|
|
184
|
+
transport.addEndpoint({
|
|
185
|
+
id: "test-1",
|
|
186
|
+
url: "https://example.com/webhook",
|
|
187
|
+
eventTypes: ["run.completed", "run.failed"],
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Should be filtered out
|
|
191
|
+
transport.emit(createTestEvent({ eventType: "run.started" }));
|
|
192
|
+
|
|
193
|
+
// Should be sent
|
|
194
|
+
transport.emit(createTestEvent({ eventType: "run.completed" }));
|
|
195
|
+
|
|
196
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
197
|
+
|
|
198
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("should filter by workflow kind", async () => {
|
|
202
|
+
transport.addEndpoint({
|
|
203
|
+
id: "test-1",
|
|
204
|
+
url: "https://example.com/webhook",
|
|
205
|
+
workflowKinds: ["order.process", "order.ship"],
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Should be filtered out
|
|
209
|
+
transport.emit(createTestEvent({ kind: "user.notify" }));
|
|
210
|
+
|
|
211
|
+
// Should be sent
|
|
212
|
+
transport.emit(createTestEvent({ kind: "order.process" }));
|
|
213
|
+
|
|
214
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
215
|
+
|
|
216
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("should apply both filters (AND logic)", async () => {
|
|
220
|
+
transport.addEndpoint({
|
|
221
|
+
id: "test-1",
|
|
222
|
+
url: "https://example.com/webhook",
|
|
223
|
+
eventTypes: ["run.completed"],
|
|
224
|
+
workflowKinds: ["order.process"],
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Wrong event type
|
|
228
|
+
transport.emit(
|
|
229
|
+
createTestEvent({
|
|
230
|
+
eventType: "run.started",
|
|
231
|
+
kind: "order.process",
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Wrong workflow kind
|
|
236
|
+
transport.emit(
|
|
237
|
+
createTestEvent({
|
|
238
|
+
eventType: "run.completed",
|
|
239
|
+
kind: "user.notify",
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Both match
|
|
244
|
+
transport.emit(
|
|
245
|
+
createTestEvent({
|
|
246
|
+
eventType: "run.completed",
|
|
247
|
+
kind: "order.process",
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
252
|
+
|
|
253
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("should send to all endpoints when no filters", async () => {
|
|
257
|
+
transport.addEndpoint({
|
|
258
|
+
id: "test-1",
|
|
259
|
+
url: "https://example.com/webhook1",
|
|
260
|
+
});
|
|
261
|
+
transport.addEndpoint({
|
|
262
|
+
id: "test-2",
|
|
263
|
+
url: "https://example.com/webhook2",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
transport.emit(createTestEvent());
|
|
267
|
+
|
|
268
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
269
|
+
|
|
270
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("HMAC signing", () => {
|
|
275
|
+
it("should sign payload when secret is provided", async () => {
|
|
276
|
+
transport.addEndpoint({
|
|
277
|
+
id: "test-1",
|
|
278
|
+
url: "https://example.com/webhook",
|
|
279
|
+
secret: "my-secret-key",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
transport.emit(createTestEvent());
|
|
283
|
+
|
|
284
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
285
|
+
|
|
286
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
287
|
+
const signature = callArgs[1].headers["X-Webhook-Signature"];
|
|
288
|
+
|
|
289
|
+
expect(signature).toBeDefined();
|
|
290
|
+
expect(signature).toMatch(/^sha256=[a-f0-9]{64}$/);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("should not include signature header when no secret", async () => {
|
|
294
|
+
transport.addEndpoint({
|
|
295
|
+
id: "test-1",
|
|
296
|
+
url: "https://example.com/webhook",
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
transport.emit(createTestEvent());
|
|
300
|
+
|
|
301
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
302
|
+
|
|
303
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
304
|
+
expect(callArgs[1].headers["X-Webhook-Signature"]).toBeUndefined();
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("retries", () => {
|
|
309
|
+
it("should retry on failure", async () => {
|
|
310
|
+
mockFetch
|
|
311
|
+
.mockRejectedValueOnce(new Error("Network error"))
|
|
312
|
+
.mockRejectedValueOnce(new Error("Network error"))
|
|
313
|
+
.mockResolvedValueOnce({ ok: true });
|
|
314
|
+
|
|
315
|
+
transport = new WebhookEventTransport({
|
|
316
|
+
fetchFn: mockFetch,
|
|
317
|
+
defaultTimeout: 1000,
|
|
318
|
+
defaultRetries: 3,
|
|
319
|
+
retryDelay: 10, // Fast retries for tests
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
transport.addEndpoint({
|
|
323
|
+
id: "test-1",
|
|
324
|
+
url: "https://example.com/webhook",
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
transport.emit(createTestEvent());
|
|
328
|
+
|
|
329
|
+
// Wait for retries
|
|
330
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
331
|
+
|
|
332
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("should retry on non-ok response", async () => {
|
|
336
|
+
mockFetch
|
|
337
|
+
.mockResolvedValueOnce({
|
|
338
|
+
ok: false,
|
|
339
|
+
status: 500,
|
|
340
|
+
statusText: "Internal Server Error",
|
|
341
|
+
})
|
|
342
|
+
.mockResolvedValueOnce({ ok: true });
|
|
343
|
+
|
|
344
|
+
transport = new WebhookEventTransport({
|
|
345
|
+
fetchFn: mockFetch,
|
|
346
|
+
defaultTimeout: 1000,
|
|
347
|
+
defaultRetries: 3,
|
|
348
|
+
retryDelay: 10,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
transport.addEndpoint({
|
|
352
|
+
id: "test-1",
|
|
353
|
+
url: "https://example.com/webhook",
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
transport.emit(createTestEvent());
|
|
357
|
+
|
|
358
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
359
|
+
|
|
360
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("subscribe / subscribeAll", () => {
|
|
365
|
+
it("should call local subscribers on emit", () => {
|
|
366
|
+
const callback = vi.fn();
|
|
367
|
+
transport.subscribe("run-123", callback);
|
|
368
|
+
|
|
369
|
+
const event = createTestEvent({ runId: "run-123" });
|
|
370
|
+
transport.emit(event);
|
|
371
|
+
|
|
372
|
+
expect(callback).toHaveBeenCalledWith(event);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("should not call subscriber for different runId", () => {
|
|
376
|
+
const callback = vi.fn();
|
|
377
|
+
transport.subscribe("run-123", callback);
|
|
378
|
+
|
|
379
|
+
const event = createTestEvent({ runId: "run-456" });
|
|
380
|
+
transport.emit(event);
|
|
381
|
+
|
|
382
|
+
expect(callback).not.toHaveBeenCalled();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("should call global subscribers for all events", () => {
|
|
386
|
+
const callback = vi.fn();
|
|
387
|
+
transport.subscribeAll(callback);
|
|
388
|
+
|
|
389
|
+
transport.emit(createTestEvent({ runId: "run-1" }));
|
|
390
|
+
transport.emit(createTestEvent({ runId: "run-2" }));
|
|
391
|
+
|
|
392
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("should unsubscribe correctly", () => {
|
|
396
|
+
const callback = vi.fn();
|
|
397
|
+
const unsubscribe = transport.subscribe("run-123", callback);
|
|
398
|
+
|
|
399
|
+
unsubscribe();
|
|
400
|
+
|
|
401
|
+
transport.emit(createTestEvent({ runId: "run-123" }));
|
|
402
|
+
expect(callback).not.toHaveBeenCalled();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("should handle callback errors gracefully", () => {
|
|
406
|
+
const errorCallback = vi.fn().mockImplementation(() => {
|
|
407
|
+
throw new Error("Callback error");
|
|
408
|
+
});
|
|
409
|
+
const normalCallback = vi.fn();
|
|
410
|
+
|
|
411
|
+
transport.subscribe("run-123", errorCallback);
|
|
412
|
+
transport.subscribe("run-123", normalCallback);
|
|
413
|
+
|
|
414
|
+
transport.emit(createTestEvent({ runId: "run-123" }));
|
|
415
|
+
|
|
416
|
+
expect(errorCallback).toHaveBeenCalled();
|
|
417
|
+
expect(normalCallback).toHaveBeenCalled();
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe("close", () => {
|
|
422
|
+
it("should clear all endpoints and subscribers", () => {
|
|
423
|
+
transport.addEndpoint({
|
|
424
|
+
id: "test-1",
|
|
425
|
+
url: "https://example.com/webhook",
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const callback = vi.fn();
|
|
429
|
+
transport.subscribe("run-123", callback);
|
|
430
|
+
transport.subscribeAll(vi.fn());
|
|
431
|
+
|
|
432
|
+
transport.close();
|
|
433
|
+
|
|
434
|
+
expect(transport.getEndpoints()).toHaveLength(0);
|
|
435
|
+
|
|
436
|
+
// Callback should not be called after close
|
|
437
|
+
transport.emit(createTestEvent({ runId: "run-123" }));
|
|
438
|
+
expect(callback).not.toHaveBeenCalled();
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("SSRF protection - isBlockedHost", () => {
|
|
443
|
+
it("should block 10.x.x.x private range", () => {
|
|
444
|
+
expect(() =>
|
|
445
|
+
transport.addEndpoint({ id: "ssrf-1", url: "https://10.0.0.1/hook" }),
|
|
446
|
+
).toThrow(/blocked/);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("should block 172.16-31.x.x private range", () => {
|
|
450
|
+
expect(() =>
|
|
451
|
+
transport.addEndpoint({ id: "ssrf-2", url: "https://172.16.0.1/hook" }),
|
|
452
|
+
).toThrow(/blocked/);
|
|
453
|
+
expect(() =>
|
|
454
|
+
transport.addEndpoint({
|
|
455
|
+
id: "ssrf-3",
|
|
456
|
+
url: "https://172.31.255.255/hook",
|
|
457
|
+
}),
|
|
458
|
+
).toThrow(/blocked/);
|
|
459
|
+
// 172.32 should NOT be blocked
|
|
460
|
+
transport.addEndpoint({ id: "ssrf-ok", url: "https://172.32.0.1/hook" });
|
|
461
|
+
expect(
|
|
462
|
+
transport.getEndpoints().find((e) => e.id === "ssrf-ok"),
|
|
463
|
+
).toBeDefined();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("should block 192.168.x.x private range", () => {
|
|
467
|
+
expect(() =>
|
|
468
|
+
transport.addEndpoint({
|
|
469
|
+
id: "ssrf-4",
|
|
470
|
+
url: "https://192.168.1.1/hook",
|
|
471
|
+
}),
|
|
472
|
+
).toThrow(/blocked/);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("should block 169.254.x.x link-local range", () => {
|
|
476
|
+
expect(() =>
|
|
477
|
+
transport.addEndpoint({
|
|
478
|
+
id: "ssrf-5",
|
|
479
|
+
url: "https://169.254.1.1/hook",
|
|
480
|
+
}),
|
|
481
|
+
).toThrow(/blocked/);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("should block 0.0.0.0", () => {
|
|
485
|
+
expect(() =>
|
|
486
|
+
transport.addEndpoint({ id: "ssrf-6", url: "https://0.0.0.0/hook" }),
|
|
487
|
+
).toThrow(/blocked/);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("should block IPv6 link-local (fe80::)", () => {
|
|
491
|
+
expect(() =>
|
|
492
|
+
transport.addEndpoint({ id: "ssrf-7", url: "https://[fe80::1]/hook" }),
|
|
493
|
+
).toThrow(/blocked/);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("should block localhost and 127.x.x.x", () => {
|
|
497
|
+
expect(() =>
|
|
498
|
+
transport.addEndpoint({ id: "ssrf-8", url: "https://localhost/hook" }),
|
|
499
|
+
).toThrow(/blocked/);
|
|
500
|
+
expect(() =>
|
|
501
|
+
transport.addEndpoint({ id: "ssrf-9", url: "https://127.0.0.1/hook" }),
|
|
502
|
+
).toThrow(/blocked/);
|
|
503
|
+
expect(() =>
|
|
504
|
+
transport.addEndpoint({ id: "ssrf-10", url: "https://127.0.0.2/hook" }),
|
|
505
|
+
).toThrow(/blocked/);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("should block cloud metadata endpoint", () => {
|
|
509
|
+
expect(() =>
|
|
510
|
+
transport.addEndpoint({
|
|
511
|
+
id: "ssrf-11",
|
|
512
|
+
url: "https://169.254.169.254/latest/meta-data",
|
|
513
|
+
}),
|
|
514
|
+
).toThrow(/blocked/);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
describe("emit subscriber error isolation", () => {
|
|
519
|
+
it("should catch subscriber error and still call other subscribers", () => {
|
|
520
|
+
const errorCallback = vi.fn().mockImplementation(() => {
|
|
521
|
+
throw new Error("Subscriber exploded");
|
|
522
|
+
});
|
|
523
|
+
const normalCallback = vi.fn();
|
|
524
|
+
|
|
525
|
+
transport.subscribeAll(errorCallback);
|
|
526
|
+
transport.subscribeAll(normalCallback);
|
|
527
|
+
|
|
528
|
+
const event = createTestEvent();
|
|
529
|
+
transport.emit(event);
|
|
530
|
+
|
|
531
|
+
expect(errorCallback).toHaveBeenCalledWith(event);
|
|
532
|
+
expect(normalCallback).toHaveBeenCalledWith(event);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("should catch run-subscriber error and still call other run-subscribers", () => {
|
|
536
|
+
const errorCallback = vi.fn().mockImplementation(() => {
|
|
537
|
+
throw new Error("Run subscriber failed");
|
|
538
|
+
});
|
|
539
|
+
const normalCallback = vi.fn();
|
|
540
|
+
|
|
541
|
+
transport.subscribe("run-123", errorCallback);
|
|
542
|
+
transport.subscribe("run-123", normalCallback);
|
|
543
|
+
|
|
544
|
+
const event = createTestEvent({ runId: "run-123" });
|
|
545
|
+
transport.emit(event);
|
|
546
|
+
|
|
547
|
+
expect(errorCallback).toHaveBeenCalled();
|
|
548
|
+
expect(normalCallback).toHaveBeenCalled();
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe("close clears all state", () => {
|
|
553
|
+
it("should clear endpoints, run subscribers, and global subscribers after close", () => {
|
|
554
|
+
transport.addEndpoint({ id: "ep-1", url: "https://example.com/hook" });
|
|
555
|
+
const runCb = vi.fn();
|
|
556
|
+
const globalCb = vi.fn();
|
|
557
|
+
transport.subscribe("run-123", runCb);
|
|
558
|
+
transport.subscribeAll(globalCb);
|
|
559
|
+
|
|
560
|
+
transport.close();
|
|
561
|
+
|
|
562
|
+
expect(transport.getEndpoints()).toHaveLength(0);
|
|
563
|
+
|
|
564
|
+
// Subscribers should no longer receive events
|
|
565
|
+
transport.emit(createTestEvent({ runId: "run-123" }));
|
|
566
|
+
expect(runCb).not.toHaveBeenCalled();
|
|
567
|
+
expect(globalCb).not.toHaveBeenCalled();
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
describe("constructor with initial endpoints", () => {
|
|
572
|
+
it("should register initial endpoints", () => {
|
|
573
|
+
transport = new WebhookEventTransport({
|
|
574
|
+
fetchFn: mockFetch,
|
|
575
|
+
endpoints: [
|
|
576
|
+
{ id: "ep-1", url: "https://example.com/1" },
|
|
577
|
+
{ id: "ep-2", url: "https://example.com/2" },
|
|
578
|
+
],
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
expect(transport.getEndpoints()).toHaveLength(2);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
describe("SSRF protection - DNS resolution", () => {
|
|
586
|
+
it("should block webhooks when hostname resolves to private IP", async () => {
|
|
587
|
+
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
588
|
+
dnsLookup.mockResolvedValueOnce({ address: "10.0.0.5", family: 4 });
|
|
589
|
+
|
|
590
|
+
const logger = {
|
|
591
|
+
debug: vi.fn(),
|
|
592
|
+
info: vi.fn(),
|
|
593
|
+
warn: vi.fn(),
|
|
594
|
+
error: vi.fn(),
|
|
595
|
+
};
|
|
596
|
+
transport = new WebhookEventTransport({
|
|
597
|
+
fetchFn: mockFetch,
|
|
598
|
+
defaultRetries: 0,
|
|
599
|
+
logger,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
transport.addEndpoint({
|
|
603
|
+
id: "ssrf-dns",
|
|
604
|
+
url: "https://evil.example.com/hook",
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
transport.emit(createTestEvent());
|
|
608
|
+
|
|
609
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
610
|
+
|
|
611
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
612
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
613
|
+
expect.stringContaining("ssrf-dns"),
|
|
614
|
+
expect.objectContaining({
|
|
615
|
+
message: expect.stringContaining("resolves to blocked IP"),
|
|
616
|
+
}),
|
|
617
|
+
);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("should block webhooks when hostname resolves to loopback", async () => {
|
|
621
|
+
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
622
|
+
dnsLookup.mockResolvedValueOnce({ address: "127.0.0.1", family: 4 });
|
|
623
|
+
|
|
624
|
+
const logger = {
|
|
625
|
+
debug: vi.fn(),
|
|
626
|
+
info: vi.fn(),
|
|
627
|
+
warn: vi.fn(),
|
|
628
|
+
error: vi.fn(),
|
|
629
|
+
};
|
|
630
|
+
transport = new WebhookEventTransport({
|
|
631
|
+
fetchFn: mockFetch,
|
|
632
|
+
defaultRetries: 0,
|
|
633
|
+
logger,
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
transport.addEndpoint({
|
|
637
|
+
id: "ssrf-loopback",
|
|
638
|
+
url: "https://loopback.example.com/hook",
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
transport.emit(createTestEvent());
|
|
642
|
+
|
|
643
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
644
|
+
|
|
645
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("should block webhooks when hostname resolves to cloud metadata IP", async () => {
|
|
649
|
+
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
650
|
+
dnsLookup.mockResolvedValueOnce({
|
|
651
|
+
address: "169.254.169.254",
|
|
652
|
+
family: 4,
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
const logger = {
|
|
656
|
+
debug: vi.fn(),
|
|
657
|
+
info: vi.fn(),
|
|
658
|
+
warn: vi.fn(),
|
|
659
|
+
error: vi.fn(),
|
|
660
|
+
};
|
|
661
|
+
transport = new WebhookEventTransport({
|
|
662
|
+
fetchFn: mockFetch,
|
|
663
|
+
defaultRetries: 0,
|
|
664
|
+
logger,
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
transport.addEndpoint({
|
|
668
|
+
id: "ssrf-metadata",
|
|
669
|
+
url: "https://metadata.example.com/hook",
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
transport.emit(createTestEvent());
|
|
673
|
+
|
|
674
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
675
|
+
|
|
676
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("should allow webhooks when hostname resolves to public IP", async () => {
|
|
680
|
+
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
681
|
+
dnsLookup.mockResolvedValueOnce({ address: "93.184.216.34", family: 4 });
|
|
682
|
+
|
|
683
|
+
transport.addEndpoint({
|
|
684
|
+
id: "public-ok",
|
|
685
|
+
url: "https://example.com/hook",
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
transport.emit(createTestEvent());
|
|
689
|
+
|
|
690
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
691
|
+
|
|
692
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
});
|