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