@multiplier-labs/stepflow 0.3.1 → 0.3.4
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/code-review/2026-04-11_code-review-daily.md +146 -0
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
- package/.codekin/reports/comments/2026-04-04_comment-assessment.md +179 -0
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
- package/.codekin/reports/complexity/2026-04-03_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
- package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +94 -0
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
- package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -0
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
- package/.codekin/reports/security/2026-04-03_security-audit.md +208 -0
- package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
- package/.github/CODEOWNERS +15 -0
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/ci.yml +38 -0
- package/.github/workflows/publish.yml +17 -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-5XJBAPXF.js} +527 -551
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +60 -7
- package/dist/events/index.js +1 -1
- package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
- package/dist/index.d.ts +15 -23
- package/dist/index.js +784 -547
- 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 +322 -56
- package/dist/storage/index.js +4 -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 +5 -4
- package/package.json +12 -8
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +536 -522
- package/src/core/engine.ts +116 -235
- package/src/core/index.ts +3 -3
- package/src/core/orchestrator/persistence.ts +165 -0
- package/src/core/orchestrator/plan-resolution.ts +140 -0
- package/src/core/orchestrator/state-transitions.ts +109 -0
- package/src/core/orchestrator/step-runner.ts +427 -0
- package/src/core/orchestrator.ts +57 -395
- 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 +489 -235
- package/src/events/webhook.ts +355 -139
- package/src/index.ts +19 -19
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/index.ts +6 -3
- package/src/planning/planner.ts +45 -152
- package/src/planning/planning.test.ts +303 -366
- package/src/planning/registry.test.ts +204 -251
- package/src/planning/registry.ts +20 -106
- package/src/planning/types.ts +23 -27
- package/src/scheduler/cron.test.ts +149 -150
- package/src/scheduler/cron.ts +58 -69
- package/src/scheduler/index.ts +4 -14
- package/src/scheduler/postgres-persistence.ts +89 -105
- package/src/scheduler/sqlite-persistence.test.ts +96 -96
- package/src/scheduler/sqlite-persistence.ts +40 -22
- 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-core.ts +550 -0
- package/src/storage/postgres-migration.test.ts +69 -0
- package/src/storage/postgres-transaction-pagination.test.ts +107 -0
- package/src/storage/postgres.ts +222 -937
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +330 -281
- package/src/storage/sqlite.ts +225 -170
- 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 +6 -4
- package/src/utils/logger.test.ts +52 -58
- package/src/utils/logger.ts +4 -4
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +20 -4
- package/src/utils/retry.test.ts +47 -67
- package/src/utils/retry.ts +9 -9
- package/src/utils/safe-json.test.ts +166 -0
- package/src/utils/safe-json.ts +107 -0
- package/src/utils/webhook-signature.test.ts +111 -0
- package/src/utils/webhook-signature.ts +74 -0
- 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,346 @@ 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('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', () => {
|
|
520
769
|
const errorCallback = vi.fn().mockImplementation(() => {
|
|
521
|
-
throw new Error(
|
|
770
|
+
throw new Error('Subscriber exploded');
|
|
522
771
|
});
|
|
523
772
|
const normalCallback = vi.fn();
|
|
524
773
|
|
|
@@ -532,16 +781,16 @@ describe("WebhookEventTransport", () => {
|
|
|
532
781
|
expect(normalCallback).toHaveBeenCalledWith(event);
|
|
533
782
|
});
|
|
534
783
|
|
|
535
|
-
it(
|
|
784
|
+
it('should catch run-subscriber error and still call other run-subscribers', () => {
|
|
536
785
|
const errorCallback = vi.fn().mockImplementation(() => {
|
|
537
|
-
throw new Error(
|
|
786
|
+
throw new Error('Run subscriber failed');
|
|
538
787
|
});
|
|
539
788
|
const normalCallback = vi.fn();
|
|
540
789
|
|
|
541
|
-
transport.subscribe(
|
|
542
|
-
transport.subscribe(
|
|
790
|
+
transport.subscribe('run-123', errorCallback);
|
|
791
|
+
transport.subscribe('run-123', normalCallback);
|
|
543
792
|
|
|
544
|
-
const event = createTestEvent({ runId:
|
|
793
|
+
const event = createTestEvent({ runId: 'run-123' });
|
|
545
794
|
transport.emit(event);
|
|
546
795
|
|
|
547
796
|
expect(errorCallback).toHaveBeenCalled();
|
|
@@ -549,12 +798,12 @@ describe("WebhookEventTransport", () => {
|
|
|
549
798
|
});
|
|
550
799
|
});
|
|
551
800
|
|
|
552
|
-
describe(
|
|
553
|
-
it(
|
|
554
|
-
transport.addEndpoint({ id:
|
|
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' });
|
|
555
804
|
const runCb = vi.fn();
|
|
556
805
|
const globalCb = vi.fn();
|
|
557
|
-
transport.subscribe(
|
|
806
|
+
transport.subscribe('run-123', runCb);
|
|
558
807
|
transport.subscribeAll(globalCb);
|
|
559
808
|
|
|
560
809
|
transport.close();
|
|
@@ -562,19 +811,19 @@ describe("WebhookEventTransport", () => {
|
|
|
562
811
|
expect(transport.getEndpoints()).toHaveLength(0);
|
|
563
812
|
|
|
564
813
|
// Subscribers should no longer receive events
|
|
565
|
-
transport.emit(createTestEvent({ runId:
|
|
814
|
+
transport.emit(createTestEvent({ runId: 'run-123' }));
|
|
566
815
|
expect(runCb).not.toHaveBeenCalled();
|
|
567
816
|
expect(globalCb).not.toHaveBeenCalled();
|
|
568
817
|
});
|
|
569
818
|
});
|
|
570
819
|
|
|
571
|
-
describe(
|
|
572
|
-
it(
|
|
820
|
+
describe('constructor with initial endpoints', () => {
|
|
821
|
+
it('should register initial endpoints', () => {
|
|
573
822
|
transport = new WebhookEventTransport({
|
|
574
823
|
fetchFn: mockFetch,
|
|
575
824
|
endpoints: [
|
|
576
|
-
{ id:
|
|
577
|
-
{ id:
|
|
825
|
+
{ id: 'ep-1', url: 'https://example.com/1' },
|
|
826
|
+
{ id: 'ep-2', url: 'https://example.com/2' },
|
|
578
827
|
],
|
|
579
828
|
});
|
|
580
829
|
|
|
@@ -582,17 +831,12 @@ describe("WebhookEventTransport", () => {
|
|
|
582
831
|
});
|
|
583
832
|
});
|
|
584
833
|
|
|
585
|
-
describe(
|
|
586
|
-
it(
|
|
834
|
+
describe('SSRF protection - DNS resolution', () => {
|
|
835
|
+
it('should block webhooks when hostname resolves to private IP', async () => {
|
|
587
836
|
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
588
|
-
dnsLookup.mockResolvedValueOnce({ address:
|
|
837
|
+
dnsLookup.mockResolvedValueOnce({ address: '10.0.0.5', family: 4 });
|
|
589
838
|
|
|
590
|
-
const logger = {
|
|
591
|
-
debug: vi.fn(),
|
|
592
|
-
info: vi.fn(),
|
|
593
|
-
warn: vi.fn(),
|
|
594
|
-
error: vi.fn(),
|
|
595
|
-
};
|
|
839
|
+
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
596
840
|
transport = new WebhookEventTransport({
|
|
597
841
|
fetchFn: mockFetch,
|
|
598
842
|
defaultRetries: 0,
|
|
@@ -600,8 +844,8 @@ describe("WebhookEventTransport", () => {
|
|
|
600
844
|
});
|
|
601
845
|
|
|
602
846
|
transport.addEndpoint({
|
|
603
|
-
id:
|
|
604
|
-
url:
|
|
847
|
+
id: 'ssrf-dns',
|
|
848
|
+
url: 'https://evil.example.com/hook',
|
|
605
849
|
});
|
|
606
850
|
|
|
607
851
|
transport.emit(createTestEvent());
|
|
@@ -610,23 +854,18 @@ describe("WebhookEventTransport", () => {
|
|
|
610
854
|
|
|
611
855
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
612
856
|
expect(logger.error).toHaveBeenCalledWith(
|
|
613
|
-
expect.stringContaining(
|
|
857
|
+
expect.stringContaining('ssrf-dns'),
|
|
614
858
|
expect.objectContaining({
|
|
615
|
-
message: expect.stringContaining(
|
|
616
|
-
})
|
|
859
|
+
message: expect.stringContaining('resolves to blocked IP'),
|
|
860
|
+
})
|
|
617
861
|
);
|
|
618
862
|
});
|
|
619
863
|
|
|
620
|
-
it(
|
|
864
|
+
it('should block webhooks when hostname resolves to loopback', async () => {
|
|
621
865
|
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
622
|
-
dnsLookup.mockResolvedValueOnce({ address:
|
|
866
|
+
dnsLookup.mockResolvedValueOnce({ address: '127.0.0.1', family: 4 });
|
|
623
867
|
|
|
624
|
-
const logger = {
|
|
625
|
-
debug: vi.fn(),
|
|
626
|
-
info: vi.fn(),
|
|
627
|
-
warn: vi.fn(),
|
|
628
|
-
error: vi.fn(),
|
|
629
|
-
};
|
|
868
|
+
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
630
869
|
transport = new WebhookEventTransport({
|
|
631
870
|
fetchFn: mockFetch,
|
|
632
871
|
defaultRetries: 0,
|
|
@@ -634,8 +873,8 @@ describe("WebhookEventTransport", () => {
|
|
|
634
873
|
});
|
|
635
874
|
|
|
636
875
|
transport.addEndpoint({
|
|
637
|
-
id:
|
|
638
|
-
url:
|
|
876
|
+
id: 'ssrf-loopback',
|
|
877
|
+
url: 'https://loopback.example.com/hook',
|
|
639
878
|
});
|
|
640
879
|
|
|
641
880
|
transport.emit(createTestEvent());
|
|
@@ -645,19 +884,11 @@ describe("WebhookEventTransport", () => {
|
|
|
645
884
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
646
885
|
});
|
|
647
886
|
|
|
648
|
-
it(
|
|
887
|
+
it('should block webhooks when hostname resolves to cloud metadata IP', async () => {
|
|
649
888
|
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
650
|
-
dnsLookup.mockResolvedValueOnce({
|
|
651
|
-
address: "169.254.169.254",
|
|
652
|
-
family: 4,
|
|
653
|
-
});
|
|
889
|
+
dnsLookup.mockResolvedValueOnce({ address: '169.254.169.254', family: 4 });
|
|
654
890
|
|
|
655
|
-
const logger = {
|
|
656
|
-
debug: vi.fn(),
|
|
657
|
-
info: vi.fn(),
|
|
658
|
-
warn: vi.fn(),
|
|
659
|
-
error: vi.fn(),
|
|
660
|
-
};
|
|
891
|
+
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
661
892
|
transport = new WebhookEventTransport({
|
|
662
893
|
fetchFn: mockFetch,
|
|
663
894
|
defaultRetries: 0,
|
|
@@ -665,8 +896,8 @@ describe("WebhookEventTransport", () => {
|
|
|
665
896
|
});
|
|
666
897
|
|
|
667
898
|
transport.addEndpoint({
|
|
668
|
-
id:
|
|
669
|
-
url:
|
|
899
|
+
id: 'ssrf-metadata',
|
|
900
|
+
url: 'https://metadata.example.com/hook',
|
|
670
901
|
});
|
|
671
902
|
|
|
672
903
|
transport.emit(createTestEvent());
|
|
@@ -676,13 +907,35 @@ describe("WebhookEventTransport", () => {
|
|
|
676
907
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
677
908
|
});
|
|
678
909
|
|
|
679
|
-
it(
|
|
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.
|
|
680
932
|
const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
|
|
681
|
-
dnsLookup.
|
|
933
|
+
dnsLookup.mockClear();
|
|
934
|
+
dnsLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 });
|
|
682
935
|
|
|
683
936
|
transport.addEndpoint({
|
|
684
|
-
id:
|
|
685
|
-
url:
|
|
937
|
+
id: 'one-lookup',
|
|
938
|
+
url: 'https://example.com/hook',
|
|
686
939
|
});
|
|
687
940
|
|
|
688
941
|
transport.emit(createTestEvent());
|
|
@@ -690,6 +943,7 @@ describe("WebhookEventTransport", () => {
|
|
|
690
943
|
await new Promise((r) => setTimeout(r, 50));
|
|
691
944
|
|
|
692
945
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
946
|
+
expect(dnsLookup).toHaveBeenCalledTimes(1);
|
|
693
947
|
});
|
|
694
948
|
});
|
|
695
949
|
});
|