Skip to content

Commit 0b8c715

Browse files
committed
misc: add tests
1 parent a96b1f8 commit 0b8c715

14 files changed

+5005
-488
lines changed

README.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,3 @@ With that, requests with path prefix `/api_v1` will be streamed to client 1, req
133133
## Related
134134

135135
A introduce article: [Building a HTTP Tunnel with WebSocket and Node.JS](https://medium.com/@embbnux/building-a-http-tunnel-with-websocket-and-node-js-98068b0225d3?source=friends_link&sk=985d90ec9f512928b34ed38b7ddcb378)
136-
137-
## TODO
138-
139-
- [ ] Add tests

jest.config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/** @type {import('jest').Config} */
2+
module.exports = {
3+
testEnvironment: 'node',
4+
testMatch: ['**/tests/**/*.test.js'],
5+
verbose: true,
6+
testTimeout: 15000,
7+
maxWorkers: 1,
8+
globalTeardown: '<rootDir>/tests/globalTeardown.js',
9+
};

package-lock.json

Lines changed: 4431 additions & 418 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
},
1212
"license": "GPL-3.0",
1313
"scripts": {
14-
"start": "node server.js"
14+
"start": "node server.js",
15+
"test": "jest"
1516
},
1617
"dependencies": {
1718
"dotenv": "^16.0.0",
@@ -20,5 +21,11 @@
2021
"morgan": "^1.10.1",
2122
"socket.io": "^4.8.1",
2223
"uuid": "^8.3.2"
24+
},
25+
"devDependencies": {
26+
"jest": "^29.7.0",
27+
"supertest": "^6.3.3",
28+
"socket.io-client": "^4.8.1",
29+
"ws": "^8.18.0"
2330
}
2431
}

server.js

Lines changed: 20 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const jwt = require('jsonwebtoken');
88
require('dotenv').config();
99

1010
const { TunnelRequest, TunnelResponse } = require('./lib');
11+
const { getTunnelSocket, setTunnelSocket, removeTunnelSocket, getAvailableTunnelSocket } = require('./utils/tunnels');
12+
const { getReqHeaders } = require('./utils/headers');
1113

1214
const app = express();
1315
const httpServer = http.createServer(app);
@@ -16,52 +18,6 @@ const io = new Server(httpServer, {
1618
path: webTunnelPath,
1719
});
1820

19-
let tunnelSockets = [];
20-
21-
function getTunnelSocket(host, pathPrefix) {
22-
return tunnelSockets.find((s) =>
23-
s.host === host && s.pathPrefix === pathPrefix
24-
);
25-
}
26-
27-
function setTunnelSocket(host, pathPrefix, socket) {
28-
tunnelSockets.push({
29-
host,
30-
pathPrefix,
31-
socket,
32-
});
33-
}
34-
35-
function removeTunnelSocket(host, pathPrefix) {
36-
tunnelSockets = tunnelSockets.filter((s) =>
37-
!(s.host === host && s.pathPrefix === pathPrefix)
38-
);
39-
console.log('tunnelSockets: ', tunnelSockets);
40-
}
41-
42-
function getAvailableTunnelSocket(host, url) {
43-
const tunnels = tunnelSockets.filter((s) => {
44-
if (s.host !== host) {
45-
return false;
46-
}
47-
if (!s.pathPrefix) {
48-
return true;
49-
}
50-
return url.indexOf(s.pathPrefix) === 0;
51-
}).sort((a, b) => {
52-
if (!a.pathPrefix) {
53-
return 1;
54-
}
55-
if (!b.pathPrefix) {
56-
return -1;
57-
}
58-
return b.pathPrefix.length - a.pathPrefix.length;
59-
});
60-
if (tunnels.length === 0) {
61-
return null;
62-
}
63-
return tunnels[0].socket;
64-
}
6521

