@yousolution/node-red-contrib-you-tunnel-websocket 1.1.2 → 1.2.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/AGENTS.md +45 -0
- package/CHANGELOG.md +27 -0
- package/README.md +83 -4
- package/STABILITY_CONTRACT.md +299 -0
- package/nodes/wsTunnel.html +126 -4
- package/nodes/wsTunnel.js +99 -6
- package/package.json +4 -12
- package/test/ki-001-host-path-passthrough.test.js +187 -0
- package/test/ki-002-tunnel-id-header-default.test.js +60 -0
- package/test/ki-003-debug-log-level.test.js +180 -0
- package/test/ki-004-console-error-usage.test.js +144 -0
- package/test/m-3-concurrent-deployment.test.js +227 -0
- package/test/output-events.test.js +241 -0
- package/test/rwt-001-port-exclusivity.test.js +171 -0
- package/test/rwt-001-port-release.test.js +58 -0
- package/test/rwt-002-shutdown-completion.test.js +226 -0
- package/test/rwt-003-runtime-editor-compatibility.test.js +113 -0
- package/test/rwt-004-persisted-config-compatibility.test.js +140 -0
- package/test/start-failure-path.test.js +212 -0
- package/test/wstunnel-command.test.js +203 -0
- package/test/wstunnel-metrics-input.test.js +234 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KI-004 — console.error in Production Code (Regression Test)
|
|
3
|
+
*
|
|
4
|
+
* This test proves that the stop-failure error path uses console.error()
|
|
5
|
+
* instead of logger.error(), bypassing log level filtering and context.
|
|
6
|
+
*
|
|
7
|
+
* Expected behavior after fix:
|
|
8
|
+
* - Stop failure is logged via logger.error(), not console.error()
|
|
9
|
+
*
|
|
10
|
+
* Current (defective) behavior:
|
|
11
|
+
* - Stop failure is logged via console.error()
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
|
|
17
|
+
|
|
18
|
+
// Mock state
|
|
19
|
+
let mockStopResolved = true;
|
|
20
|
+
|
|
21
|
+
// Mock stopWebSocketServer
|
|
22
|
+
const mockStopWebSocketServer = jest.fn((port) => {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
setImmediate(() => {
|
|
25
|
+
if (mockStopResolved) {
|
|
26
|
+
resolve();
|
|
27
|
+
} else {
|
|
28
|
+
reject(new Error('Stop failed'));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Mock startWebSocketServer
|
|
35
|
+
const mockStartWebSocketServer = jest.fn((opts) =>
|
|
36
|
+
Promise.resolve({
|
|
37
|
+
[opts.port]: {
|
|
38
|
+
webSocketServer: {
|
|
39
|
+
on: jest.fn(),
|
|
40
|
+
clients: new Set(),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Mock logger
|
|
47
|
+
const mockLogger = {
|
|
48
|
+
info: jest.fn(),
|
|
49
|
+
warn: jest.fn(),
|
|
50
|
+
error: jest.fn(),
|
|
51
|
+
debug: jest.fn(),
|
|
52
|
+
trace: jest.fn(),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Mock setLogContext and setLogLevel
|
|
56
|
+
const mockSetLogContext = jest.fn();
|
|
57
|
+
const mockSetLogLevel = jest.fn();
|
|
58
|
+
|
|
59
|
+
// Mock RED object
|
|
60
|
+
function createMockRED() {
|
|
61
|
+
const registeredTypes = {};
|
|
62
|
+
return {
|
|
63
|
+
nodes: {
|
|
64
|
+
createNode: jest.fn((node, config) => {
|
|
65
|
+
node.status = jest.fn();
|
|
66
|
+
node.on = jest.fn((event, handler) => {
|
|
67
|
+
node._handlers = node._handlers || {};
|
|
68
|
+
node._handlers[event] = handler;
|
|
69
|
+
});
|
|
70
|
+
}),
|
|
71
|
+
registerType: jest.fn((type, constructor) => {
|
|
72
|
+
registeredTypes[type] = constructor;
|
|
73
|
+
}),
|
|
74
|
+
getNode: jest.fn(() => ({
|
|
75
|
+
options: {
|
|
76
|
+
name: 'test-tunnel',
|
|
77
|
+
host: 'localhost',
|
|
78
|
+
port: 1880,
|
|
79
|
+
path: '',
|
|
80
|
+
tunnelIdHeaderName: 'x-tunnel-id',
|
|
81
|
+
logLevel: 'info',
|
|
82
|
+
},
|
|
83
|
+
})),
|
|
84
|
+
},
|
|
85
|
+
_registeredTypes: registeredTypes,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('KI-004 — error logging mechanism (regression)', () => {
|
|
90
|
+
beforeAll(() => {
|
|
91
|
+
const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
|
|
92
|
+
serverModule.stopWebSocketServer = mockStopWebSocketServer;
|
|
93
|
+
serverModule.startWebSocketServer = mockStartWebSocketServer;
|
|
94
|
+
|
|
95
|
+
const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
|
|
96
|
+
utilsModule.logger = mockLogger;
|
|
97
|
+
utilsModule.setLogContext = mockSetLogContext;
|
|
98
|
+
utilsModule.setLogLevel = mockSetLogLevel;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
mockStopResolved = true;
|
|
103
|
+
mockStopWebSocketServer.mockClear();
|
|
104
|
+
mockStartWebSocketServer.mockClear();
|
|
105
|
+
mockLogger.info.mockClear();
|
|
106
|
+
mockLogger.debug.mockClear();
|
|
107
|
+
mockLogger.error.mockClear();
|
|
108
|
+
mockLogger.warn.mockClear();
|
|
109
|
+
mockSetLogContext.mockClear();
|
|
110
|
+
mockSetLogLevel.mockClear();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('stop failure is logged via logger.error, not console.error', async () => {
|
|
114
|
+
const RED = createMockRED();
|
|
115
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
116
|
+
tunnelModule(RED);
|
|
117
|
+
|
|
118
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
119
|
+
const node = {};
|
|
120
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
121
|
+
|
|
122
|
+
// Wait for constructor to succeed and set instances[id].state
|
|
123
|
+
// mockStopWebSocketServer resolves via setImmediate
|
|
124
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
125
|
+
|
|
126
|
+
// Now make stop fail
|
|
127
|
+
mockStopResolved = false;
|
|
128
|
+
|
|
129
|
+
const done = jest.fn();
|
|
130
|
+
mockLogger.error.mockClear();
|
|
131
|
+
|
|
132
|
+
node._handlers.close.call(node, false, done);
|
|
133
|
+
|
|
134
|
+
// Wait for close handler promise chain
|
|
135
|
+
// mockStopWebSocketServer rejects via setImmediate
|
|
136
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
137
|
+
|
|
138
|
+
// ASSERTION: logger.error must be called with the error message
|
|
139
|
+
// This FAILS with current code because console.error is used instead
|
|
140
|
+
expect(mockLogger.error).toHaveBeenCalled();
|
|
141
|
+
const errorCalls = mockLogger.error.mock.calls.map((c) => c[0]);
|
|
142
|
+
expect(errorCalls.some((msg) => msg.includes('Error stopping server'))).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M-3 — Concurrent Deployment on Same Port (Regression Test)
|
|
3
|
+
*
|
|
4
|
+
* Documents the interleaving when two wstunnel server nodes target the same
|
|
5
|
+
* port. The second node fails with EADDRINUSE because the library's
|
|
6
|
+
* startWebSocketServer (synchronous) creates a new WebSocket.Server which
|
|
7
|
+
* throws if the port is already bound.
|
|
8
|
+
*
|
|
9
|
+
* Observable behavior:
|
|
10
|
+
* 1. Node A succeeds; Node A stores state in instances[nodeA].
|
|
11
|
+
* 2. Node B fails; Node B has no state (instances[nodeB] stays empty).
|
|
12
|
+
* 3. Node A close → calls stopWebSocketServer → cleans up.
|
|
13
|
+
* 4. Node B close → no-op (no state to clean up).
|
|
14
|
+
*
|
|
15
|
+
* RWT-001 is NOT violated: the second node never starts. The library
|
|
16
|
+
* throws EADDRINUSE, which is caught by .catch().
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
|
|
22
|
+
|
|
23
|
+
// Track which ports have been "started" — second call on same port throws
|
|
24
|
+
let startedPorts = new Set();
|
|
25
|
+
|
|
26
|
+
// Mock stopWebSocketServer — resolves via setImmediate (no-op, no state check)
|
|
27
|
+
const mockStopWebSocketServer = jest.fn(() =>
|
|
28
|
+
new Promise((resolve) => setImmediate(resolve))
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// Mock startWebSocketServer — succeeds first call, throws EADDRINUSE for duplicate port
|
|
32
|
+
const mockStartWebSocketServer = jest.fn((opts) => {
|
|
33
|
+
const portKey = String(opts.port);
|
|
34
|
+
if (startedPorts.has(portKey)) {
|
|
35
|
+
throw new Error(`EADDRINUSE: port ${opts.port} already in use`);
|
|
36
|
+
}
|
|
37
|
+
startedPorts.add(portKey);
|
|
38
|
+
return {
|
|
39
|
+
[opts.port]: {
|
|
40
|
+
webSocketServer: {
|
|
41
|
+
on: jest.fn(),
|
|
42
|
+
clients: new Set(),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Mock logger
|
|
49
|
+
const mockLogger = {
|
|
50
|
+
info: jest.fn(),
|
|
51
|
+
warn: jest.fn(),
|
|
52
|
+
error: jest.fn(),
|
|
53
|
+
debug: jest.fn(),
|
|
54
|
+
trace: jest.fn(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const mockSetLogContext = jest.fn();
|
|
58
|
+
const mockSetLogLevel = jest.fn();
|
|
59
|
+
|
|
60
|
+
function createMockRED() {
|
|
61
|
+
const registeredTypes = {};
|
|
62
|
+
return {
|
|
63
|
+
nodes: {
|
|
64
|
+
createNode: jest.fn((node, config) => {
|
|
65
|
+
if (!node.id) node.id = 'node-' + Math.random().toString(36).slice(2, 9);
|
|
66
|
+
node.status = jest.fn();
|
|
67
|
+
node.on = jest.fn((event, handler) => {
|
|
68
|
+
node._handlers = node._handlers || {};
|
|
69
|
+
node._handlers[event] = handler;
|
|
70
|
+
});
|
|
71
|
+
}),
|
|
72
|
+
registerType: jest.fn((type, constructor) => {
|
|
73
|
+
registeredTypes[type] = constructor;
|
|
74
|
+
}),
|
|
75
|
+
getNode: jest.fn(() => ({
|
|
76
|
+
options: {
|
|
77
|
+
name: 'test-tunnel',
|
|
78
|
+
host: 'localhost',
|
|
79
|
+
port: 1880,
|
|
80
|
+
path: '',
|
|
81
|
+
tunnelIdHeaderName: 'x-tunnel-id',
|
|
82
|
+
logLevel: 'info',
|
|
83
|
+
},
|
|
84
|
+
})),
|
|
85
|
+
},
|
|
86
|
+
_registeredTypes: registeredTypes,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('M-3 — Concurrent deployment on same port', () => {
|
|
91
|
+
beforeAll(() => {
|
|
92
|
+
const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
|
|
93
|
+
serverModule.stopWebSocketServer = mockStopWebSocketServer;
|
|
94
|
+
serverModule.startWebSocketServer = mockStartWebSocketServer;
|
|
95
|
+
|
|
96
|
+
const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
|
|
97
|
+
utilsModule.logger = mockLogger;
|
|
98
|
+
utilsModule.setLogContext = mockSetLogContext;
|
|
99
|
+
utilsModule.setLogLevel = mockSetLogLevel;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
startedPorts = new Set();
|
|
104
|
+
mockStopWebSocketServer.mockClear();
|
|
105
|
+
mockStartWebSocketServer.mockClear();
|
|
106
|
+
mockLogger.info.mockClear();
|
|
107
|
+
mockLogger.error.mockClear();
|
|
108
|
+
mockLogger.debug.mockClear();
|
|
109
|
+
mockLogger.warn.mockClear();
|
|
110
|
+
mockSetLogContext.mockClear();
|
|
111
|
+
mockSetLogLevel.mockClear();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('second node on same port fails with EADDRINUSE', async () => {
|
|
115
|
+
const RED = createMockRED();
|
|
116
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
117
|
+
tunnelModule(RED);
|
|
118
|
+
|
|
119
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
120
|
+
|
|
121
|
+
// Node A — succeeds
|
|
122
|
+
const nodeA = { id: 'nodeA' };
|
|
123
|
+
WSTunnelServer.call(nodeA, { wstunnel: 'test-config-id', name: 'server-A' });
|
|
124
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
125
|
+
|
|
126
|
+
// Clear logs after Node A construction
|
|
127
|
+
mockLogger.error.mockClear();
|
|
128
|
+
|
|
129
|
+
// Node B — fails (same port)
|
|
130
|
+
const nodeB = { id: 'nodeB' };
|
|
131
|
+
WSTunnelServer.call(nodeB, { wstunnel: 'test-config-id', name: 'server-B' });
|
|
132
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
133
|
+
|
|
134
|
+
// Node A started successfully — no error logged after its construction
|
|
135
|
+
const startErrors = mockLogger.error.mock.calls.filter((c) =>
|
|
136
|
+
c[0].includes('Error starting WebSocketServer')
|
|
137
|
+
);
|
|
138
|
+
expect(startErrors.length).toBe(1);
|
|
139
|
+
expect(startErrors[0][0]).toContain('1880');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('node B close handler is a no-op after failed start', async () => {
|
|
143
|
+
const RED = createMockRED();
|
|
144
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
145
|
+
tunnelModule(RED);
|
|
146
|
+
|
|
147
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
148
|
+
|
|
149
|
+
const nodeA = { id: 'nodeA' };
|
|
150
|
+
WSTunnelServer.call(nodeA, { wstunnel: 'test-config-id', name: 'server-A' });
|
|
151
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
152
|
+
|
|
153
|
+
const nodeB = { id: 'nodeB' };
|
|
154
|
+
WSTunnelServer.call(nodeB, { wstunnel: 'test-config-id', name: 'server-B' });
|
|
155
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
156
|
+
|
|
157
|
+
const stopCountBefore = mockStopWebSocketServer.mock.calls.length;
|
|
158
|
+
|
|
159
|
+
// Close Node B — should be a no-op (no state stored)
|
|
160
|
+
const doneB = jest.fn();
|
|
161
|
+
nodeB._handlers.close.call(nodeB, false, doneB);
|
|
162
|
+
await Promise.resolve();
|
|
163
|
+
|
|
164
|
+
expect(doneB).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(mockStopWebSocketServer.mock.calls.length).toBe(stopCountBefore);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('node A close calls stopWebSocketServer and cleans up', async () => {
|
|
169
|
+
const RED = createMockRED();
|
|
170
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
171
|
+
tunnelModule(RED);
|
|
172
|
+
|
|
173
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
174
|
+
|
|
175
|
+
const nodeA = { id: 'nodeA' };
|
|
176
|
+
WSTunnelServer.call(nodeA, { wstunnel: 'test-config-id', name: 'server-A' });
|
|
177
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
178
|
+
|
|
179
|
+
const nodeB = { id: 'nodeB' };
|
|
180
|
+
WSTunnelServer.call(nodeB, { wstunnel: 'test-config-id', name: 'server-B' });
|
|
181
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
182
|
+
|
|
183
|
+
mockStopWebSocketServer.mockClear();
|
|
184
|
+
|
|
185
|
+
// Close Node A — should call stopWebSocketServer
|
|
186
|
+
const doneA = jest.fn();
|
|
187
|
+
nodeA._handlers.close.call(nodeA, false, doneA);
|
|
188
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
189
|
+
|
|
190
|
+
expect(doneA).toHaveBeenCalledTimes(1);
|
|
191
|
+
expect(mockStopWebSocketServer).toHaveBeenCalledWith(1880);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('total stop calls = 2 from construction + 1 from node A close', async () => {
|
|
195
|
+
const RED = createMockRED();
|
|
196
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
197
|
+
tunnelModule(RED);
|
|
198
|
+
|
|
199
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
200
|
+
|
|
201
|
+
const nodeA = { id: 'nodeA' };
|
|
202
|
+
WSTunnelServer.call(nodeA, { wstunnel: 'test-config-id', name: 'server-A' });
|
|
203
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
204
|
+
|
|
205
|
+
const nodeB = { id: 'nodeB' };
|
|
206
|
+
WSTunnelServer.call(nodeB, { wstunnel: 'test-config-id', name: 'server-B' });
|
|
207
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
208
|
+
|
|
209
|
+
// 2 stop calls from construction (both no-ops)
|
|
210
|
+
const constructionStopCalls = mockStopWebSocketServer.mock.calls.length;
|
|
211
|
+
expect(constructionStopCalls).toBe(2);
|
|
212
|
+
|
|
213
|
+
// Close both nodes
|
|
214
|
+
const doneA = jest.fn();
|
|
215
|
+
const doneB = jest.fn();
|
|
216
|
+
nodeA._handlers.close.call(nodeA, false, doneA);
|
|
217
|
+
nodeB._handlers.close.call(nodeB, false, doneB);
|
|
218
|
+
|
|
219
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
220
|
+
await Promise.resolve();
|
|
221
|
+
|
|
222
|
+
// Total: 2 (construction) + 1 (Node A close) = 3
|
|
223
|
+
expect(mockStopWebSocketServer.mock.calls.length).toBe(constructionStopCalls + 1);
|
|
224
|
+
expect(doneA).toHaveBeenCalledTimes(1);
|
|
225
|
+
expect(doneB).toHaveBeenCalledTimes(1);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wstunnel server — Output Events (Regression Test)
|
|
3
|
+
*
|
|
4
|
+
* Tests that the wstunnel server node emits connect and disconnect events
|
|
5
|
+
* on its output when tunnels register (CONFIG handshake) and close.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { EventEmitter } = require('events');
|
|
10
|
+
|
|
11
|
+
const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
|
|
12
|
+
|
|
13
|
+
const PORT = 1880;
|
|
14
|
+
const TUNNEL_ID = 'test-tunnel-uuid-001';
|
|
15
|
+
|
|
16
|
+
let serverHandlers = {};
|
|
17
|
+
let mockTunnels = {};
|
|
18
|
+
|
|
19
|
+
const mockStopWebSocketServer = jest.fn(() => Promise.resolve());
|
|
20
|
+
|
|
21
|
+
const mockStartWebSocketServer = jest.fn((opts) => {
|
|
22
|
+
serverHandlers = {};
|
|
23
|
+
mockTunnels = {};
|
|
24
|
+
return Promise.resolve({
|
|
25
|
+
[opts.port]: {
|
|
26
|
+
webSocketServer: {
|
|
27
|
+
on: jest.fn((event, handler) => {
|
|
28
|
+
serverHandlers[event] = handler;
|
|
29
|
+
}),
|
|
30
|
+
clients: new Set(),
|
|
31
|
+
},
|
|
32
|
+
websocketTunnels: mockTunnels,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const mockLogger = {
|
|
38
|
+
info: jest.fn(),
|
|
39
|
+
warn: jest.fn(),
|
|
40
|
+
error: jest.fn(),
|
|
41
|
+
debug: jest.fn(),
|
|
42
|
+
trace: jest.fn(),
|
|
43
|
+
};
|
|
44
|
+
const mockSetLogContext = jest.fn();
|
|
45
|
+
const mockSetLogLevel = jest.fn();
|
|
46
|
+
|
|
47
|
+
function createMockRED() {
|
|
48
|
+
const registeredTypes = {};
|
|
49
|
+
return {
|
|
50
|
+
nodes: {
|
|
51
|
+
createNode: jest.fn((node) => {
|
|
52
|
+
if (!node.id) node.id = 'node-' + Math.random().toString(36).slice(2, 9);
|
|
53
|
+
node.status = jest.fn();
|
|
54
|
+
node.send = jest.fn();
|
|
55
|
+
node.on = jest.fn((event, handler) => {
|
|
56
|
+
node._handlers = node._handlers || {};
|
|
57
|
+
node._handlers[event] = handler;
|
|
58
|
+
});
|
|
59
|
+
}),
|
|
60
|
+
registerType: jest.fn((type, constructor) => {
|
|
61
|
+
registeredTypes[type] = constructor;
|
|
62
|
+
}),
|
|
63
|
+
getNode: jest.fn(() => ({
|
|
64
|
+
options: {
|
|
65
|
+
name: 'test-tunnel',
|
|
66
|
+
host: 'localhost',
|
|
67
|
+
port: PORT,
|
|
68
|
+
path: '',
|
|
69
|
+
tunnelIdHeaderName: 'x-tunnel-id',
|
|
70
|
+
logLevel: 'info',
|
|
71
|
+
},
|
|
72
|
+
})),
|
|
73
|
+
},
|
|
74
|
+
_registeredTypes: registeredTypes,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createMockWs(remoteAddress = '192.168.1.10') {
|
|
79
|
+
const ws = new EventEmitter();
|
|
80
|
+
ws._socket = { remoteAddress, remotePort: 54321 };
|
|
81
|
+
ws.readyState = 1;
|
|
82
|
+
return ws;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('wstunnel server — output events', () => {
|
|
86
|
+
beforeAll(() => {
|
|
87
|
+
const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
|
|
88
|
+
serverModule.stopWebSocketServer = mockStopWebSocketServer;
|
|
89
|
+
serverModule.startWebSocketServer = mockStartWebSocketServer;
|
|
90
|
+
|
|
91
|
+
const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
|
|
92
|
+
utilsModule.logger = mockLogger;
|
|
93
|
+
utilsModule.setLogContext = mockSetLogContext;
|
|
94
|
+
utilsModule.setLogLevel = mockSetLogLevel;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
serverHandlers = {};
|
|
99
|
+
mockTunnels = {};
|
|
100
|
+
mockStopWebSocketServer.mockClear();
|
|
101
|
+
mockStartWebSocketServer.mockClear();
|
|
102
|
+
mockLogger.info.mockClear();
|
|
103
|
+
mockLogger.error.mockClear();
|
|
104
|
+
mockLogger.debug.mockClear();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('emits connect event when tunnel registers via CONFIG', async () => {
|
|
108
|
+
const RED = createMockRED();
|
|
109
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
110
|
+
tunnelModule(RED);
|
|
111
|
+
|
|
112
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
113
|
+
const node = { id: 'testNode' };
|
|
114
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
115
|
+
|
|
116
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
117
|
+
|
|
118
|
+
const mockWs = createMockWs('10.0.0.1');
|
|
119
|
+
const connectedAt = Date.now();
|
|
120
|
+
|
|
121
|
+
mockTunnels[TUNNEL_ID] = { ws: mockWs };
|
|
122
|
+
|
|
123
|
+
serverHandlers['connection'](mockWs);
|
|
124
|
+
|
|
125
|
+
mockWs.emit('message', Buffer.from('config'));
|
|
126
|
+
|
|
127
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
128
|
+
|
|
129
|
+
expect(node.send).toHaveBeenCalledTimes(1);
|
|
130
|
+
expect(node.send).toHaveBeenCalledWith({
|
|
131
|
+
event: 'connect',
|
|
132
|
+
tunnelId: TUNNEL_ID,
|
|
133
|
+
remoteAddress: '10.0.0.1',
|
|
134
|
+
connectedAt: expect.any(Number),
|
|
135
|
+
serverPort: PORT,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('emits disconnect event when registered tunnel closes', async () => {
|
|
140
|
+
const RED = createMockRED();
|
|
141
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
142
|
+
tunnelModule(RED);
|
|
143
|
+
|
|
144
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
145
|
+
const node = { id: 'testNode' };
|
|
146
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
147
|
+
|
|
148
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
149
|
+
|
|
150
|
+
const mockWs = createMockWs('10.0.0.2');
|
|
151
|
+
mockTunnels[TUNNEL_ID] = { ws: mockWs };
|
|
152
|
+
|
|
153
|
+
serverHandlers['connection'](mockWs);
|
|
154
|
+
mockWs.emit('message', Buffer.from('config'));
|
|
155
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
156
|
+
|
|
157
|
+
node.send.mockClear();
|
|
158
|
+
|
|
159
|
+
mockWs.emit('close');
|
|
160
|
+
|
|
161
|
+
expect(node.send).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(node.send).toHaveBeenCalledWith({
|
|
163
|
+
event: 'disconnect',
|
|
164
|
+
tunnelId: TUNNEL_ID,
|
|
165
|
+
remoteAddress: '10.0.0.2',
|
|
166
|
+
serverPort: PORT,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('does not emit events for connections without CONFIG', async () => {
|
|
171
|
+
const RED = createMockRED();
|
|
172
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
173
|
+
tunnelModule(RED);
|
|
174
|
+
|
|
175
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
176
|
+
const node = { id: 'testNode' };
|
|
177
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
178
|
+
|
|
179
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
180
|
+
|
|
181
|
+
const mockWs = createMockWs('10.0.0.3');
|
|
182
|
+
|
|
183
|
+
serverHandlers['connection'](mockWs);
|
|
184
|
+
|
|
185
|
+
mockWs.emit('close');
|
|
186
|
+
|
|
187
|
+
expect(node.send).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('connect event contains all expected fields', async () => {
|
|
191
|
+
const RED = createMockRED();
|
|
192
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
193
|
+
tunnelModule(RED);
|
|
194
|
+
|
|
195
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
196
|
+
const node = { id: 'testNode' };
|
|
197
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
198
|
+
|
|
199
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
200
|
+
|
|
201
|
+
const mockWs = createMockWs('172.16.0.5');
|
|
202
|
+
mockTunnels[TUNNEL_ID] = { ws: mockWs };
|
|
203
|
+
|
|
204
|
+
serverHandlers['connection'](mockWs);
|
|
205
|
+
mockWs.emit('message', Buffer.from('config'));
|
|
206
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
207
|
+
|
|
208
|
+
const sentMsg = node.send.mock.calls[0][0];
|
|
209
|
+
expect(sentMsg).toHaveProperty('event', 'connect');
|
|
210
|
+
expect(sentMsg).toHaveProperty('tunnelId', TUNNEL_ID);
|
|
211
|
+
expect(sentMsg).toHaveProperty('remoteAddress', '172.16.0.5');
|
|
212
|
+
expect(sentMsg).toHaveProperty('connectedAt');
|
|
213
|
+
expect(typeof sentMsg.connectedAt).toBe('number');
|
|
214
|
+
expect(sentMsg).toHaveProperty('serverPort', PORT);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('disconnect event is not emitted for unconfirmed connections', async () => {
|
|
218
|
+
const RED = createMockRED();
|
|
219
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
220
|
+
tunnelModule(RED);
|
|
221
|
+
|
|
222
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
223
|
+
const node = { id: 'testNode' };
|
|
224
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
225
|
+
|
|
226
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
227
|
+
|
|
228
|
+
const mockWs = createMockWs('10.0.0.4');
|
|
229
|
+
|
|
230
|
+
serverHandlers['connection'](mockWs);
|
|
231
|
+
|
|
232
|
+
mockWs.emit('message', Buffer.from('config'));
|
|
233
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
234
|
+
|
|
235
|
+
node.send.mockClear();
|
|
236
|
+
|
|
237
|
+
mockWs.emit('close');
|
|
238
|
+
|
|
239
|
+
expect(node.send).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
});
|