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.
- package/.dockerignore +10 -0
- package/Dockerfile +41 -0
- package/README.md +606 -0
- package/bench/baseline.bench.mjs +73 -0
- package/bench/connections.bench.mjs +70 -0
- package/bench/keepalive.bench.mjs +248 -0
- package/bench/latency.bench.mjs +47 -0
- package/bench/run.mjs +211 -0
- package/bench/switching.bench.mjs +96 -0
- package/bench/throughput.bench.mjs +44 -0
- package/bench/validate.mjs +260 -0
- package/docker-compose.yml +62 -0
- package/examples/api.env +30 -0
- package/examples/web.env +27 -0
- package/package.json +39 -0
- package/portok.mjs +793 -0
- package/portok@.service +62 -0
- package/portokd.mjs +793 -0
- package/test/cli.test.mjs +220 -0
- package/test/drain.test.mjs +249 -0
- package/test/helpers/mock-server.mjs +305 -0
- package/test/metrics.test.mjs +328 -0
- package/test/proxy.test.mjs +223 -0
- package/test/rollback.test.mjs +344 -0
- package/test/security.test.mjs +256 -0
- package/test/switching.test.mjs +261 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Tests
|
|
3
|
+
* Tests token validation, IP allowlist, and rate limiting
|
|
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('Security', () => {
|
|
12
|
+
let mockServer;
|
|
13
|
+
let proxyPort;
|
|
14
|
+
let daemonProcess;
|
|
15
|
+
const adminToken = 'secure-test-token-xyz';
|
|
16
|
+
|
|
17
|
+
before(async () => {
|
|
18
|
+
mockServer = await createMockServer({ port: 0 });
|
|
19
|
+
proxyPort = await getFreePort();
|
|
20
|
+
|
|
21
|
+
daemonProcess = spawn('node', ['portokd.mjs'], {
|
|
22
|
+
env: {
|
|
23
|
+
...process.env,
|
|
24
|
+
LISTEN_PORT: String(proxyPort),
|
|
25
|
+
INITIAL_TARGET_PORT: String(mockServer.port),
|
|
26
|
+
ADMIN_TOKEN: adminToken,
|
|
27
|
+
STATE_FILE: `/tmp/portok-security-test-${Date.now()}.json`,
|
|
28
|
+
ADMIN_ALLOWLIST: '127.0.0.1,::1,::ffff:127.0.0.1',
|
|
29
|
+
DRAIN_MS: '1000',
|
|
30
|
+
ROLLBACK_WINDOW_MS: '60000',
|
|
31
|
+
ROLLBACK_CHECK_EVERY_MS: '5000',
|
|
32
|
+
ROLLBACK_FAIL_THRESHOLD: '3',
|
|
33
|
+
ADMIN_RATE_LIMIT: '1000',
|
|
34
|
+
},
|
|
35
|
+
stdio: 'pipe',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await waitFor(async () => {
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
41
|
+
headers: { 'x-admin-token': adminToken },
|
|
42
|
+
});
|
|
43
|
+
return res.ok;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}, 5000);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
after(async () => {
|
|
51
|
+
if (daemonProcess) {
|
|
52
|
+
daemonProcess.kill('SIGTERM');
|
|
53
|
+
}
|
|
54
|
+
if (mockServer) await mockServer.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('Token Validation', () => {
|
|
58
|
+
it('should reject request without token', async () => {
|
|
59
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__status`);
|
|
60
|
+
assert.strictEqual(response.status, 401);
|
|
61
|
+
|
|
62
|
+
const data = await response.json();
|
|
63
|
+
assert.ok(data.error.includes('Unauthorized'));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should reject request with invalid token', async () => {
|
|
67
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
68
|
+
headers: { 'x-admin-token': 'wrong-token' },
|
|
69
|
+
});
|
|
70
|
+
assert.strictEqual(response.status, 401);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should accept request with correct token', async () => {
|
|
74
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
75
|
+
headers: { 'x-admin-token': adminToken },
|
|
76
|
+
});
|
|
77
|
+
assert.strictEqual(response.status, 200);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should use timing-safe comparison', async () => {
|
|
81
|
+
// Test various token lengths to ensure timing-safe comparison
|
|
82
|
+
const testTokens = [
|
|
83
|
+
'',
|
|
84
|
+
'a',
|
|
85
|
+
'short',
|
|
86
|
+
adminToken.substring(0, 5),
|
|
87
|
+
adminToken + 'extra',
|
|
88
|
+
adminToken,
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
for (const token of testTokens) {
|
|
92
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
93
|
+
headers: { 'x-admin-token': token },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (token === adminToken) {
|
|
97
|
+
assert.strictEqual(response.status, 200);
|
|
98
|
+
} else {
|
|
99
|
+
assert.strictEqual(response.status, 401);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should require token for all admin endpoints', async () => {
|
|
105
|
+
const endpoints = [
|
|
106
|
+
{ path: '/__status', method: 'GET' },
|
|
107
|
+
{ path: '/__metrics', method: 'GET' },
|
|
108
|
+
{ path: '/__health', method: 'GET' },
|
|
109
|
+
{ path: `/__switch?port=${mockServer.port}`, method: 'POST' },
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
for (const { path, method } of endpoints) {
|
|
113
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}${path}`, {
|
|
114
|
+
method,
|
|
115
|
+
});
|
|
116
|
+
assert.strictEqual(response.status, 401, `Expected 401 for ${method} ${path}`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('Admin Endpoints vs Proxy', () => {
|
|
122
|
+
it('should not require token for proxied requests', async () => {
|
|
123
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/`);
|
|
124
|
+
assert.strictEqual(response.status, 200);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should proxy /status (not admin endpoint)', async () => {
|
|
128
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/status`);
|
|
129
|
+
// Should go to backend, not be treated as admin
|
|
130
|
+
assert.strictEqual(response.status, 200);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should only treat /__ prefix as admin', async () => {
|
|
134
|
+
// /_status should be proxied, not admin
|
|
135
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/_status`);
|
|
136
|
+
assert.strictEqual(response.status, 200);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('Rate Limiting', () => {
|
|
141
|
+
it('should not rate limit proxied requests', async () => {
|
|
142
|
+
// Make many proxied requests
|
|
143
|
+
const responses = [];
|
|
144
|
+
for (let i = 0; i < 20; i++) {
|
|
145
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/`);
|
|
146
|
+
responses.push(response.status);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// All should succeed
|
|
150
|
+
const successCount = responses.filter(s => s === 200).length;
|
|
151
|
+
assert.strictEqual(successCount, 20);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('Target Host Security (SSRF Guard)', () => {
|
|
156
|
+
it('should only proxy to localhost', async () => {
|
|
157
|
+
// All requests should go to 127.0.0.1
|
|
158
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/echo`);
|
|
159
|
+
const data = await response.json();
|
|
160
|
+
|
|
161
|
+
// Verify the request reached our mock server on localhost
|
|
162
|
+
assert.ok(data.headers.host);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should not allow port override via headers', async () => {
|
|
166
|
+
// Try to trick proxy with Host header
|
|
167
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/echo`, {
|
|
168
|
+
headers: {
|
|
169
|
+
'Host': 'evil.com:9999',
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Should still reach our mock server
|
|
174
|
+
assert.strictEqual(response.status, 200);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('Security - Rate Limit Test', () => {
|
|
180
|
+
let mockServer;
|
|
181
|
+
let proxyPort;
|
|
182
|
+
let daemonProcess;
|
|
183
|
+
const adminToken = 'rate-limit-test-token';
|
|
184
|
+
|
|
185
|
+
before(async () => {
|
|
186
|
+
mockServer = await createMockServer({ port: 0 });
|
|
187
|
+
proxyPort = await getFreePort();
|
|
188
|
+
|
|
189
|
+
// Use rate limit of 15 - waitFor uses ~5 requests, then we make 15 more
|
|
190
|
+
daemonProcess = spawn('node', ['portokd.mjs'], {
|
|
191
|
+
env: {
|
|
192
|
+
...process.env,
|
|
193
|
+
LISTEN_PORT: String(proxyPort),
|
|
194
|
+
INITIAL_TARGET_PORT: String(mockServer.port),
|
|
195
|
+
ADMIN_TOKEN: adminToken,
|
|
196
|
+
STATE_FILE: `/tmp/portok-ratelimit-test-${Date.now()}.json`,
|
|
197
|
+
DRAIN_MS: '1000',
|
|
198
|
+
ROLLBACK_WINDOW_MS: '60000',
|
|
199
|
+
ROLLBACK_CHECK_EVERY_MS: '5000',
|
|
200
|
+
ROLLBACK_FAIL_THRESHOLD: '3',
|
|
201
|
+
ADMIN_RATE_LIMIT: '15',
|
|
202
|
+
},
|
|
203
|
+
stdio: 'pipe',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await waitFor(async () => {
|
|
207
|
+
try {
|
|
208
|
+
const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
209
|
+
headers: { 'x-admin-token': adminToken },
|
|
210
|
+
});
|
|
211
|
+
return res.ok;
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}, 5000);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
after(async () => {
|
|
219
|
+
if (daemonProcess) {
|
|
220
|
+
daemonProcess.kill('SIGTERM');
|
|
221
|
+
}
|
|
222
|
+
if (mockServer) await mockServer.close();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should rate limit admin endpoints', async () => {
|
|
226
|
+
// Make 20 requests rapidly - some should get rate limited
|
|
227
|
+
const responses = [];
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < 20; i++) {
|
|
230
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
231
|
+
headers: { 'x-admin-token': adminToken },
|
|
232
|
+
});
|
|
233
|
+
responses.push(response.status);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const successCount = responses.filter(s => s === 200).length;
|
|
237
|
+
const rateLimitedCount = responses.filter(s => s === 429).length;
|
|
238
|
+
|
|
239
|
+
// Some should succeed, some should be rate limited
|
|
240
|
+
assert.ok(successCount > 0, `Expected some successes, got ${successCount}`);
|
|
241
|
+
assert.ok(rateLimitedCount > 0, `Expected some rate limited responses, got ${rateLimitedCount}`);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should return 429 with proper error message', async () => {
|
|
245
|
+
// Should be rate limited from previous test
|
|
246
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
247
|
+
headers: { 'x-admin-token': adminToken },
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Should be rate limited
|
|
251
|
+
assert.strictEqual(response.status, 429);
|
|
252
|
+
const data = await response.json();
|
|
253
|
+
assert.ok(data.error.includes('Rate limit'));
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health-Gated Switching Tests
|
|
3
|
+
* Tests the /__switch endpoint with health checks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
7
|
+
import assert from 'node:assert';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { createMockServer, getFreePort, waitFor, adminRequest } from './helpers/mock-server.mjs';
|
|
10
|
+
|
|
11
|
+
describe('Health-Gated Switching', () => {
|
|
12
|
+
let mockServer1;
|
|
13
|
+
let mockServer2;
|
|
14
|
+
let proxyPort;
|
|
15
|
+
let daemonProcess;
|
|
16
|
+
const adminToken = 'test-token-switch';
|
|
17
|
+
|
|
18
|
+
before(async () => {
|
|
19
|
+
// Create two mock backend servers
|
|
20
|
+
mockServer1 = await createMockServer({ port: 0 });
|
|
21
|
+
mockServer2 = await createMockServer({ port: 0 });
|
|
22
|
+
|
|
23
|
+
// Get a free port for the proxy
|
|
24
|
+
proxyPort = await getFreePort();
|
|
25
|
+
|
|
26
|
+
// Start the daemon
|
|
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-switch-test-${Date.now()}.json`,
|
|
34
|
+
DRAIN_MS: '500',
|
|
35
|
+
ROLLBACK_WINDOW_MS: '10000',
|
|
36
|
+
ROLLBACK_CHECK_EVERY_MS: '1000',
|
|
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 (mockServer1) await mockServer1.close();
|
|
61
|
+
if (mockServer2) await mockServer2.close();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
mockServer1.setHealthy(true);
|
|
66
|
+
mockServer2.setHealthy(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('Successful Switch', () => {
|
|
70
|
+
it('should switch to healthy port', async () => {
|
|
71
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'x-admin-token': adminToken },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.strictEqual(response.status, 200);
|
|
77
|
+
|
|
78
|
+
const data = await response.json();
|
|
79
|
+
assert.strictEqual(data.success, true);
|
|
80
|
+
assert.strictEqual(data.switch.to, mockServer2.port);
|
|
81
|
+
assert.strictEqual(data.switch.from, mockServer1.port);
|
|
82
|
+
assert.strictEqual(data.switch.reason, 'manual');
|
|
83
|
+
assert.ok(data.switch.id);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should update status after switch', async () => {
|
|
87
|
+
// First switch to server2
|
|
88
|
+
await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'x-admin-token': adminToken },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Check status
|
|
94
|
+
const statusRes = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
95
|
+
headers: { 'x-admin-token': adminToken },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const status = await statusRes.json();
|
|
99
|
+
assert.strictEqual(status.activePort, mockServer2.port);
|
|
100
|
+
assert.ok(status.drainUntil); // Drain should be active
|
|
101
|
+
assert.strictEqual(status.lastSwitch.to, mockServer2.port);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should route traffic to new port after switch', async () => {
|
|
105
|
+
// Switch to server2
|
|
106
|
+
await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'x-admin-token': adminToken },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Clear both servers' request logs
|
|
112
|
+
mockServer1.clearRequests();
|
|
113
|
+
mockServer2.clearRequests();
|
|
114
|
+
|
|
115
|
+
// Make a request - should go to server2 for new connections
|
|
116
|
+
// (Existing connections might still go to server1 during drain)
|
|
117
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
118
|
+
|
|
119
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/`);
|
|
120
|
+
assert.strictEqual(response.status, 200);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('Failed Switch - Unhealthy Port', () => {
|
|
125
|
+
it('should reject switch to unhealthy port with 409', async () => {
|
|
126
|
+
// Make server2 unhealthy
|
|
127
|
+
mockServer2.setHealthy(false);
|
|
128
|
+
|
|
129
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: { 'x-admin-token': adminToken },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
assert.strictEqual(response.status, 409);
|
|
135
|
+
|
|
136
|
+
const data = await response.json();
|
|
137
|
+
assert.ok(data.error.includes('Health check failed'));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should not change active port when switch fails', async () => {
|
|
141
|
+
// Get current status
|
|
142
|
+
const statusBefore = await adminRequest(`http://127.0.0.1:${proxyPort}`, '/__status', { token: adminToken });
|
|
143
|
+
const portBefore = statusBefore.data.activePort;
|
|
144
|
+
|
|
145
|
+
// Make server2 unhealthy and try to switch
|
|
146
|
+
mockServer2.setHealthy(false);
|
|
147
|
+
|
|
148
|
+
await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'x-admin-token': adminToken },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Check status unchanged
|
|
154
|
+
const statusAfter = await adminRequest(`http://127.0.0.1:${proxyPort}`, '/__status', { token: adminToken });
|
|
155
|
+
assert.strictEqual(statusAfter.data.activePort, portBefore);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('Switch Validation', () => {
|
|
160
|
+
it('should reject invalid port (non-numeric)', async () => {
|
|
161
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=invalid`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'x-admin-token': adminToken },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
assert.strictEqual(response.status, 400);
|
|
167
|
+
|
|
168
|
+
const data = await response.json();
|
|
169
|
+
assert.ok(data.error.includes('Invalid port'));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should reject port out of range', async () => {
|
|
173
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=99999`, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'x-admin-token': adminToken },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
assert.strictEqual(response.status, 400);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should reject port 0', async () => {
|
|
182
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=0`, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { 'x-admin-token': adminToken },
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
assert.strictEqual(response.status, 400);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should reject missing port parameter', async () => {
|
|
191
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'x-admin-token': adminToken },
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
assert.strictEqual(response.status, 400);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('Health Endpoint', () => {
|
|
201
|
+
it('should report healthy when active port is healthy', async () => {
|
|
202
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__health`, {
|
|
203
|
+
headers: { 'x-admin-token': adminToken },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
assert.strictEqual(response.status, 200);
|
|
207
|
+
|
|
208
|
+
const data = await response.json();
|
|
209
|
+
assert.strictEqual(data.healthy, true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should report unhealthy when active port is unhealthy', async () => {
|
|
213
|
+
// Get current active port from status
|
|
214
|
+
const statusRes = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
215
|
+
headers: { 'x-admin-token': adminToken },
|
|
216
|
+
});
|
|
217
|
+
const status = await statusRes.json();
|
|
218
|
+
|
|
219
|
+
// Make current active server unhealthy
|
|
220
|
+
if (status.activePort === mockServer1.port) {
|
|
221
|
+
mockServer1.setHealthy(false);
|
|
222
|
+
} else {
|
|
223
|
+
mockServer2.setHealthy(false);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__health`, {
|
|
227
|
+
headers: { 'x-admin-token': adminToken },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
assert.strictEqual(response.status, 503);
|
|
231
|
+
|
|
232
|
+
const data = await response.json();
|
|
233
|
+
assert.strictEqual(data.healthy, false);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('Switch ID', () => {
|
|
238
|
+
it('should generate unique switch IDs', async () => {
|
|
239
|
+
const ids = new Set();
|
|
240
|
+
|
|
241
|
+
for (let i = 0; i < 3; i++) {
|
|
242
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer1.port}`, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { 'x-admin-token': adminToken },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const data = await response.json();
|
|
248
|
+
ids.add(data.switch.id);
|
|
249
|
+
|
|
250
|
+
// Switch back
|
|
251
|
+
await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'x-admin-token': adminToken },
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
assert.strictEqual(ids.size, 3); // All IDs should be unique
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|