portok 1.0.0

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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Proxy Core Tests
3
+ * Tests HTTP proxying, WebSocket upgrades, and streaming
4
+ */
5
+
6
+ import { describe, it, before, after, beforeEach } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import http from 'node:http';
9
+ import { spawn } from 'node:child_process';
10
+ import { createMockServer, getFreePort, waitFor } from './helpers/mock-server.mjs';
11
+ import WebSocket from 'ws';
12
+
13
+ describe('Proxy Core', () => {
14
+ let mockServer;
15
+ let proxyPort;
16
+ let daemonProcess;
17
+ const adminToken = 'test-token-proxy';
18
+
19
+ before(async () => {
20
+ // Create mock backend server
21
+ mockServer = await createMockServer({ port: 0 });
22
+
23
+ // Get a free port for the proxy
24
+ proxyPort = await getFreePort();
25
+
26
+ // Start the daemon as a subprocess
27
+ daemonProcess = spawn('node', ['portokd.mjs'], {
28
+ env: {
29
+ ...process.env,
30
+ LISTEN_PORT: String(proxyPort),
31
+ INITIAL_TARGET_PORT: String(mockServer.port),
32
+ ADMIN_TOKEN: adminToken,
33
+ STATE_FILE: `/tmp/portok-proxy-test-${Date.now()}.json`,
34
+ DRAIN_MS: '1000',
35
+ ROLLBACK_WINDOW_MS: '5000',
36
+ ROLLBACK_CHECK_EVERY_MS: '500',
37
+ ROLLBACK_FAIL_THRESHOLD: '3',
38
+ ADMIN_RATE_LIMIT: '1000',
39
+ },
40
+ stdio: 'pipe',
41
+ });
42
+
43
+ // Wait for daemon to start
44
+ await waitFor(async () => {
45
+ try {
46
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
47
+ headers: { 'x-admin-token': adminToken },
48
+ });
49
+ return res.ok;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }, 5000);
54
+ });
55
+
56
+ after(async () => {
57
+ if (daemonProcess) {
58
+ daemonProcess.kill('SIGTERM');
59
+ }
60
+ if (mockServer) {
61
+ await mockServer.close();
62
+ }
63
+ });
64
+
65
+ beforeEach(() => {
66
+ mockServer.clearRequests();
67
+ });
68
+
69
+ describe('HTTP Proxying', () => {
70
+ it('should proxy GET requests to backend', async () => {
71
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/`);
72
+ assert.strictEqual(response.status, 200);
73
+
74
+ const body = await response.text();
75
+ assert.strictEqual(body, 'OK');
76
+
77
+ const requests = mockServer.getRequests();
78
+ assert.strictEqual(requests.length, 1);
79
+ assert.strictEqual(requests[0].method, 'GET');
80
+ });
81
+
82
+ it('should proxy POST requests with body', async () => {
83
+ const testBody = JSON.stringify({ test: 'data' });
84
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/echo`, {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body: testBody,
88
+ });
89
+
90
+ assert.strictEqual(response.status, 200);
91
+
92
+ const data = await response.json();
93
+ assert.strictEqual(data.method, 'POST');
94
+ assert.strictEqual(data.body, testBody);
95
+ });
96
+
97
+ it('should forward headers to backend', async () => {
98
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/echo`, {
99
+ headers: {
100
+ 'X-Custom-Header': 'test-value',
101
+ 'Accept': 'application/json',
102
+ },
103
+ });
104
+
105
+ assert.strictEqual(response.status, 200);
106
+
107
+ const data = await response.json();
108
+ assert.strictEqual(data.headers['x-custom-header'], 'test-value');
109
+ assert.strictEqual(data.headers['accept'], 'application/json');
110
+ });
111
+
112
+ it('should add X-Forwarded headers', async () => {
113
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/echo`);
114
+ const data = await response.json();
115
+
116
+ assert.ok(data.headers['x-forwarded-for']);
117
+ assert.ok(data.headers['x-forwarded-host']);
118
+ });
119
+
120
+ it('should handle backend errors', async () => {
121
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/error`);
122
+ assert.strictEqual(response.status, 500);
123
+ });
124
+ });
125
+
126
+ describe('Streaming', () => {
127
+ it('should proxy streamed responses', async () => {
128
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/stream`);
129
+ assert.strictEqual(response.status, 200);
130
+
131
+ const body = await response.text();
132
+ assert.ok(body.includes('chunk 0'));
133
+ assert.ok(body.includes('chunk 4'));
134
+ });
135
+ });
136
+
137
+ describe('WebSocket Proxying', () => {
138
+ it('should proxy WebSocket connections', async () => {
139
+ const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`);
140
+
141
+ const messages = [];
142
+ await new Promise((resolve, reject) => {
143
+ const timeout = setTimeout(() => reject(new Error('WebSocket timeout')), 5000);
144
+
145
+ ws.on('open', () => {
146
+ // Wait for initial 'connected' message
147
+ });
148
+
149
+ ws.on('message', (data) => {
150
+ messages.push(data.toString());
151
+ if (messages.length === 1 && messages[0] === 'connected') {
152
+ ws.send('hello');
153
+ } else if (messages.length === 2) {
154
+ clearTimeout(timeout);
155
+ ws.close();
156
+ resolve();
157
+ }
158
+ });
159
+
160
+ ws.on('error', (err) => {
161
+ clearTimeout(timeout);
162
+ reject(err);
163
+ });
164
+ });
165
+
166
+ assert.strictEqual(messages[0], 'connected');
167
+ assert.strictEqual(messages[1], 'echo: hello');
168
+ });
169
+
170
+ it('should handle WebSocket close properly', async () => {
171
+ const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`);
172
+
173
+ await new Promise((resolve, reject) => {
174
+ const timeout = setTimeout(() => reject(new Error('WebSocket timeout')), 5000);
175
+
176
+ ws.on('open', () => {
177
+ setTimeout(() => ws.close(), 100);
178
+ });
179
+
180
+ ws.on('close', () => {
181
+ clearTimeout(timeout);
182
+ resolve();
183
+ });
184
+
185
+ ws.on('error', reject);
186
+ });
187
+
188
+ // Connection should be closed cleanly
189
+ assert.strictEqual(ws.readyState, WebSocket.CLOSED);
190
+ });
191
+ });
192
+
193
+ describe('Target Host Security', () => {
194
+ it('should only proxy to localhost', async () => {
195
+ // The proxy should always target 127.0.0.1
196
+ // This is verified by checking that requests reach the mock server
197
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/echo`);
198
+ const data = await response.json();
199
+
200
+ // Host header should show the proxy received the request
201
+ assert.ok(data.headers.host.includes('127.0.0.1'));
202
+ });
203
+ });
204
+
205
+ describe('Concurrent Requests', () => {
206
+ it('should handle multiple concurrent requests', async () => {
207
+ const promises = [];
208
+ for (let i = 0; i < 10; i++) {
209
+ promises.push(fetch(`http://127.0.0.1:${proxyPort}/`));
210
+ }
211
+
212
+ const responses = await Promise.all(promises);
213
+
214
+ for (const response of responses) {
215
+ assert.strictEqual(response.status, 200);
216
+ }
217
+
218
+ const requests = mockServer.getRequests();
219
+ assert.strictEqual(requests.length, 10);
220
+ });
221
+ });
222
+ });
223
+
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Auto-Rollback Tests
3
+ * Tests automatic rollback when health checks fail consecutively
4
+ */
5
+
6
+ import { describe, it, before, after } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import { spawn } from 'node:child_process';
9
+ import { createMockServer, getFreePort, waitFor } from './helpers/mock-server.mjs';
10
+
11
+ describe('Auto Rollback', () => {
12
+ const adminToken = 'test-token-rollback';
13
+
14
+ describe('Rollback Trigger', () => {
15
+ let mockServer1;
16
+ let mockServer2;
17
+ let proxyPort;
18
+ let daemonProcess;
19
+
20
+ before(async () => {
21
+ mockServer1 = await createMockServer({ port: 0, responseBody: 'SERVER1' });
22
+ mockServer2 = await createMockServer({ port: 0, responseBody: 'SERVER2' });
23
+
24
+ proxyPort = await getFreePort();
25
+
26
+ // Start daemon with aggressive rollback settings for testing
27
+ daemonProcess = spawn('node', ['portokd.mjs'], {
28
+ env: {
29
+ ...process.env,
30
+ LISTEN_PORT: String(proxyPort),
31
+ INITIAL_TARGET_PORT: String(mockServer1.port),
32
+ ADMIN_TOKEN: adminToken,
33
+ STATE_FILE: `/tmp/portok-rollback-test-${Date.now()}.json`,
34
+ DRAIN_MS: '500',
35
+ ROLLBACK_WINDOW_MS: '10000',
36
+ ROLLBACK_CHECK_EVERY_MS: '200', // Check every 200ms
37
+ ROLLBACK_FAIL_THRESHOLD: '2',
38
+ ADMIN_RATE_LIMIT: '1000', // Rollback after 2 failures
39
+ },
40
+ stdio: 'pipe',
41
+ });
42
+
43
+ await waitFor(async () => {
44
+ try {
45
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
46
+ headers: { 'x-admin-token': adminToken },
47
+ });
48
+ return res.ok;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }, 5000);
53
+ });
54
+
55
+ after(async () => {
56
+ if (daemonProcess) {
57
+ daemonProcess.kill('SIGTERM');
58
+ }
59
+ if (mockServer1) await mockServer1.close();
60
+ if (mockServer2) await mockServer2.close();
61
+ });
62
+
63
+ it('should rollback when new port becomes unhealthy', async () => {
64
+ // Switch to server2
65
+ const switchRes = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
66
+ method: 'POST',
67
+ headers: { 'x-admin-token': adminToken },
68
+ });
69
+ assert.strictEqual(switchRes.status, 200);
70
+
71
+ // Verify we switched
72
+ let status = await (await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
73
+ headers: { 'x-admin-token': adminToken },
74
+ })).json();
75
+ assert.strictEqual(status.activePort, mockServer2.port);
76
+
77
+ // Make server2 unhealthy
78
+ mockServer2.setHealthy(false);
79
+
80
+ // Wait for rollback to happen (2 failures * 200ms check interval + buffer)
81
+ await new Promise(resolve => setTimeout(resolve, 1000));
82
+
83
+ // Should have rolled back to server1
84
+ status = await (await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
85
+ headers: { 'x-admin-token': adminToken },
86
+ })).json();
87
+ assert.strictEqual(status.activePort, mockServer1.port);
88
+ assert.strictEqual(status.lastSwitch.reason, 'auto-rollback');
89
+ });
90
+
91
+ it('should not rollback if health recovers', async () => {
92
+ // Make sure server2 is healthy first
93
+ mockServer2.setHealthy(true);
94
+
95
+ // Switch to server2
96
+ await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
97
+ method: 'POST',
98
+ headers: { 'x-admin-token': adminToken },
99
+ });
100
+
101
+ // Make server2 unhealthy for just 1 check (below threshold)
102
+ mockServer2.setHealthy(false);
103
+ await new Promise(resolve => setTimeout(resolve, 250));
104
+
105
+ // Make it healthy again before threshold reached
106
+ mockServer2.setHealthy(true);
107
+ await new Promise(resolve => setTimeout(resolve, 500));
108
+
109
+ // Should still be on server2
110
+ const status = await (await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
111
+ headers: { 'x-admin-token': adminToken },
112
+ })).json();
113
+ assert.strictEqual(status.activePort, mockServer2.port);
114
+ });
115
+ });
116
+
117
+ describe('Rollback Loop Prevention', () => {
118
+ let mockServer1;
119
+ let mockServer2;
120
+ let proxyPort;
121
+ let daemonProcess;
122
+
123
+ before(async () => {
124
+ mockServer1 = await createMockServer({ port: 0, responseBody: 'SERVER1' });
125
+ mockServer2 = await createMockServer({ port: 0, responseBody: 'SERVER2' });
126
+
127
+ proxyPort = await getFreePort();
128
+
129
+ daemonProcess = spawn('node', ['portokd.mjs'], {
130
+ env: {
131
+ ...process.env,
132
+ LISTEN_PORT: String(proxyPort),
133
+ INITIAL_TARGET_PORT: String(mockServer1.port),
134
+ ADMIN_TOKEN: adminToken,
135
+ STATE_FILE: `/tmp/portok-rollback-loop-test-${Date.now()}.json`,
136
+ DRAIN_MS: '200',
137
+ ROLLBACK_WINDOW_MS: '5000',
138
+ ROLLBACK_CHECK_EVERY_MS: '100',
139
+ ROLLBACK_FAIL_THRESHOLD: '2',
140
+ ADMIN_RATE_LIMIT: '1000',
141
+ },
142
+ stdio: 'pipe',
143
+ });
144
+
145
+ await waitFor(async () => {
146
+ try {
147
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
148
+ headers: { 'x-admin-token': adminToken },
149
+ });
150
+ return res.ok;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }, 5000);
155
+ });
156
+
157
+ after(async () => {
158
+ if (daemonProcess) {
159
+ daemonProcess.kill('SIGTERM');
160
+ }
161
+ if (mockServer1) await mockServer1.close();
162
+ if (mockServer2) await mockServer2.close();
163
+ });
164
+
165
+ it('should not start rollback monitor for rollback operation', async () => {
166
+ // Switch to server2
167
+ await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
168
+ method: 'POST',
169
+ headers: { 'x-admin-token': adminToken },
170
+ });
171
+
172
+ // Make server2 unhealthy to trigger rollback
173
+ mockServer2.setHealthy(false);
174
+
175
+ // Wait for rollback
176
+ await new Promise(resolve => setTimeout(resolve, 500));
177
+
178
+ // Verify rolled back to server1
179
+ let status = await (await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
180
+ headers: { 'x-admin-token': adminToken },
181
+ })).json();
182
+ assert.strictEqual(status.activePort, mockServer1.port);
183
+
184
+ // Now make server1 unhealthy too
185
+ mockServer1.setHealthy(false);
186
+
187
+ // Wait - should NOT trigger another rollback to server2
188
+ // because rollback operations don't start monitors
189
+ await new Promise(resolve => setTimeout(resolve, 500));
190
+
191
+ // Should still be on server1
192
+ status = await (await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
193
+ headers: { 'x-admin-token': adminToken },
194
+ })).json();
195
+ assert.strictEqual(status.activePort, mockServer1.port);
196
+ });
197
+ });
198
+
199
+ describe('Rollback Window', () => {
200
+ let mockServer1;
201
+ let mockServer2;
202
+ let proxyPort;
203
+ let daemonProcess;
204
+
205
+ before(async () => {
206
+ mockServer1 = await createMockServer({ port: 0 });
207
+ mockServer2 = await createMockServer({ port: 0 });
208
+
209
+ proxyPort = await getFreePort();
210
+
211
+ daemonProcess = spawn('node', ['portokd.mjs'], {
212
+ env: {
213
+ ...process.env,
214
+ LISTEN_PORT: String(proxyPort),
215
+ INITIAL_TARGET_PORT: String(mockServer1.port),
216
+ ADMIN_TOKEN: adminToken,
217
+ STATE_FILE: `/tmp/portok-rollback-window-test-${Date.now()}.json`,
218
+ DRAIN_MS: '100',
219
+ ROLLBACK_WINDOW_MS: '500', // Short window for testing
220
+ ROLLBACK_CHECK_EVERY_MS: '100',
221
+ ROLLBACK_FAIL_THRESHOLD: '2',
222
+ ADMIN_RATE_LIMIT: '1000',
223
+ },
224
+ stdio: 'pipe',
225
+ });
226
+
227
+ await waitFor(async () => {
228
+ try {
229
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
230
+ headers: { 'x-admin-token': adminToken },
231
+ });
232
+ return res.ok;
233
+ } catch {
234
+ return false;
235
+ }
236
+ }, 5000);
237
+ });
238
+
239
+ after(async () => {
240
+ if (daemonProcess) {
241
+ daemonProcess.kill('SIGTERM');
242
+ }
243
+ if (mockServer1) await mockServer1.close();
244
+ if (mockServer2) await mockServer2.close();
245
+ });
246
+
247
+ it('should not rollback after window expires', async () => {
248
+ // Switch to server2
249
+ await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
250
+ method: 'POST',
251
+ headers: { 'x-admin-token': adminToken },
252
+ });
253
+
254
+ // Wait for rollback window to expire
255
+ await new Promise(resolve => setTimeout(resolve, 700));
256
+
257
+ // Now make server2 unhealthy
258
+ mockServer2.setHealthy(false);
259
+
260
+ // Wait some more - should NOT rollback because window expired
261
+ await new Promise(resolve => setTimeout(resolve, 500));
262
+
263
+ // Should still be on server2
264
+ const status = await (await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
265
+ headers: { 'x-admin-token': adminToken },
266
+ })).json();
267
+ assert.strictEqual(status.activePort, mockServer2.port);
268
+ });
269
+ });
270
+
271
+ describe('Health Metrics During Rollback', () => {
272
+ let mockServer1;
273
+ let mockServer2;
274
+ let proxyPort;
275
+ let daemonProcess;
276
+
277
+ before(async () => {
278
+ mockServer1 = await createMockServer({ port: 0 });
279
+ mockServer2 = await createMockServer({ port: 0 });
280
+
281
+ proxyPort = await getFreePort();
282
+
283
+ daemonProcess = spawn('node', ['portokd.mjs'], {
284
+ env: {
285
+ ...process.env,
286
+ LISTEN_PORT: String(proxyPort),
287
+ INITIAL_TARGET_PORT: String(mockServer1.port),
288
+ ADMIN_TOKEN: adminToken,
289
+ STATE_FILE: `/tmp/portok-rollback-metrics-test-${Date.now()}.json`,
290
+ DRAIN_MS: '100',
291
+ ROLLBACK_WINDOW_MS: '10000',
292
+ ROLLBACK_CHECK_EVERY_MS: '100',
293
+ ROLLBACK_FAIL_THRESHOLD: '3',
294
+ ADMIN_RATE_LIMIT: '1000',
295
+ },
296
+ stdio: 'pipe',
297
+ });
298
+
299
+ await waitFor(async () => {
300
+ try {
301
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
302
+ headers: { 'x-admin-token': adminToken },
303
+ });
304
+ return res.ok;
305
+ } catch {
306
+ return false;
307
+ }
308
+ }, 5000);
309
+ });
310
+
311
+ after(async () => {
312
+ if (daemonProcess) {
313
+ daemonProcess.kill('SIGTERM');
314
+ }
315
+ if (mockServer1) await mockServer1.close();
316
+ if (mockServer2) await mockServer2.close();
317
+ });
318
+
319
+ it('should track consecutive fails in metrics', async () => {
320
+ // Switch to server2
321
+ await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
322
+ method: 'POST',
323
+ headers: { 'x-admin-token': adminToken },
324
+ });
325
+
326
+ // Make server2 unhealthy
327
+ mockServer2.setHealthy(false);
328
+
329
+ // Wait for some health checks
330
+ await new Promise(resolve => setTimeout(resolve, 350));
331
+
332
+ // Check metrics
333
+ const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
334
+ headers: { 'x-admin-token': adminToken },
335
+ });
336
+ const metrics = await metricsRes.json();
337
+
338
+ assert.ok(metrics.health.consecutiveFails > 0);
339
+ assert.strictEqual(metrics.health.activePortOk, false);
340
+ assert.ok(metrics.health.lastCheckedAt);
341
+ });
342
+ });
343
+ });
344
+