@multiplier-labs/stepflow 0.3.4 → 0.3.8
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/README.md +9 -0
- package/dist/{chunk-5XJBAPXF.js → chunk-4IDPWWGS.js} +15 -4
- package/dist/chunk-4IDPWWGS.js.map +1 -0
- package/dist/{chunk-U3PCVK6O.js → chunk-T6GAZBTQ.js} +70 -38
- package/dist/chunk-T6GAZBTQ.js.map +1 -0
- package/dist/{chunk-FMPJBK2N.js → chunk-TVSADUYU.js} +3 -5
- package/dist/{chunk-FMPJBK2N.js.map → chunk-TVSADUYU.js.map} +1 -1
- package/dist/events/index.d.ts +43 -3
- package/dist/events/index.js +7 -3
- package/dist/{index-Cd0IZ40L.d.ts → index-CdXDv30T.d.ts} +10 -2
- package/dist/index.d.ts +5 -4
- package/dist/index.js +159 -46
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +2 -2
- package/dist/scheduler/index.js +1 -1
- package/dist/storage/index.d.ts +6 -4
- package/dist/storage/index.js +1 -1
- package/dist/{types-DSEjiLMj.d.ts → types-BfWe5yP6.d.ts} +2 -0
- package/package.json +26 -14
- package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +0 -146
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +0 -74
- package/.codekin/reports/comments/2026-04-04_comment-assessment.md +0 -179
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +0 -161
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +0 -34
- package/.codekin/reports/complexity/2026-04-03_complexity-report.md +0 -152
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +0 -152
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +0 -34
- package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +0 -94
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +0 -93
- package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +0 -10
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +0 -138
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +0 -121
- package/.codekin/reports/security/2026-04-03_security-audit.md +0 -208
- package/.codekin/reports/security/2026-04-11_security-audit.md +0 -297
- package/.github/CODEOWNERS +0 -15
- package/.github/dependabot.yml +0 -23
- package/.github/workflows/ci.yml +0 -38
- package/.github/workflows/publish.yml +0 -42
- package/.github/workflows/security.yml +0 -29
- package/CHANGELOG.md +0 -34
- package/SECURITY.md +0 -40
- package/dist/chunk-5XJBAPXF.js.map +0 -1
- package/dist/chunk-U3PCVK6O.js.map +0 -1
- package/docs/github-packages-setup.md +0 -97
- package/docs/stepflow-api-reference.md +0 -1663
- package/src/core/engine/concurrency.ts +0 -229
- package/src/core/engine/lifecycle.ts +0 -176
- package/src/core/engine.test.ts +0 -1435
- package/src/core/engine.ts +0 -598
- package/src/core/index.ts +0 -7
- package/src/core/orchestrator/persistence.ts +0 -165
- package/src/core/orchestrator/plan-resolution.ts +0 -140
- package/src/core/orchestrator/state-transitions.ts +0 -109
- package/src/core/orchestrator/step-runner.ts +0 -427
- package/src/core/orchestrator.ts +0 -252
- package/src/core/types.ts +0 -220
- package/src/events/index.ts +0 -8
- package/src/events/memory.test.ts +0 -107
- package/src/events/memory.ts +0 -92
- package/src/events/socketio.test.ts +0 -378
- package/src/events/socketio.ts +0 -262
- package/src/events/types.ts +0 -118
- package/src/events/webhook.test.ts +0 -949
- package/src/events/webhook.ts +0 -807
- package/src/index.ts +0 -270
- package/src/planning/evaluate-condition.test.ts +0 -153
- package/src/planning/evaluate-condition.ts +0 -105
- package/src/planning/index.ts +0 -55
- package/src/planning/planner.ts +0 -494
- package/src/planning/planning.test.ts +0 -727
- package/src/planning/registry.test.ts +0 -398
- package/src/planning/registry.ts +0 -283
- package/src/planning/types.ts +0 -566
- package/src/scheduler/cron.test.ts +0 -479
- package/src/scheduler/cron.ts +0 -438
- package/src/scheduler/index.ts +0 -8
- package/src/scheduler/postgres-persistence.ts +0 -530
- package/src/scheduler/sqlite-persistence.test.ts +0 -235
- package/src/scheduler/sqlite-persistence.ts +0 -286
- package/src/scheduler/types.ts +0 -73
- package/src/storage/index.ts +0 -27
- package/src/storage/memory.test.ts +0 -349
- package/src/storage/memory.ts +0 -227
- package/src/storage/postgres-core.ts +0 -550
- package/src/storage/postgres-migration.test.ts +0 -69
- package/src/storage/postgres-transaction-pagination.test.ts +0 -107
- package/src/storage/postgres.ts +0 -835
- package/src/storage/sqlite-core.ts +0 -151
- package/src/storage/sqlite.test.ts +0 -642
- package/src/storage/sqlite.ts +0 -717
- package/src/storage/types.ts +0 -343
- package/src/utils/errors.test.ts +0 -150
- package/src/utils/errors.ts +0 -161
- package/src/utils/id.ts +0 -21
- package/src/utils/index.ts +0 -10
- package/src/utils/logger.test.ts +0 -121
- package/src/utils/logger.ts +0 -96
- package/src/utils/pinned-fetch.test.ts +0 -84
- package/src/utils/pinned-fetch.ts +0 -118
- package/src/utils/postgres-deps.ts +0 -79
- package/src/utils/retry.test.ts +0 -177
- package/src/utils/retry.ts +0 -121
- package/src/utils/safe-json.test.ts +0 -166
- package/src/utils/safe-json.ts +0 -107
- package/src/utils/webhook-signature.test.ts +0 -111
- package/src/utils/webhook-signature.ts +0 -74
- package/tsconfig.json +0 -21
- package/tsup.config.ts +0 -14
- package/vitest.config.ts +0 -9
|
@@ -1,949 +0,0 @@
|
|
|
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(overrides: Partial<WorkflowEvent> = {}): WorkflowEvent {
|
|
13
|
-
return {
|
|
14
|
-
eventType: 'run.started',
|
|
15
|
-
runId: 'run-123',
|
|
16
|
-
kind: 'test.workflow',
|
|
17
|
-
timestamp: new Date('2024-01-01T12:00:00Z'),
|
|
18
|
-
...overrides,
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe('WebhookEventTransport', () => {
|
|
23
|
-
let transport: WebhookEventTransport;
|
|
24
|
-
let mockFetch: ReturnType<typeof vi.fn>;
|
|
25
|
-
|
|
26
|
-
beforeEach(() => {
|
|
27
|
-
mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
|
28
|
-
transport = new WebhookEventTransport({
|
|
29
|
-
fetchFn: mockFetch,
|
|
30
|
-
defaultTimeout: 1000,
|
|
31
|
-
defaultRetries: 0, // Disable retries for faster tests
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
afterEach(() => {
|
|
36
|
-
transport.close();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe('addEndpoint / removeEndpoint', () => {
|
|
40
|
-
it('should add an endpoint', () => {
|
|
41
|
-
const endpoint: WebhookEndpoint = {
|
|
42
|
-
id: 'test-1',
|
|
43
|
-
url: 'https://example.com/webhook',
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
transport.addEndpoint(endpoint);
|
|
47
|
-
|
|
48
|
-
expect(transport.getEndpoints()).toHaveLength(1);
|
|
49
|
-
expect(transport.getEndpoints()[0].id).toBe('test-1');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('should add endpoint with enabled=true by default', () => {
|
|
53
|
-
transport.addEndpoint({
|
|
54
|
-
id: 'test-1',
|
|
55
|
-
url: 'https://example.com/webhook',
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
expect(transport.getEndpoints()[0].enabled).toBe(true);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('should remove an endpoint', () => {
|
|
62
|
-
transport.addEndpoint({
|
|
63
|
-
id: 'test-1',
|
|
64
|
-
url: 'https://example.com/webhook',
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
const removed = transport.removeEndpoint('test-1');
|
|
68
|
-
|
|
69
|
-
expect(removed).toBe(true);
|
|
70
|
-
expect(transport.getEndpoints()).toHaveLength(0);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('should return false when removing non-existent endpoint', () => {
|
|
74
|
-
const removed = transport.removeEndpoint('nonexistent');
|
|
75
|
-
expect(removed).toBe(false);
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe('setEndpointEnabled', () => {
|
|
80
|
-
it('should enable/disable an endpoint', () => {
|
|
81
|
-
transport.addEndpoint({
|
|
82
|
-
id: 'test-1',
|
|
83
|
-
url: 'https://example.com/webhook',
|
|
84
|
-
enabled: true,
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
transport.setEndpointEnabled('test-1', false);
|
|
88
|
-
expect(transport.getEndpoints()[0].enabled).toBe(false);
|
|
89
|
-
|
|
90
|
-
transport.setEndpointEnabled('test-1', true);
|
|
91
|
-
expect(transport.getEndpoints()[0].enabled).toBe(true);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('should do nothing for non-existent endpoint', () => {
|
|
95
|
-
transport.setEndpointEnabled('nonexistent', false);
|
|
96
|
-
// Should not throw
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe('emit', () => {
|
|
101
|
-
it('should send event to enabled endpoints', async () => {
|
|
102
|
-
transport.addEndpoint({
|
|
103
|
-
id: 'test-1',
|
|
104
|
-
url: 'https://example.com/webhook',
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
const event = createTestEvent();
|
|
108
|
-
transport.emit(event);
|
|
109
|
-
|
|
110
|
-
// Wait for async webhook
|
|
111
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
112
|
-
|
|
113
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
114
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
115
|
-
'https://example.com/webhook',
|
|
116
|
-
expect.objectContaining({
|
|
117
|
-
method: 'POST',
|
|
118
|
-
headers: expect.objectContaining({
|
|
119
|
-
'Content-Type': 'application/json',
|
|
120
|
-
}),
|
|
121
|
-
})
|
|
122
|
-
);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('should not send to disabled endpoints', async () => {
|
|
126
|
-
transport.addEndpoint({
|
|
127
|
-
id: 'test-1',
|
|
128
|
-
url: 'https://example.com/webhook',
|
|
129
|
-
enabled: false,
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
const event = createTestEvent();
|
|
133
|
-
transport.emit(event);
|
|
134
|
-
|
|
135
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
136
|
-
|
|
137
|
-
expect(mockFetch).not.toHaveBeenCalled();
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('should include custom headers', async () => {
|
|
141
|
-
transport.addEndpoint({
|
|
142
|
-
id: 'test-1',
|
|
143
|
-
url: 'https://example.com/webhook',
|
|
144
|
-
headers: { 'X-Custom-Header': 'custom-value' },
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
transport.emit(createTestEvent());
|
|
148
|
-
|
|
149
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
150
|
-
|
|
151
|
-
const callArgs = mockFetch.mock.calls[0];
|
|
152
|
-
expect(callArgs[1].headers['X-Custom-Header']).toBe('custom-value');
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('should serialize payload correctly', async () => {
|
|
156
|
-
transport.addEndpoint({
|
|
157
|
-
id: 'test-1',
|
|
158
|
-
url: 'https://example.com/webhook',
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
const event = createTestEvent({
|
|
162
|
-
runId: 'run-456',
|
|
163
|
-
kind: 'my.workflow',
|
|
164
|
-
});
|
|
165
|
-
transport.emit(event);
|
|
166
|
-
|
|
167
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
168
|
-
|
|
169
|
-
const callArgs = mockFetch.mock.calls[0];
|
|
170
|
-
const body = JSON.parse(callArgs[1].body);
|
|
171
|
-
|
|
172
|
-
expect(body.event.runId).toBe('run-456');
|
|
173
|
-
expect(body.event.kind).toBe('my.workflow');
|
|
174
|
-
expect(body.event.timestamp).toBe('2024-01-01T12:00:00.000Z');
|
|
175
|
-
expect(body.webhookId).toBe('test-1');
|
|
176
|
-
expect(body.deliveredAt).toBeDefined();
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
describe('event filtering', () => {
|
|
181
|
-
it('should filter by event type', async () => {
|
|
182
|
-
transport.addEndpoint({
|
|
183
|
-
id: 'test-1',
|
|
184
|
-
url: 'https://example.com/webhook',
|
|
185
|
-
eventTypes: ['run.completed', 'run.failed'],
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
// Should be filtered out
|
|
189
|
-
transport.emit(createTestEvent({ eventType: 'run.started' }));
|
|
190
|
-
|
|
191
|
-
// Should be sent
|
|
192
|
-
transport.emit(createTestEvent({ eventType: 'run.completed' }));
|
|
193
|
-
|
|
194
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
195
|
-
|
|
196
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('should filter by workflow kind', async () => {
|
|
200
|
-
transport.addEndpoint({
|
|
201
|
-
id: 'test-1',
|
|
202
|
-
url: 'https://example.com/webhook',
|
|
203
|
-
workflowKinds: ['order.process', 'order.ship'],
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
// Should be filtered out
|
|
207
|
-
transport.emit(createTestEvent({ kind: 'user.notify' }));
|
|
208
|
-
|
|
209
|
-
// Should be sent
|
|
210
|
-
transport.emit(createTestEvent({ kind: 'order.process' }));
|
|
211
|
-
|
|
212
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
213
|
-
|
|
214
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it('should apply both filters (AND logic)', async () => {
|
|
218
|
-
transport.addEndpoint({
|
|
219
|
-
id: 'test-1',
|
|
220
|
-
url: 'https://example.com/webhook',
|
|
221
|
-
eventTypes: ['run.completed'],
|
|
222
|
-
workflowKinds: ['order.process'],
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
// Wrong event type
|
|
226
|
-
transport.emit(
|
|
227
|
-
createTestEvent({
|
|
228
|
-
eventType: 'run.started',
|
|
229
|
-
kind: 'order.process',
|
|
230
|
-
})
|
|
231
|
-
);
|
|
232
|
-
|
|
233
|
-
// Wrong workflow kind
|
|
234
|
-
transport.emit(
|
|
235
|
-
createTestEvent({
|
|
236
|
-
eventType: 'run.completed',
|
|
237
|
-
kind: 'user.notify',
|
|
238
|
-
})
|
|
239
|
-
);
|
|
240
|
-
|
|
241
|
-
// Both match
|
|
242
|
-
transport.emit(
|
|
243
|
-
createTestEvent({
|
|
244
|
-
eventType: 'run.completed',
|
|
245
|
-
kind: 'order.process',
|
|
246
|
-
})
|
|
247
|
-
);
|
|
248
|
-
|
|
249
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
250
|
-
|
|
251
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it('should send to all endpoints when no filters', async () => {
|
|
255
|
-
transport.addEndpoint({
|
|
256
|
-
id: 'test-1',
|
|
257
|
-
url: 'https://example.com/webhook1',
|
|
258
|
-
});
|
|
259
|
-
transport.addEndpoint({
|
|
260
|
-
id: 'test-2',
|
|
261
|
-
url: 'https://example.com/webhook2',
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
transport.emit(createTestEvent());
|
|
265
|
-
|
|
266
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
267
|
-
|
|
268
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
describe('HMAC signing', () => {
|
|
273
|
-
it('should sign payload when secret is provided', async () => {
|
|
274
|
-
transport.addEndpoint({
|
|
275
|
-
id: 'test-1',
|
|
276
|
-
url: 'https://example.com/webhook',
|
|
277
|
-
secret: 'my-secret-key',
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
transport.emit(createTestEvent());
|
|
281
|
-
|
|
282
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
283
|
-
|
|
284
|
-
const callArgs = mockFetch.mock.calls[0];
|
|
285
|
-
const signature = callArgs[1].headers['X-Webhook-Signature'];
|
|
286
|
-
|
|
287
|
-
expect(signature).toBeDefined();
|
|
288
|
-
expect(signature).toMatch(/^sha256=[a-f0-9]{64}$/);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
it('should not include signature header when no secret', async () => {
|
|
292
|
-
transport.addEndpoint({
|
|
293
|
-
id: 'test-1',
|
|
294
|
-
url: 'https://example.com/webhook',
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
transport.emit(createTestEvent());
|
|
298
|
-
|
|
299
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
300
|
-
|
|
301
|
-
const callArgs = mockFetch.mock.calls[0];
|
|
302
|
-
expect(callArgs[1].headers['X-Webhook-Signature']).toBeUndefined();
|
|
303
|
-
});
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
describe('retries', () => {
|
|
307
|
-
it('should retry on failure', async () => {
|
|
308
|
-
mockFetch
|
|
309
|
-
.mockRejectedValueOnce(new Error('Network error'))
|
|
310
|
-
.mockRejectedValueOnce(new Error('Network error'))
|
|
311
|
-
.mockResolvedValueOnce({ ok: true });
|
|
312
|
-
|
|
313
|
-
transport = new WebhookEventTransport({
|
|
314
|
-
fetchFn: mockFetch,
|
|
315
|
-
defaultTimeout: 1000,
|
|
316
|
-
defaultRetries: 3,
|
|
317
|
-
retryDelay: 10, // Fast retries for tests
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
transport.addEndpoint({
|
|
321
|
-
id: 'test-1',
|
|
322
|
-
url: 'https://example.com/webhook',
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
transport.emit(createTestEvent());
|
|
326
|
-
|
|
327
|
-
// Wait for retries
|
|
328
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
329
|
-
|
|
330
|
-
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it('should retry on non-ok response', async () => {
|
|
334
|
-
mockFetch
|
|
335
|
-
.mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error' })
|
|
336
|
-
.mockResolvedValueOnce({ ok: true });
|
|
337
|
-
|
|
338
|
-
transport = new WebhookEventTransport({
|
|
339
|
-
fetchFn: mockFetch,
|
|
340
|
-
defaultTimeout: 1000,
|
|
341
|
-
defaultRetries: 3,
|
|
342
|
-
retryDelay: 10,
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
transport.addEndpoint({
|
|
346
|
-
id: 'test-1',
|
|
347
|
-
url: 'https://example.com/webhook',
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
transport.emit(createTestEvent());
|
|
351
|
-
|
|
352
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
353
|
-
|
|
354
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
355
|
-
});
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
describe('subscribe / subscribeAll', () => {
|
|
359
|
-
it('should call local subscribers on emit', () => {
|
|
360
|
-
const callback = vi.fn();
|
|
361
|
-
transport.subscribe('run-123', callback);
|
|
362
|
-
|
|
363
|
-
const event = createTestEvent({ runId: 'run-123' });
|
|
364
|
-
transport.emit(event);
|
|
365
|
-
|
|
366
|
-
expect(callback).toHaveBeenCalledWith(event);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it('should not call subscriber for different runId', () => {
|
|
370
|
-
const callback = vi.fn();
|
|
371
|
-
transport.subscribe('run-123', callback);
|
|
372
|
-
|
|
373
|
-
const event = createTestEvent({ runId: 'run-456' });
|
|
374
|
-
transport.emit(event);
|
|
375
|
-
|
|
376
|
-
expect(callback).not.toHaveBeenCalled();
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
it('should call global subscribers for all events', () => {
|
|
380
|
-
const callback = vi.fn();
|
|
381
|
-
transport.subscribeAll(callback);
|
|
382
|
-
|
|
383
|
-
transport.emit(createTestEvent({ runId: 'run-1' }));
|
|
384
|
-
transport.emit(createTestEvent({ runId: 'run-2' }));
|
|
385
|
-
|
|
386
|
-
expect(callback).toHaveBeenCalledTimes(2);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
it('should unsubscribe correctly', () => {
|
|
390
|
-
const callback = vi.fn();
|
|
391
|
-
const unsubscribe = transport.subscribe('run-123', callback);
|
|
392
|
-
|
|
393
|
-
unsubscribe();
|
|
394
|
-
|
|
395
|
-
transport.emit(createTestEvent({ runId: 'run-123' }));
|
|
396
|
-
expect(callback).not.toHaveBeenCalled();
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
it('should handle callback errors gracefully', () => {
|
|
400
|
-
const errorCallback = vi.fn().mockImplementation(() => {
|
|
401
|
-
throw new Error('Callback error');
|
|
402
|
-
});
|
|
403
|
-
const normalCallback = vi.fn();
|
|
404
|
-
|
|
405
|
-
transport.subscribe('run-123', errorCallback);
|
|
406
|
-
transport.subscribe('run-123', normalCallback);
|
|
407
|
-
|
|
408
|
-
transport.emit(createTestEvent({ runId: 'run-123' }));
|
|
409
|
-
|
|
410
|
-
expect(errorCallback).toHaveBeenCalled();
|
|
411
|
-
expect(normalCallback).toHaveBeenCalled();
|
|
412
|
-
});
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
describe('close', () => {
|
|
416
|
-
it('should clear all endpoints and subscribers', () => {
|
|
417
|
-
transport.addEndpoint({
|
|
418
|
-
id: 'test-1',
|
|
419
|
-
url: 'https://example.com/webhook',
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
const callback = vi.fn();
|
|
423
|
-
transport.subscribe('run-123', callback);
|
|
424
|
-
transport.subscribeAll(vi.fn());
|
|
425
|
-
|
|
426
|
-
transport.close();
|
|
427
|
-
|
|
428
|
-
expect(transport.getEndpoints()).toHaveLength(0);
|
|
429
|
-
|
|
430
|
-
// Callback should not be called after close
|
|
431
|
-
transport.emit(createTestEvent({ runId: 'run-123' }));
|
|
432
|
-
expect(callback).not.toHaveBeenCalled();
|
|
433
|
-
});
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
describe('SSRF protection - isBlockedHost', () => {
|
|
437
|
-
it('should block 10.x.x.x private range', () => {
|
|
438
|
-
expect(() =>
|
|
439
|
-
transport.addEndpoint({ id: 'ssrf-1', url: 'https://10.0.0.1/hook' })
|
|
440
|
-
).toThrow(/blocked/);
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
it('should block 172.16-31.x.x private range', () => {
|
|
444
|
-
expect(() =>
|
|
445
|
-
transport.addEndpoint({ id: 'ssrf-2', url: 'https://172.16.0.1/hook' })
|
|
446
|
-
).toThrow(/blocked/);
|
|
447
|
-
expect(() =>
|
|
448
|
-
transport.addEndpoint({ id: 'ssrf-3', url: 'https://172.31.255.255/hook' })
|
|
449
|
-
).toThrow(/blocked/);
|
|
450
|
-
// 172.32 should NOT be blocked
|
|
451
|
-
transport.addEndpoint({ id: 'ssrf-ok', url: 'https://172.32.0.1/hook' });
|
|
452
|
-
expect(transport.getEndpoints().find(e => e.id === 'ssrf-ok')).toBeDefined();
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
it('should block 192.168.x.x private range', () => {
|
|
456
|
-
expect(() =>
|
|
457
|
-
transport.addEndpoint({ id: 'ssrf-4', url: 'https://192.168.1.1/hook' })
|
|
458
|
-
).toThrow(/blocked/);
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
it('should block 169.254.x.x link-local range', () => {
|
|
462
|
-
expect(() =>
|
|
463
|
-
transport.addEndpoint({ id: 'ssrf-5', url: 'https://169.254.1.1/hook' })
|
|
464
|
-
).toThrow(/blocked/);
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
it('should block 0.0.0.0', () => {
|
|
468
|
-
expect(() =>
|
|
469
|
-
transport.addEndpoint({ id: 'ssrf-6', url: 'https://0.0.0.0/hook' })
|
|
470
|
-
).toThrow(/blocked/);
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
it('should block IPv6 link-local (fe80::)', () => {
|
|
474
|
-
expect(() =>
|
|
475
|
-
transport.addEndpoint({ id: 'ssrf-7', url: 'https://[fe80::1]/hook' })
|
|
476
|
-
).toThrow(/blocked/);
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
it('should block localhost and 127.x.x.x', () => {
|
|
480
|
-
expect(() =>
|
|
481
|
-
transport.addEndpoint({ id: 'ssrf-8', url: 'https://localhost/hook' })
|
|
482
|
-
).toThrow(/blocked/);
|
|
483
|
-
expect(() =>
|
|
484
|
-
transport.addEndpoint({ id: 'ssrf-9', url: 'https://127.0.0.1/hook' })
|
|
485
|
-
).toThrow(/blocked/);
|
|
486
|
-
expect(() =>
|
|
487
|
-
transport.addEndpoint({ id: 'ssrf-10', url: 'https://127.0.0.2/hook' })
|
|
488
|
-
).toThrow(/blocked/);
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
it('should block cloud metadata endpoint', () => {
|
|
492
|
-
expect(() =>
|
|
493
|
-
transport.addEndpoint({ id: 'ssrf-11', url: 'https://169.254.169.254/latest/meta-data' })
|
|
494
|
-
).toThrow(/blocked/);
|
|
495
|
-
});
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
describe('SSRF protection - IPv6 coverage', () => {
|
|
499
|
-
it('should block IPv6 loopback ::1 (in any abbreviated form)', () => {
|
|
500
|
-
expect(() =>
|
|
501
|
-
transport.addEndpoint({ id: 'v6-loop-1', url: 'https://[::1]/hook' })
|
|
502
|
-
).toThrow(/blocked/);
|
|
503
|
-
expect(() =>
|
|
504
|
-
transport.addEndpoint({ id: 'v6-loop-2', url: 'https://[0000:0000:0000:0000:0000:0000:0000:0001]/hook' })
|
|
505
|
-
).toThrow(/blocked/);
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
it('should block IPv6 unspecified ::', () => {
|
|
509
|
-
expect(() =>
|
|
510
|
-
transport.addEndpoint({ id: 'v6-unspec', url: 'https://[::]/hook' })
|
|
511
|
-
).toThrow(/blocked/);
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
it('should block link-local fe80::/10 across the full range', () => {
|
|
515
|
-
expect(() =>
|
|
516
|
-
transport.addEndpoint({ id: 'v6-ll-1', url: 'https://[fe80::1]/hook' })
|
|
517
|
-
).toThrow(/blocked/);
|
|
518
|
-
expect(() =>
|
|
519
|
-
transport.addEndpoint({ id: 'v6-ll-2', url: 'https://[febf::abcd]/hook' })
|
|
520
|
-
).toThrow(/blocked/);
|
|
521
|
-
// fec0:: is NOT link-local (deprecated site-local), should not be caught by fe80::/10
|
|
522
|
-
// but fe7f:: is below the range and should be allowed
|
|
523
|
-
transport.addEndpoint({ id: 'v6-ll-ok', url: 'https://[fe7f::1]/hook' });
|
|
524
|
-
expect(transport.getEndpoints().find(e => e.id === 'v6-ll-ok')).toBeDefined();
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
it('should block ULA fc00::/7 (fc and fd prefixes)', () => {
|
|
528
|
-
expect(() =>
|
|
529
|
-
transport.addEndpoint({ id: 'v6-ula-1', url: 'https://[fc00::1]/hook' })
|
|
530
|
-
).toThrow(/blocked/);
|
|
531
|
-
expect(() =>
|
|
532
|
-
transport.addEndpoint({ id: 'v6-ula-2', url: 'https://[fd12:3456::abcd]/hook' })
|
|
533
|
-
).toThrow(/blocked/);
|
|
534
|
-
expect(() =>
|
|
535
|
-
transport.addEndpoint({ id: 'v6-ula-3', url: 'https://[fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff]/hook' })
|
|
536
|
-
).toThrow(/blocked/);
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
it('should block IPv4-mapped ::ffff:0:0/96 by decoding embedded IPv4', () => {
|
|
540
|
-
// ::ffff:127.0.0.1 → loopback
|
|
541
|
-
expect(() =>
|
|
542
|
-
transport.addEndpoint({ id: 'v6-map-1', url: 'https://[::ffff:127.0.0.1]/hook' })
|
|
543
|
-
).toThrow(/blocked/);
|
|
544
|
-
// ::ffff:10.0.0.1 → RFC 1918
|
|
545
|
-
expect(() =>
|
|
546
|
-
transport.addEndpoint({ id: 'v6-map-2', url: 'https://[::ffff:10.0.0.1]/hook' })
|
|
547
|
-
).toThrow(/blocked/);
|
|
548
|
-
// ::ffff:169.254.169.254 → cloud metadata
|
|
549
|
-
expect(() =>
|
|
550
|
-
transport.addEndpoint({ id: 'v6-map-3', url: 'https://[::ffff:169.254.169.254]/hook' })
|
|
551
|
-
).toThrow(/blocked/);
|
|
552
|
-
// Hex form ::ffff:c0a8:0001 == ::ffff:192.168.0.1
|
|
553
|
-
expect(() =>
|
|
554
|
-
transport.addEndpoint({ id: 'v6-map-4', url: 'https://[::ffff:c0a8:0001]/hook' })
|
|
555
|
-
).toThrow(/blocked/);
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
it('should block 6to4 2002::/16 by decoding embedded IPv4', () => {
|
|
559
|
-
// 2002:7f00:0001:: == 6to4 wrapping 127.0.0.1
|
|
560
|
-
expect(() =>
|
|
561
|
-
transport.addEndpoint({ id: 'v6-6to4-loop', url: 'https://[2002:7f00:0001::]/hook' })
|
|
562
|
-
).toThrow(/blocked/);
|
|
563
|
-
// 2002:c0a8:0001:: == 6to4 wrapping 192.168.0.1
|
|
564
|
-
expect(() =>
|
|
565
|
-
transport.addEndpoint({ id: 'v6-6to4-1918', url: 'https://[2002:c0a8:0001::]/hook' })
|
|
566
|
-
).toThrow(/blocked/);
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
it('should block Teredo 2001::/32', () => {
|
|
570
|
-
expect(() =>
|
|
571
|
-
transport.addEndpoint({ id: 'v6-teredo', url: 'https://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/hook' })
|
|
572
|
-
).toThrow(/blocked/);
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
it('should block IPv6 multicast ff00::/8', () => {
|
|
576
|
-
expect(() =>
|
|
577
|
-
transport.addEndpoint({ id: 'v6-mc', url: 'https://[ff02::1]/hook' })
|
|
578
|
-
).toThrow(/blocked/);
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
it('should allow legitimate public IPv6 addresses', () => {
|
|
582
|
-
// Google Public DNS — must not be blocked
|
|
583
|
-
transport.addEndpoint({ id: 'v6-pub-1', url: 'https://[2001:4860:4860::8888]/hook' });
|
|
584
|
-
// Cloudflare DNS
|
|
585
|
-
transport.addEndpoint({ id: 'v6-pub-2', url: 'https://[2606:4700:4700::1111]/hook' });
|
|
586
|
-
expect(transport.getEndpoints().find(e => e.id === 'v6-pub-1')).toBeDefined();
|
|
587
|
-
expect(transport.getEndpoints().find(e => e.id === 'v6-pub-2')).toBeDefined();
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
it('should block when DNS resolves to an IPv6 ULA address', async () => {
|
|
591
|
-
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
592
|
-
dnsLookup.mockResolvedValueOnce({ address: 'fd00::1', family: 6 });
|
|
593
|
-
|
|
594
|
-
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
595
|
-
transport = new WebhookEventTransport({
|
|
596
|
-
fetchFn: mockFetch,
|
|
597
|
-
defaultRetries: 0,
|
|
598
|
-
logger,
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
transport.addEndpoint({
|
|
602
|
-
id: 'v6-dns-ula',
|
|
603
|
-
url: 'https://internal.example.com/hook',
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
transport.emit(createTestEvent());
|
|
607
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
608
|
-
|
|
609
|
-
expect(mockFetch).not.toHaveBeenCalled();
|
|
610
|
-
expect(logger.error).toHaveBeenCalledWith(
|
|
611
|
-
expect.stringContaining('v6-dns-ula'),
|
|
612
|
-
expect.objectContaining({
|
|
613
|
-
message: expect.stringContaining('resolves to blocked IP'),
|
|
614
|
-
})
|
|
615
|
-
);
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
it('should block when DNS resolves to an IPv4-mapped IPv6 loopback', async () => {
|
|
619
|
-
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
620
|
-
dnsLookup.mockResolvedValueOnce({ address: '::ffff:127.0.0.1', family: 6 });
|
|
621
|
-
|
|
622
|
-
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
623
|
-
transport = new WebhookEventTransport({
|
|
624
|
-
fetchFn: mockFetch,
|
|
625
|
-
defaultRetries: 0,
|
|
626
|
-
logger,
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
transport.addEndpoint({
|
|
630
|
-
id: 'v6-dns-mapped',
|
|
631
|
-
url: 'https://sneaky.example.com/hook',
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
transport.emit(createTestEvent());
|
|
635
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
636
|
-
|
|
637
|
-
expect(mockFetch).not.toHaveBeenCalled();
|
|
638
|
-
});
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
describe('queue back-pressure (memory exhaustion guard)', () => {
|
|
642
|
-
it('should drop deliveries beyond maxQueueDepth and log a warning', async () => {
|
|
643
|
-
// Hold every fetch open so the active slot fills and subsequent emits queue.
|
|
644
|
-
let resolveFetch: ((v: { ok: true }) => void) | undefined;
|
|
645
|
-
const blockingFetch = vi.fn().mockImplementation(
|
|
646
|
-
() => new Promise<{ ok: true }>((r) => { resolveFetch = r; })
|
|
647
|
-
);
|
|
648
|
-
|
|
649
|
-
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
650
|
-
|
|
651
|
-
transport = new WebhookEventTransport({
|
|
652
|
-
fetchFn: blockingFetch as unknown as typeof fetch,
|
|
653
|
-
defaultRetries: 0,
|
|
654
|
-
maxConcurrentRequests: 1,
|
|
655
|
-
maxQueueDepth: 2,
|
|
656
|
-
logger,
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
transport.addEndpoint({ id: 'cap-test', url: 'https://example.com/hook' });
|
|
660
|
-
|
|
661
|
-
// 1st emit → consumes the active slot.
|
|
662
|
-
// 2nd, 3rd emit → fill the queue (depth 2/2).
|
|
663
|
-
// 4th, 5th emit → must be dropped.
|
|
664
|
-
for (let i = 0; i < 5; i++) {
|
|
665
|
-
transport.emit(createTestEvent({ runId: `run-${i}` }));
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// Let the dispatch loop run.
|
|
669
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
670
|
-
|
|
671
|
-
expect(transport.getDroppedRequestCount()).toBe(2);
|
|
672
|
-
expect(transport.getQueueDepth()).toBe(2);
|
|
673
|
-
expect(logger.warn).toHaveBeenCalledTimes(2);
|
|
674
|
-
expect(logger.warn).toHaveBeenCalledWith(
|
|
675
|
-
expect.stringMatching(/cap-test delivery dropped: queue full/)
|
|
676
|
-
);
|
|
677
|
-
expect(logger.warn).toHaveBeenCalledWith(
|
|
678
|
-
expect.stringMatching(/retryAfterMs 1000/)
|
|
679
|
-
);
|
|
680
|
-
|
|
681
|
-
// Drain the in-flight request so .close() doesn't hang.
|
|
682
|
-
resolveFetch?.({ ok: true });
|
|
683
|
-
await new Promise((r) => setTimeout(r, 20));
|
|
684
|
-
});
|
|
685
|
-
|
|
686
|
-
it('should resume accepting deliveries after the queue drains', async () => {
|
|
687
|
-
let pendingResolvers: Array<(v: { ok: true }) => void> = [];
|
|
688
|
-
const blockingFetch = vi.fn().mockImplementation(
|
|
689
|
-
() => new Promise<{ ok: true }>((r) => { pendingResolvers.push(r); })
|
|
690
|
-
);
|
|
691
|
-
|
|
692
|
-
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
693
|
-
|
|
694
|
-
transport = new WebhookEventTransport({
|
|
695
|
-
fetchFn: blockingFetch as unknown as typeof fetch,
|
|
696
|
-
defaultRetries: 0,
|
|
697
|
-
maxConcurrentRequests: 1,
|
|
698
|
-
maxQueueDepth: 1,
|
|
699
|
-
logger,
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
transport.addEndpoint({ id: 'drain', url: 'https://example.com/hook' });
|
|
703
|
-
|
|
704
|
-
// 1 active + 1 queued + 1 dropped
|
|
705
|
-
transport.emit(createTestEvent({ runId: 'a' }));
|
|
706
|
-
transport.emit(createTestEvent({ runId: 'b' }));
|
|
707
|
-
transport.emit(createTestEvent({ runId: 'c' }));
|
|
708
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
709
|
-
expect(transport.getDroppedRequestCount()).toBe(1);
|
|
710
|
-
|
|
711
|
-
// Drain everything in flight.
|
|
712
|
-
while (pendingResolvers.length > 0) {
|
|
713
|
-
const next = pendingResolvers.shift()!;
|
|
714
|
-
next({ ok: true });
|
|
715
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
716
|
-
}
|
|
717
|
-
// Allow any newly-promoted queued requests to start and resolve.
|
|
718
|
-
while (pendingResolvers.length > 0) {
|
|
719
|
-
const next = pendingResolvers.shift()!;
|
|
720
|
-
next({ ok: true });
|
|
721
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// Now a new emit should be accepted, not dropped.
|
|
725
|
-
transport.emit(createTestEvent({ runId: 'd' }));
|
|
726
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
727
|
-
expect(transport.getDroppedRequestCount()).toBe(1); // unchanged
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
it('should treat maxQueueDepth: 0 as unbounded', async () => {
|
|
731
|
-
let resolveFetch: ((v: { ok: true }) => void) | undefined;
|
|
732
|
-
const blockingFetch = vi.fn().mockImplementation(
|
|
733
|
-
() => new Promise<{ ok: true }>((r) => { resolveFetch = r; })
|
|
734
|
-
);
|
|
735
|
-
|
|
736
|
-
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
737
|
-
|
|
738
|
-
transport = new WebhookEventTransport({
|
|
739
|
-
fetchFn: blockingFetch as unknown as typeof fetch,
|
|
740
|
-
defaultRetries: 0,
|
|
741
|
-
maxConcurrentRequests: 1,
|
|
742
|
-
maxQueueDepth: 0, // explicit opt-out
|
|
743
|
-
logger,
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
transport.addEndpoint({ id: 'no-cap', url: 'https://example.com/hook' });
|
|
747
|
-
|
|
748
|
-
for (let i = 0; i < 50; i++) {
|
|
749
|
-
transport.emit(createTestEvent({ runId: `run-${i}` }));
|
|
750
|
-
}
|
|
751
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
752
|
-
|
|
753
|
-
expect(transport.getDroppedRequestCount()).toBe(0);
|
|
754
|
-
expect(logger.warn).not.toHaveBeenCalled();
|
|
755
|
-
|
|
756
|
-
resolveFetch?.({ ok: true });
|
|
757
|
-
await new Promise((r) => setTimeout(r, 20));
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
it('should reject negative maxQueueDepth at construction', () => {
|
|
761
|
-
expect(
|
|
762
|
-
() => new WebhookEventTransport({ maxQueueDepth: -1 })
|
|
763
|
-
).toThrow(/maxQueueDepth/);
|
|
764
|
-
});
|
|
765
|
-
});
|
|
766
|
-
|
|
767
|
-
describe('emit subscriber error isolation', () => {
|
|
768
|
-
it('should catch subscriber error and still call other subscribers', () => {
|
|
769
|
-
const errorCallback = vi.fn().mockImplementation(() => {
|
|
770
|
-
throw new Error('Subscriber exploded');
|
|
771
|
-
});
|
|
772
|
-
const normalCallback = vi.fn();
|
|
773
|
-
|
|
774
|
-
transport.subscribeAll(errorCallback);
|
|
775
|
-
transport.subscribeAll(normalCallback);
|
|
776
|
-
|
|
777
|
-
const event = createTestEvent();
|
|
778
|
-
transport.emit(event);
|
|
779
|
-
|
|
780
|
-
expect(errorCallback).toHaveBeenCalledWith(event);
|
|
781
|
-
expect(normalCallback).toHaveBeenCalledWith(event);
|
|
782
|
-
});
|
|
783
|
-
|
|
784
|
-
it('should catch run-subscriber error and still call other run-subscribers', () => {
|
|
785
|
-
const errorCallback = vi.fn().mockImplementation(() => {
|
|
786
|
-
throw new Error('Run subscriber failed');
|
|
787
|
-
});
|
|
788
|
-
const normalCallback = vi.fn();
|
|
789
|
-
|
|
790
|
-
transport.subscribe('run-123', errorCallback);
|
|
791
|
-
transport.subscribe('run-123', normalCallback);
|
|
792
|
-
|
|
793
|
-
const event = createTestEvent({ runId: 'run-123' });
|
|
794
|
-
transport.emit(event);
|
|
795
|
-
|
|
796
|
-
expect(errorCallback).toHaveBeenCalled();
|
|
797
|
-
expect(normalCallback).toHaveBeenCalled();
|
|
798
|
-
});
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
describe('close clears all state', () => {
|
|
802
|
-
it('should clear endpoints, run subscribers, and global subscribers after close', () => {
|
|
803
|
-
transport.addEndpoint({ id: 'ep-1', url: 'https://example.com/hook' });
|
|
804
|
-
const runCb = vi.fn();
|
|
805
|
-
const globalCb = vi.fn();
|
|
806
|
-
transport.subscribe('run-123', runCb);
|
|
807
|
-
transport.subscribeAll(globalCb);
|
|
808
|
-
|
|
809
|
-
transport.close();
|
|
810
|
-
|
|
811
|
-
expect(transport.getEndpoints()).toHaveLength(0);
|
|
812
|
-
|
|
813
|
-
// Subscribers should no longer receive events
|
|
814
|
-
transport.emit(createTestEvent({ runId: 'run-123' }));
|
|
815
|
-
expect(runCb).not.toHaveBeenCalled();
|
|
816
|
-
expect(globalCb).not.toHaveBeenCalled();
|
|
817
|
-
});
|
|
818
|
-
});
|
|
819
|
-
|
|
820
|
-
describe('constructor with initial endpoints', () => {
|
|
821
|
-
it('should register initial endpoints', () => {
|
|
822
|
-
transport = new WebhookEventTransport({
|
|
823
|
-
fetchFn: mockFetch,
|
|
824
|
-
endpoints: [
|
|
825
|
-
{ id: 'ep-1', url: 'https://example.com/1' },
|
|
826
|
-
{ id: 'ep-2', url: 'https://example.com/2' },
|
|
827
|
-
],
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
expect(transport.getEndpoints()).toHaveLength(2);
|
|
831
|
-
});
|
|
832
|
-
});
|
|
833
|
-
|
|
834
|
-
describe('SSRF protection - DNS resolution', () => {
|
|
835
|
-
it('should block webhooks when hostname resolves to private IP', async () => {
|
|
836
|
-
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
837
|
-
dnsLookup.mockResolvedValueOnce({ address: '10.0.0.5', family: 4 });
|
|
838
|
-
|
|
839
|
-
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
840
|
-
transport = new WebhookEventTransport({
|
|
841
|
-
fetchFn: mockFetch,
|
|
842
|
-
defaultRetries: 0,
|
|
843
|
-
logger,
|
|
844
|
-
});
|
|
845
|
-
|
|
846
|
-
transport.addEndpoint({
|
|
847
|
-
id: 'ssrf-dns',
|
|
848
|
-
url: 'https://evil.example.com/hook',
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
transport.emit(createTestEvent());
|
|
852
|
-
|
|
853
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
854
|
-
|
|
855
|
-
expect(mockFetch).not.toHaveBeenCalled();
|
|
856
|
-
expect(logger.error).toHaveBeenCalledWith(
|
|
857
|
-
expect.stringContaining('ssrf-dns'),
|
|
858
|
-
expect.objectContaining({
|
|
859
|
-
message: expect.stringContaining('resolves to blocked IP'),
|
|
860
|
-
})
|
|
861
|
-
);
|
|
862
|
-
});
|
|
863
|
-
|
|
864
|
-
it('should block webhooks when hostname resolves to loopback', async () => {
|
|
865
|
-
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
866
|
-
dnsLookup.mockResolvedValueOnce({ address: '127.0.0.1', family: 4 });
|
|
867
|
-
|
|
868
|
-
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
869
|
-
transport = new WebhookEventTransport({
|
|
870
|
-
fetchFn: mockFetch,
|
|
871
|
-
defaultRetries: 0,
|
|
872
|
-
logger,
|
|
873
|
-
});
|
|
874
|
-
|
|
875
|
-
transport.addEndpoint({
|
|
876
|
-
id: 'ssrf-loopback',
|
|
877
|
-
url: 'https://loopback.example.com/hook',
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
transport.emit(createTestEvent());
|
|
881
|
-
|
|
882
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
883
|
-
|
|
884
|
-
expect(mockFetch).not.toHaveBeenCalled();
|
|
885
|
-
});
|
|
886
|
-
|
|
887
|
-
it('should block webhooks when hostname resolves to cloud metadata IP', async () => {
|
|
888
|
-
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
889
|
-
dnsLookup.mockResolvedValueOnce({ address: '169.254.169.254', family: 4 });
|
|
890
|
-
|
|
891
|
-
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
892
|
-
transport = new WebhookEventTransport({
|
|
893
|
-
fetchFn: mockFetch,
|
|
894
|
-
defaultRetries: 0,
|
|
895
|
-
logger,
|
|
896
|
-
});
|
|
897
|
-
|
|
898
|
-
transport.addEndpoint({
|
|
899
|
-
id: 'ssrf-metadata',
|
|
900
|
-
url: 'https://metadata.example.com/hook',
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
transport.emit(createTestEvent());
|
|
904
|
-
|
|
905
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
906
|
-
|
|
907
|
-
expect(mockFetch).not.toHaveBeenCalled();
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
it('should allow webhooks when hostname resolves to public IP', async () => {
|
|
911
|
-
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
912
|
-
dnsLookup.mockResolvedValueOnce({ address: '93.184.216.34', family: 4 });
|
|
913
|
-
|
|
914
|
-
transport.addEndpoint({
|
|
915
|
-
id: 'public-ok',
|
|
916
|
-
url: 'https://example.com/hook',
|
|
917
|
-
});
|
|
918
|
-
|
|
919
|
-
transport.emit(createTestEvent());
|
|
920
|
-
|
|
921
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
922
|
-
|
|
923
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
924
|
-
});
|
|
925
|
-
|
|
926
|
-
it('resolves the hostname exactly once per webhook send (no rebinding TOCTOU window)', async () => {
|
|
927
|
-
// Audit M1: the previous impl validated DNS once and then handed the
|
|
928
|
-
// hostname to fetch, which re-resolved at connect time. Two lookups in
|
|
929
|
-
// the same delivery is the symptom we're guarding against — assert the
|
|
930
|
-
// counter does not advance past one when both validation and connect
|
|
931
|
-
// run in the same delivery.
|
|
932
|
-
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
933
|
-
dnsLookup.mockClear();
|
|
934
|
-
dnsLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 });
|
|
935
|
-
|
|
936
|
-
transport.addEndpoint({
|
|
937
|
-
id: 'one-lookup',
|
|
938
|
-
url: 'https://example.com/hook',
|
|
939
|
-
});
|
|
940
|
-
|
|
941
|
-
transport.emit(createTestEvent());
|
|
942
|
-
|
|
943
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
944
|
-
|
|
945
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
946
|
-
expect(dnsLookup).toHaveBeenCalledTimes(1);
|
|
947
|
-
});
|
|
948
|
-
});
|
|
949
|
-
});
|