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,305 @@
1
+ /**
2
+ * Mock HTTP/WebSocket server for testing portokd
3
+ * Provides configurable responses, delays, and health status
4
+ */
5
+
6
+ import http from 'node:http';
7
+
8
+ // Dynamically import ws to handle when not available
9
+ let WebSocketServer;
10
+ try {
11
+ const ws = await import('ws');
12
+ WebSocketServer = ws.WebSocketServer;
13
+ } catch {
14
+ WebSocketServer = null;
15
+ }
16
+
17
+ /**
18
+ * Create a mock server with configurable behavior
19
+ * @param {Object} options
20
+ * @param {number} options.port - Port to listen on (0 for random)
21
+ * @param {number} options.healthStatus - HTTP status for /health endpoint (default: 200)
22
+ * @param {number} options.responseDelay - Delay in ms before responding (default: 0)
23
+ * @param {string} options.responseBody - Body to return for requests (default: 'OK')
24
+ * @param {boolean} options.enableWebSocket - Enable WebSocket support (default: true)
25
+ * @returns {Promise<MockServer>}
26
+ */
27
+ export async function createMockServer(options = {}) {
28
+ const config = {
29
+ port: options.port || 0,
30
+ healthStatus: options.healthStatus ?? 200,
31
+ responseDelay: options.responseDelay || 0,
32
+ responseBody: options.responseBody || 'OK',
33
+ enableWebSocket: options.enableWebSocket !== false,
34
+ };
35
+
36
+ const requests = [];
37
+ let healthy = config.healthStatus >= 200 && config.healthStatus < 300;
38
+ let currentHealthStatus = config.healthStatus;
39
+
40
+ const server = http.createServer(async (req, res) => {
41
+ // Record request
42
+ requests.push({
43
+ method: req.method,
44
+ url: req.url,
45
+ headers: { ...req.headers },
46
+ timestamp: Date.now(),
47
+ });
48
+
49
+ // Apply delay if configured
50
+ if (config.responseDelay > 0) {
51
+ await new Promise(resolve => setTimeout(resolve, config.responseDelay));
52
+ }
53
+
54
+ // Health endpoint
55
+ if (req.url === '/health' || req.url.startsWith('/health?')) {
56
+ res.writeHead(currentHealthStatus, { 'Content-Type': 'application/json' });
57
+ res.end(JSON.stringify({ status: healthy ? 'healthy' : 'unhealthy' }));
58
+ return;
59
+ }
60
+
61
+ // Echo endpoint - returns request info
62
+ if (req.url === '/echo') {
63
+ let body = '';
64
+ for await (const chunk of req) {
65
+ body += chunk;
66
+ }
67
+ res.writeHead(200, { 'Content-Type': 'application/json' });
68
+ res.end(JSON.stringify({
69
+ method: req.method,
70
+ url: req.url,
71
+ headers: req.headers,
72
+ body,
73
+ }));
74
+ return;
75
+ }
76
+
77
+ // Slow endpoint - takes longer to respond
78
+ if (req.url === '/slow') {
79
+ await new Promise(resolve => setTimeout(resolve, 2000));
80
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
81
+ res.end('Slow response');
82
+ return;
83
+ }
84
+
85
+ // Error endpoint - returns 500
86
+ if (req.url === '/error') {
87
+ res.writeHead(500, { 'Content-Type': 'application/json' });
88
+ res.end(JSON.stringify({ error: 'Internal Server Error' }));
89
+ return;
90
+ }
91
+
92
+ // Stream endpoint - sends chunked data
93
+ if (req.url === '/stream') {
94
+ res.writeHead(200, {
95
+ 'Content-Type': 'text/plain',
96
+ 'Transfer-Encoding': 'chunked',
97
+ });
98
+ for (let i = 0; i < 5; i++) {
99
+ res.write(`chunk ${i}\n`);
100
+ await new Promise(resolve => setTimeout(resolve, 100));
101
+ }
102
+ res.end();
103
+ return;
104
+ }
105
+
106
+ // Default response
107
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
108
+ res.end(config.responseBody);
109
+ });
110
+
111
+ // WebSocket support
112
+ let wss = null;
113
+ const wsConnections = [];
114
+
115
+ if (config.enableWebSocket && WebSocketServer) {
116
+ wss = new WebSocketServer({ server });
117
+
118
+ wss.on('connection', (ws) => {
119
+ wsConnections.push(ws);
120
+
121
+ ws.on('message', (message) => {
122
+ // Echo messages back
123
+ ws.send(`echo: ${message}`);
124
+ });
125
+
126
+ ws.on('close', () => {
127
+ const idx = wsConnections.indexOf(ws);
128
+ if (idx !== -1) wsConnections.splice(idx, 1);
129
+ });
130
+
131
+ ws.send('connected');
132
+ });
133
+ }
134
+
135
+ // Start server
136
+ await new Promise((resolve, reject) => {
137
+ server.listen(config.port, '127.0.0.1', () => resolve());
138
+ server.on('error', reject);
139
+ });
140
+
141
+ const actualPort = server.address().port;
142
+
143
+ return {
144
+ port: actualPort,
145
+ url: `http://127.0.0.1:${actualPort}`,
146
+ server,
147
+ wss,
148
+
149
+ // Get recorded requests
150
+ getRequests() {
151
+ return [...requests];
152
+ },
153
+
154
+ // Clear recorded requests
155
+ clearRequests() {
156
+ requests.length = 0;
157
+ },
158
+
159
+ // Set health status dynamically
160
+ setHealthy(isHealthy) {
161
+ healthy = isHealthy;
162
+ currentHealthStatus = isHealthy ? 200 : 503;
163
+ },
164
+
165
+ // Set specific health status code
166
+ setHealthStatus(statusCode) {
167
+ currentHealthStatus = statusCode;
168
+ healthy = statusCode >= 200 && statusCode < 300;
169
+ },
170
+
171
+ // Get WebSocket connections
172
+ getWebSocketConnections() {
173
+ return [...wsConnections];
174
+ },
175
+
176
+ // Broadcast to all WebSocket clients
177
+ broadcast(message) {
178
+ wsConnections.forEach(ws => {
179
+ if (ws.readyState === 1) { // OPEN
180
+ ws.send(message);
181
+ }
182
+ });
183
+ },
184
+
185
+ // Close server
186
+ async close() {
187
+ // Close WebSocket connections
188
+ wsConnections.forEach(ws => ws.close());
189
+
190
+ // Close WebSocket server
191
+ if (wss) {
192
+ await new Promise(resolve => wss.close(resolve));
193
+ }
194
+
195
+ // Close HTTP server
196
+ await new Promise(resolve => server.close(resolve));
197
+ },
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Create the portokd server for testing
203
+ * Sets up environment and spawns the daemon
204
+ */
205
+ export async function createTestDaemon(options = {}) {
206
+ const {
207
+ listenPort = 0,
208
+ targetPort,
209
+ adminToken = 'test-token-12345',
210
+ healthPath = '/health',
211
+ drainMs = 1000,
212
+ rollbackWindowMs = 5000,
213
+ rollbackCheckEveryMs = 500,
214
+ rollbackFailThreshold = 2,
215
+ } = options;
216
+
217
+ // Set environment
218
+ process.env.LISTEN_PORT = String(listenPort);
219
+ process.env.INITIAL_TARGET_PORT = String(targetPort);
220
+ process.env.ADMIN_TOKEN = adminToken;
221
+ process.env.HEALTH_PATH = healthPath;
222
+ process.env.DRAIN_MS = String(drainMs);
223
+ process.env.ROLLBACK_WINDOW_MS = String(rollbackWindowMs);
224
+ process.env.ROLLBACK_CHECK_EVERY_MS = String(rollbackCheckEveryMs);
225
+ process.env.ROLLBACK_FAIL_THRESHOLD = String(rollbackFailThreshold);
226
+ process.env.STATE_FILE = `/tmp/portok-test-${Date.now()}.json`;
227
+
228
+ // Import the daemon (will start based on env vars)
229
+ const daemon = await import('../../portokd.mjs');
230
+
231
+ // Wait for server to be ready
232
+ await new Promise(resolve => setTimeout(resolve, 100));
233
+
234
+ const actualPort = daemon.server.address()?.port || listenPort;
235
+
236
+ return {
237
+ port: actualPort,
238
+ url: `http://127.0.0.1:${actualPort}`,
239
+ adminToken,
240
+ daemon,
241
+
242
+ async close() {
243
+ daemon.server.close();
244
+ },
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Helper to make admin requests
250
+ */
251
+ export async function adminRequest(baseUrl, endpoint, options = {}) {
252
+ const { method = 'GET', token = 'test-token-12345', body } = options;
253
+
254
+ const fetchOptions = {
255
+ method,
256
+ headers: {
257
+ 'x-admin-token': token,
258
+ 'Content-Type': 'application/json',
259
+ },
260
+ };
261
+
262
+ if (body) {
263
+ fetchOptions.body = JSON.stringify(body);
264
+ }
265
+
266
+ const response = await fetch(`${baseUrl}${endpoint}`, fetchOptions);
267
+ const data = await response.json().catch(() => ({}));
268
+
269
+ return {
270
+ ok: response.ok,
271
+ status: response.status,
272
+ data,
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Helper to wait for a condition
278
+ */
279
+ export async function waitFor(condition, timeout = 5000, interval = 100) {
280
+ const startTime = Date.now();
281
+
282
+ while (Date.now() - startTime < timeout) {
283
+ if (await condition()) {
284
+ return true;
285
+ }
286
+ await new Promise(resolve => setTimeout(resolve, interval));
287
+ }
288
+
289
+ throw new Error(`Condition not met within ${timeout}ms`);
290
+ }
291
+
292
+ /**
293
+ * Helper to get a free port
294
+ */
295
+ export async function getFreePort() {
296
+ return new Promise((resolve, reject) => {
297
+ const server = http.createServer();
298
+ server.listen(0, '127.0.0.1', () => {
299
+ const port = server.address().port;
300
+ server.close(() => resolve(port));
301
+ });
302
+ server.on('error', reject);
303
+ });
304
+ }
305
+
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Metrics Tests
3
+ * Tests metrics collection: counters, RPS, inflight tracking
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('Metrics', () => {
12
+ let mockServer;
13
+ let proxyPort;
14
+ let daemonProcess;
15
+ const adminToken = 'test-token-metrics';
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-metrics-test-${Date.now()}.json`,
28
+ DRAIN_MS: '1000',
29
+ ROLLBACK_WINDOW_MS: '60000',
30
+ ROLLBACK_CHECK_EVERY_MS: '5000',
31
+ ROLLBACK_FAIL_THRESHOLD: '3',
32
+ ADMIN_RATE_LIMIT: '1000',
33
+ },
34
+ stdio: 'pipe',
35
+ });
36
+
37
+ await waitFor(async () => {
38
+ try {
39
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
40
+ headers: { 'x-admin-token': adminToken },
41
+ });
42
+ return res.ok;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }, 5000);
47
+ });
48
+
49
+ after(async () => {
50
+ if (daemonProcess) {
51
+ daemonProcess.kill('SIGTERM');
52
+ }
53
+ if (mockServer) await mockServer.close();
54
+ });
55
+
56
+ describe('Metrics Endpoint', () => {
57
+ it('should return all required metrics fields', async () => {
58
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
59
+ headers: { 'x-admin-token': adminToken },
60
+ });
61
+
62
+ assert.strictEqual(response.status, 200);
63
+
64
+ const metrics = await response.json();
65
+
66
+ // Check all required fields exist
67
+ assert.ok('startedAt' in metrics);
68
+ assert.ok('inflight' in metrics);
69
+ assert.ok('inflightMax' in metrics);
70
+ assert.ok('totalRequests' in metrics);
71
+ assert.ok('totalProxyErrors' in metrics);
72
+ assert.ok('statusCounters' in metrics);
73
+ assert.ok('rollingRps60' in metrics);
74
+ assert.ok('health' in metrics);
75
+ assert.ok('lastProxyError' in metrics);
76
+ });
77
+
78
+ it('should have valid startedAt timestamp', async () => {
79
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
80
+ headers: { 'x-admin-token': adminToken },
81
+ });
82
+ const metrics = await response.json();
83
+
84
+ const startedAt = new Date(metrics.startedAt);
85
+ assert.ok(!isNaN(startedAt.getTime()));
86
+ assert.ok(startedAt <= new Date());
87
+ });
88
+ });
89
+
90
+ describe('Request Counting', () => {
91
+ it('should count total requests', async () => {
92
+ // Get initial count
93
+ const beforeRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
94
+ headers: { 'x-admin-token': adminToken },
95
+ });
96
+ const before = await beforeRes.json();
97
+
98
+ // Make some proxied requests
99
+ for (let i = 0; i < 5; i++) {
100
+ await fetch(`http://127.0.0.1:${proxyPort}/`);
101
+ }
102
+
103
+ // Get new count
104
+ const afterRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
105
+ headers: { 'x-admin-token': adminToken },
106
+ });
107
+ const after = await afterRes.json();
108
+
109
+ // Should have increased by at least 5 (proxied requests, not admin)
110
+ assert.ok(after.totalRequests >= before.totalRequests + 5);
111
+ });
112
+ });
113
+
114
+ describe('Status Counters', () => {
115
+ it('should count 2xx responses', async () => {
116
+ const beforeRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
117
+ headers: { 'x-admin-token': adminToken },
118
+ });
119
+ const before = await beforeRes.json();
120
+
121
+ // Make successful requests
122
+ for (let i = 0; i < 3; i++) {
123
+ await fetch(`http://127.0.0.1:${proxyPort}/`);
124
+ }
125
+
126
+ const afterRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
127
+ headers: { 'x-admin-token': adminToken },
128
+ });
129
+ const after = await afterRes.json();
130
+
131
+ assert.ok(after.statusCounters['2xx'] >= before.statusCounters['2xx'] + 3);
132
+ });
133
+
134
+ it('should count 5xx responses', async () => {
135
+ const beforeRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
136
+ headers: { 'x-admin-token': adminToken },
137
+ });
138
+ const before = await beforeRes.json();
139
+
140
+ // Make requests that return 500
141
+ for (let i = 0; i < 2; i++) {
142
+ await fetch(`http://127.0.0.1:${proxyPort}/error`);
143
+ }
144
+
145
+ const afterRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
146
+ headers: { 'x-admin-token': adminToken },
147
+ });
148
+ const after = await afterRes.json();
149
+
150
+ assert.ok(after.statusCounters['5xx'] >= before.statusCounters['5xx'] + 2);
151
+ });
152
+
153
+ it('should have all status counter categories', async () => {
154
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
155
+ headers: { 'x-admin-token': adminToken },
156
+ });
157
+ const metrics = await response.json();
158
+
159
+ assert.ok('2xx' in metrics.statusCounters);
160
+ assert.ok('3xx' in metrics.statusCounters);
161
+ assert.ok('4xx' in metrics.statusCounters);
162
+ assert.ok('5xx' in metrics.statusCounters);
163
+ });
164
+ });
165
+
166
+ describe('Inflight Tracking', () => {
167
+ it('should track inflight requests', async () => {
168
+ // Make requests to slow endpoint
169
+ const promises = [];
170
+ for (let i = 0; i < 3; i++) {
171
+ promises.push(fetch(`http://127.0.0.1:${proxyPort}/slow`));
172
+ }
173
+
174
+ // Check inflight while requests are in progress
175
+ await new Promise(resolve => setTimeout(resolve, 500));
176
+
177
+ const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
178
+ headers: { 'x-admin-token': adminToken },
179
+ });
180
+ const metrics = await metricsRes.json();
181
+
182
+ // Should have some inflight requests
183
+ // Note: timing-dependent, so just check inflightMax was tracked
184
+ assert.ok(metrics.inflightMax >= 0);
185
+
186
+ // Wait for slow requests to complete
187
+ await Promise.all(promises);
188
+ });
189
+
190
+ it('should track inflightMax', async () => {
191
+ const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
192
+ headers: { 'x-admin-token': adminToken },
193
+ });
194
+ const metrics = await metricsRes.json();
195
+
196
+ // inflightMax should be >= current inflight
197
+ assert.ok(metrics.inflightMax >= metrics.inflight);
198
+ });
199
+ });
200
+
201
+ describe('Rolling RPS', () => {
202
+ it('should calculate rolling RPS over 60 seconds', async () => {
203
+ // Make some requests
204
+ for (let i = 0; i < 10; i++) {
205
+ await fetch(`http://127.0.0.1:${proxyPort}/`);
206
+ }
207
+
208
+ const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
209
+ headers: { 'x-admin-token': adminToken },
210
+ });
211
+ const metrics = await metricsRes.json();
212
+
213
+ // RPS should be a number >= 0
214
+ assert.ok(typeof metrics.rollingRps60 === 'number');
215
+ assert.ok(metrics.rollingRps60 >= 0);
216
+ });
217
+
218
+ it('should update RPS as requests come in', async () => {
219
+ const beforeRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
220
+ headers: { 'x-admin-token': adminToken },
221
+ });
222
+ const before = await beforeRes.json();
223
+
224
+ // Make burst of requests
225
+ const promises = [];
226
+ for (let i = 0; i < 20; i++) {
227
+ promises.push(fetch(`http://127.0.0.1:${proxyPort}/`));
228
+ }
229
+ await Promise.all(promises);
230
+
231
+ const afterRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
232
+ headers: { 'x-admin-token': adminToken },
233
+ });
234
+ const after = await afterRes.json();
235
+
236
+ // RPS should have increased
237
+ assert.ok(after.rollingRps60 >= before.rollingRps60);
238
+ });
239
+ });
240
+
241
+ describe('Health Metrics', () => {
242
+ it('should track health status', async () => {
243
+ const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
244
+ headers: { 'x-admin-token': adminToken },
245
+ });
246
+ const metrics = await metricsRes.json();
247
+
248
+ assert.ok('activePortOk' in metrics.health);
249
+ assert.ok('lastCheckedAt' in metrics.health);
250
+ assert.ok('consecutiveFails' in metrics.health);
251
+ });
252
+
253
+ it('should update health after health check', async () => {
254
+ // Trigger a health check via /__health endpoint
255
+ await fetch(`http://127.0.0.1:${proxyPort}/__health`, {
256
+ headers: { 'x-admin-token': adminToken },
257
+ });
258
+
259
+ const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
260
+ headers: { 'x-admin-token': adminToken },
261
+ });
262
+ const metrics = await metricsRes.json();
263
+
264
+ // lastCheckedAt should be set
265
+ assert.ok(metrics.health.lastCheckedAt);
266
+ });
267
+ });
268
+
269
+ describe('Error Tracking', () => {
270
+ it('should track proxy errors', async () => {
271
+ const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
272
+ headers: { 'x-admin-token': adminToken },
273
+ });
274
+ const metrics = await metricsRes.json();
275
+
276
+ assert.ok(typeof metrics.totalProxyErrors === 'number');
277
+ });
278
+
279
+ it('should record last proxy error details', async () => {
280
+ // Create a daemon with invalid target port
281
+ const badPort = await getFreePort();
282
+ const badDaemon = spawn('node', ['portokd.mjs'], {
283
+ env: {
284
+ ...process.env,
285
+ LISTEN_PORT: String(badPort),
286
+ INITIAL_TARGET_PORT: '1', // Invalid/closed port
287
+ ADMIN_TOKEN: adminToken,
288
+ STATE_FILE: `/tmp/portok-error-test-${Date.now()}.json`,
289
+ DRAIN_MS: '1000',
290
+ ROLLBACK_WINDOW_MS: '60000',
291
+ ROLLBACK_CHECK_EVERY_MS: '5000',
292
+ ROLLBACK_FAIL_THRESHOLD: '3',
293
+ ADMIN_RATE_LIMIT: '1000',
294
+ },
295
+ stdio: 'pipe',
296
+ });
297
+
298
+ await new Promise(resolve => setTimeout(resolve, 1000));
299
+
300
+ // Make a request that will fail
301
+ await fetch(`http://127.0.0.1:${badPort}/`).catch(() => {});
302
+
303
+ // Wait a bit for error to be recorded
304
+ await new Promise(resolve => setTimeout(resolve, 200));
305
+
306
+ try {
307
+ const metricsRes = await fetch(`http://127.0.0.1:${badPort}/__metrics`, {
308
+ headers: { 'x-admin-token': adminToken },
309
+ });
310
+
311
+ if (metricsRes.ok) {
312
+ const metrics = await metricsRes.json();
313
+
314
+ if (metrics.lastProxyError) {
315
+ assert.ok(metrics.lastProxyError.message);
316
+ assert.ok(metrics.lastProxyError.timestamp);
317
+ assert.ok(metrics.lastProxyError.targetPort);
318
+ }
319
+ }
320
+ } catch {
321
+ // Daemon might not be fully up, which is fine
322
+ }
323
+
324
+ badDaemon.kill('SIGTERM');
325
+ });
326
+ });
327
+ });
328
+