6622
io.use((socket, next) => {
6723
const connectHost = socket.handshake.headers.host;
@@ -122,23 +78,7 @@ app.get('/tunnel_jwt_generator', (req, res) => {
12278
res.send('Forbidden');
12379
});
12480

125-
function getReqHeaders(req) {
126-
const encrypted = !!(req.isSpdy || req.connection.encrypted || req.connection.pair);
127-
const headers = { ...req.headers };
128-
const url = new URL(`${encrypted ? 'https' : 'http'}://${req.headers.host}`);
129-
const forwardValues = {
130-
for: req.connection.remoteAddress || req.socket.remoteAddress,
131-
port: url.port || (encrypted ? 443 : 80),
132-
proto: encrypted ? 'https' : 'http',
133-
};
134-
['for', 'port', 'proto'].forEach((key) => {
135-
const previousValue = req.headers[`x-forwarded-${key}`] || '';
136-
headers[`x-forwarded-${key}`] =
137-
`${previousValue || ''}${previousValue ? ',' : ''}${forwardValues[key]}`;
138-
});
139-
headers['x-forwarded-host'] = req.headers['x-forwarded-host'] || req.headers.host || '';
140-
return headers;
141-
}
81+
// helpers moved to utils
14282

14383
app.use('/', (req, res) => {
14484
const tunnelSocket = getAvailableTunnelSocket(req.headers.host, req.url);
@@ -283,5 +223,20 @@ httpServer.on('upgrade', (req, socket, head) => {
283223
tunnelResponse.once('response', onResponse);
284224
});
285225

286-
httpServer.listen(process.env.PORT || 3000);
287-
console.log(`app start at http://localhost:${process.env.PORT || 3000}`);
226+
const PORT = process.env.PORT || 3000;
227+
if (require.main === module) {
228+
httpServer.listen(PORT);
229+
console.log(`app start at http://localhost:${PORT}`);
230+
}
231+
232+
// Export helpers for testing and embedding
233+
module.exports = {
234+
app,
235+
httpServer,
236+
io,
237+
getAvailableTunnelSocket,
238+
// Internal utilities exposed for tests
239+
setTunnelSocket,
240+
removeTunnelSocket,
241+
getReqHeaders,
242+
};

tests/auth.invalidJwt.test.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
const { io } = require('socket.io-client');
2+
const jwt = require('jsonwebtoken');
3+
const { httpServer } = require('../server');
4+
5+
function ensureProxyListening() {
6+
return new Promise((resolve) => {
7+
if (httpServer.listening) return resolve(httpServer.address().port);
8+
httpServer.listen(0, () => resolve(httpServer.address().port));
9+
});
10+
}
11+
12+
describe('Auth: invalid JWT is rejected', () => {
13+
let proxyPort;
14+
15+
beforeAll(async () => {
16+
process.env.SECRET_KEY = 'SECRET';
17+
process.env.VERIFY_TOKEN = 'VERIFY';
18+
proxyPort = await ensureProxyListening();
19+
});
20+
21+
afterAll(async () => {
22+
if (httpServer.listening) {
23+
await new Promise((r) => httpServer.close(() => r()));
24+
}
25+
});
26+
27+
test('token signed with correct secret but wrong payload is rejected', async () => {
28+
const socket = io(`http://localhost:${proxyPort}`, {
29+
path: '/$web_tunnel',
30+
transports: ['websocket'],
31+
reconnection: false,
32+
auth: { token: jwt.sign({ token: 'WRONG' }, 'SECRET') },
33+
autoConnect: false,
34+
});
35+
const err = await new Promise((resolve) => {
36+
socket.on('connect', () => resolve(new Error('should not connect')));
37+
socket.on('connect_error', (e) => resolve(e));
38+
socket.connect();
39+
});
40+
socket.close();
41+
expect(err).toBeTruthy();
42+
expect(err.message).toMatch(/Authentication error/i);
43+
});
44+
45+
test('token signed with wrong secret is rejected', async () => {
46+
const socket = io(`http://localhost:${proxyPort}`, {
47+
path: '/$web_tunnel',
48+
transports: ['websocket'],
49+
reconnection: false,
50+
auth: { token: jwt.sign({ token: 'VERIFY' }, 'BADSECRET') },
51+
autoConnect: false,
52+
});
53+
const err = await new Promise((resolve) => {
54+
socket.on('connect', () => resolve(new Error('should not connect')));
55+
socket.on('connect_error', (e) => resolve(e));
56+
socket.connect();
57+
});
58+
socket.close();
59+
expect(err).toBeTruthy();
60+
expect(err.message).toMatch(/Authentication error/i);
61+
});
62+
});

tests/globalTeardown.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = async () => {
2+
// Jest sometimes leaves open timers from socket.io keep-alives.
3+
// Force process exit after small delay allowing disconnect handlers to run.
4+
await new Promise((r) => setTimeout(r, 200));
5+
};

tests/headers.test.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const { getReqHeaders } = require('../utils/headers');
2+
3+
function makeReq({ host = 'example.com', encrypted = false, remoteAddress = '1.2.3.4', xff } = {}) {
4+
return {
5+
isSpdy: false,
6+
connection: { encrypted, pair: undefined, remoteAddress },
7+
socket: { remoteAddress },
8+
headers: Object.assign({ host }, xff ? { 'x-forwarded-for': xff } : {}),
9+
};
10+
}
11+
12+
describe('getReqHeaders', () => {
13+
test('adds x-forwarded-* when none exist over http', () => {
14+
const req = makeReq();
15+
const headers = getReqHeaders(req);
16+
expect(headers['x-forwarded-for']).toBe('1.2.3.4');
17+
expect(headers['x-forwarded-port']).toBe('80');
18+
expect(headers['x-forwarded-proto']).toBe('http');
19+
expect(headers['x-forwarded-host']).toBe('example.com');
20+
});
21+
22+
test('appends to existing x-forwarded-for and sets https/443 when encrypted', () => {
23+
const req = makeReq({ encrypted: true, xff: '2.2.2.2' });
24+
const headers = getReqHeaders(req);
25+
expect(headers['x-forwarded-for']).toBe('2.2.2.2,1.2.3.4');
26+
expect(headers['x-forwarded-port']).toBe('443');
27+
expect(headers['x-forwarded-proto']).toBe('https');
28+
});
29+
});

tests/integration.http.test.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
const http = require('http');
2+
const { io } = require('socket.io-client');
3+
const jwt = require('jsonwebtoken');
4+
5+
// Import server pieces
6+
const { httpServer } = require('../server');
7+
// Use the client-side tunnel classes for the simulated client
8+
const { TunnelRequest, TunnelResponse } = require('../../proxy-client/lib');
9+
10+
// Helper to create a local target HTTP server
11+
function createLocalTarget(handler) {
12+
return new Promise((resolve) => {
13+
const server = http.createServer(handler);
14+
server.listen(0, () => {
15+
const { port } = server.address();
16+
resolve({ server, port });
17+
});
18+
});
19+
}
20+
21+
// Start the proxy server on ephemeral port
22+
function ensureProxyListening() {
23+
return new Promise((resolve) => {
24+
if (httpServer.listening) {
25+
return resolve(httpServer.address().port);
26+
}
27+
httpServer.listen(0, () => {
28+
resolve(httpServer.address().port);
29+
});
30+
});
31+
}
32+
33+
describe('Integration: HTTP tunnel flow', () => {
34+
let localTarget;
35+
let proxyPort;
36+
let clientSocket;
37+
38+
beforeAll(async () => {
39+
// Create a simple echo JSON endpoint
40+
localTarget = await createLocalTarget((req, res) => {
41+
if (req.url === '/ping' && req.method === 'GET') {
42+
res.writeHead(200, { 'content-type': 'application/json' });
43+
res.end(JSON.stringify({ ok: true }));
44+
return;
45+
}
46+
res.writeHead(404);
47+
res.end('NF');
48+
});
49+
proxyPort = await ensureProxyListening();
50+
51+
// Set auth envs expected by the server for Socket.IO auth middleware
52+
process.env.SECRET_KEY = 'SECRET';
53+
process.env.VERIFY_TOKEN = 'VERIFY';
54+
55+
// Fake client socket to receive tunneled requests (simulate real tunnel client logic minimal subset)
56+
clientSocket = io(`http://localhost:${proxyPort}`, {
57+
path: '/$web_tunnel',
58+
transports: ['websocket'],
59+
auth: { token: jwt.sign({ token: 'VERIFY' }, 'SECRET') },
60+
autoConnect: false,
61+
});
62+
await new Promise((resolve, reject) => {
63+
clientSocket.on('connect_error', reject);
64+
clientSocket.on('connect', resolve);
65+
clientSocket.connect();
66+
});
67+
});
68+
69+
afterAll(async () => {
70+
if (clientSocket && clientSocket.connected) clientSocket.disconnect();
71+
await new Promise((r) => httpServer.close(() => r()));
72+
await new Promise((r) => localTarget.server.close(() => r()));
73+
});
74+
75+
test('GET /ping proxied successfully', async () => {
76+
// Implement minimal client-side handling of tunnel events
77+
clientSocket.on('request', (requestId, request) => {
78+
// rewrite target
79+
request.port = localTarget.port;
80+
request.hostname = 'localhost';
81+
const localReq = http.request(request, (localRes) => {
82+
const tunnelResponse = new TunnelResponse({ socket: clientSocket, responseId: requestId });
83+
tunnelResponse.writeHead(localRes.statusCode, localRes.statusMessage, localRes.headers, localRes.httpVersion);
84+
localRes.pipe(tunnelResponse);
85+
});
86+
const tunnelRequest = new TunnelRequest({ socket: clientSocket, requestId });
87+
tunnelRequest.pipe(localReq);
88+
});
89+
90+
const body = await new Promise((resolve, reject) => {
91+
const req = http.request({
92+
host: 'localhost',
93+
port: proxyPort,
94+
path: '/ping',
95+
method: 'GET',
96+
headers: { Host: `localhost:${proxyPort}` },
97+
}, (res) => {
98+
let data = '';
99+
res.setEncoding('utf8');
100+
res.on('data', (chunk) => (data += chunk));
101+
res.on('end', () => {
102+
try {
103+
resolve({ status: res.statusCode, json: JSON.parse(data) });
104+
} catch (e) {
105+
reject(e);
106+
}
107+
});
108+
});
109+
req.on('error', reject);
110+
req.end();
111+
});
112+
113+
expect(body.status).toBe(200);
114+
expect(body.json).toEqual({ ok: true });
115+
});
116+
});

0 commit comments

Comments
 (0)