@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
package/nodes/wsTunnel.js
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
**/
|
|
16
|
-
const { startWebSocketServer, stopWebSocketServer } = require('@remotelinker/reverse-ws-tunnel/server');
|
|
17
|
-
const { logger, setLogContext, setLogLevel } = require('@remotelinker/reverse-ws-tunnel/utils');
|
|
16
|
+
const { startWebSocketServer, stopWebSocketServer, sendCommand } = require('@remotelinker/reverse-ws-tunnel/server');
|
|
17
|
+
const { logger, setLogContext, setLogLevel, getMetrics } = require('@remotelinker/reverse-ws-tunnel/utils');
|
|
18
18
|
|
|
19
19
|
const instances = {};
|
|
20
20
|
|
|
@@ -49,10 +49,12 @@ module.exports = function (RED) {
|
|
|
49
49
|
|
|
50
50
|
node.status({ fill: 'red', shape: 'ring', text: 'disconnected' });
|
|
51
51
|
const port = node.tunnelConfig.options.port;
|
|
52
|
-
const host = node.tunnelConfig.options.host
|
|
53
|
-
const path = node.tunnelConfig.options.path
|
|
52
|
+
const host = node.tunnelConfig.options.host || undefined;
|
|
53
|
+
const path = node.tunnelConfig.options.path || undefined;
|
|
54
54
|
const tunnelIdHeaderName = node.tunnelConfig.options.tunnelIdHeaderName;
|
|
55
55
|
instances[id] = instances[id] || {};
|
|
56
|
+
const pendingConnections = new Map();
|
|
57
|
+
const confirmedConnections = new Map();
|
|
56
58
|
// Cleanup any existing server on this port before starting
|
|
57
59
|
// This handles cases where previous deployment didn't cleanup properly
|
|
58
60
|
stopWebSocketServer(port)
|
|
@@ -101,7 +103,45 @@ module.exports = function (RED) {
|
|
|
101
103
|
});
|
|
102
104
|
logger.info(`WebSocket connection from ${clientAddress}:${clientPort}`);
|
|
103
105
|
logger.info(`Total connected clients: ${totalClients}`);
|
|
106
|
+
|
|
107
|
+
const connectedAt = Date.now();
|
|
108
|
+
pendingConnections.set(ws, { remoteAddress: clientAddress, connectedAt });
|
|
109
|
+
|
|
110
|
+
const onFirstMessage = () => {
|
|
111
|
+
ws.removeListener('message', onFirstMessage);
|
|
112
|
+
setImmediate(() => {
|
|
113
|
+
const tunnels =
|
|
114
|
+
instances[id]?.state?.[port]?.websocketTunnels || {};
|
|
115
|
+
for (const [tunnelId, tunnel] of Object.entries(tunnels)) {
|
|
116
|
+
if (tunnel.ws === ws) {
|
|
117
|
+
const pending = pendingConnections.get(ws);
|
|
118
|
+
if (pending) {
|
|
119
|
+
pendingConnections.delete(ws);
|
|
120
|
+
confirmedConnections.set(ws, {
|
|
121
|
+
tunnelId,
|
|
122
|
+
remoteAddress: pending.remoteAddress,
|
|
123
|
+
connectedAt: pending.connectedAt,
|
|
124
|
+
});
|
|
125
|
+
node.send({
|
|
126
|
+
event: 'connect',
|
|
127
|
+
tunnelId,
|
|
128
|
+
remoteAddress: pending.remoteAddress,
|
|
129
|
+
connectedAt: pending.connectedAt,
|
|
130
|
+
serverPort: port,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
ws.on('message', onFirstMessage);
|
|
139
|
+
|
|
104
140
|
ws.on('close', () => {
|
|
141
|
+
const confirmed = confirmedConnections.get(ws);
|
|
142
|
+
confirmedConnections.delete(ws);
|
|
143
|
+
pendingConnections.delete(ws);
|
|
144
|
+
|
|
105
145
|
const remainingClients = server.clients.size;
|
|
106
146
|
node.status({
|
|
107
147
|
fill: 'green',
|
|
@@ -110,11 +150,31 @@ module.exports = function (RED) {
|
|
|
110
150
|
});
|
|
111
151
|
logger.info(`Client disconnected: ${clientAddress}:${clientPort}`);
|
|
112
152
|
logger.info(`Remaining clients: ${remainingClients}`);
|
|
153
|
+
|
|
154
|
+
if (confirmed) {
|
|
155
|
+
node.send({
|
|
156
|
+
event: 'disconnect',
|
|
157
|
+
tunnelId: confirmed.tunnelId,
|
|
158
|
+
remoteAddress: confirmed.remoteAddress,
|
|
159
|
+
serverPort: port,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
113
162
|
});
|
|
114
163
|
});
|
|
115
164
|
}
|
|
165
|
+
|
|
166
|
+
node.on('input', function (msg) {
|
|
167
|
+
const snapshot = getMetrics().snapshot();
|
|
168
|
+
msg.payload = snapshot;
|
|
169
|
+
msg.event = 'metrics';
|
|
170
|
+
msg.serverPort = port;
|
|
171
|
+
node.send(msg);
|
|
172
|
+
});
|
|
173
|
+
|
|
116
174
|
this.on('close', function (removed, done) {
|
|
117
|
-
logger.
|
|
175
|
+
logger.debug(`Close called for node ${id}, removed=${removed}`);
|
|
176
|
+
pendingConnections.clear();
|
|
177
|
+
confirmedConnections.clear();
|
|
118
178
|
|
|
119
179
|
if (instances[id] && instances[id].state && instances[id].state[port]) {
|
|
120
180
|
stopWebSocketServer(port)
|
|
@@ -124,7 +184,7 @@ module.exports = function (RED) {
|
|
|
124
184
|
done();
|
|
125
185
|
})
|
|
126
186
|
.catch((err) => {
|
|
127
|
-
|
|
187
|
+
logger.error(`Error stopping server on port ${port}:`, err);
|
|
128
188
|
delete instances[id];
|
|
129
189
|
done();
|
|
130
190
|
});
|
|
@@ -135,4 +195,37 @@ module.exports = function (RED) {
|
|
|
135
195
|
});
|
|
136
196
|
}
|
|
137
197
|
RED.nodes.registerType('wstunnel server', WSTunnelServer);
|
|
198
|
+
|
|
199
|
+
function WSTunnelCommand(n) {
|
|
200
|
+
RED.nodes.createNode(this, n);
|
|
201
|
+
const node = this;
|
|
202
|
+
node.tunnelConfig = RED.nodes.getNode(n.wstunnel);
|
|
203
|
+
|
|
204
|
+
node.on('input', function (msg) {
|
|
205
|
+
const port = node.tunnelConfig.options.port;
|
|
206
|
+
const tunnelId = msg.tunnelId || (msg.payload && msg.payload.tunnelId);
|
|
207
|
+
const command = msg.command || (msg.payload && msg.payload.command);
|
|
208
|
+
const args = msg.args || (msg.payload && msg.payload.args) || {};
|
|
209
|
+
|
|
210
|
+
if (!tunnelId || !command) {
|
|
211
|
+
node.error('Missing tunnelId or command');
|
|
212
|
+
msg.payload = false;
|
|
213
|
+
msg.sent = false;
|
|
214
|
+
msg.error = 'Missing tunnelId or command';
|
|
215
|
+
node.send([null, msg]);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const sent = sendCommand(port, tunnelId, command, args);
|
|
220
|
+
msg.payload = sent;
|
|
221
|
+
msg.sent = sent;
|
|
222
|
+
msg.tunnelId = tunnelId;
|
|
223
|
+
msg.command = command;
|
|
224
|
+
if (!sent) {
|
|
225
|
+
msg.error = 'Tunnel not found or not connected';
|
|
226
|
+
}
|
|
227
|
+
node.send([sent ? msg : null, sent ? null : msg]);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
RED.nodes.registerType('wstunnel command', WSTunnelCommand);
|
|
138
231
|
};
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yousolution/node-red-contrib-you-tunnel-websocket",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Module to create websockeet tunnel for NODE-RED",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"update": "PACKAGE_NAME=$(npm pack) && mv $PACKAGE_NAME ./data && cd data && rm -rf node_modules && npm i $PACKAGE_NAME && docker-compose restart",
|
|
8
|
-
"test": "
|
|
8
|
+
"test": "jest --verbose",
|
|
9
9
|
"coverage": "nyc npm run test"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
@@ -23,15 +23,7 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@remotelinker/reverse-ws-tunnel": "^1.0
|
|
27
|
-
"cookie": "^1.0.2",
|
|
28
|
-
"cross-conf-env": "^1.3.0",
|
|
29
|
-
"dotenv": "^17.2.1",
|
|
30
|
-
"express": "^4.21.2",
|
|
31
|
-
"http-parser-js": "^0.5.10",
|
|
32
|
-
"http-proxy": "^1.18.1",
|
|
33
|
-
"uuid": "^11.1.0",
|
|
34
|
-
"ws": "^8.18.0"
|
|
26
|
+
"@remotelinker/reverse-ws-tunnel": "^1.2.0"
|
|
35
27
|
},
|
|
36
28
|
"repository": {
|
|
37
29
|
"type": "git",
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KI-001 — Inverted host/path Default Logic (Regression Test)
|
|
3
|
+
*
|
|
4
|
+
* This test proves that configured host and path values are discarded
|
|
5
|
+
* by the current ternary logic in WSTunnelServer.
|
|
6
|
+
*
|
|
7
|
+
* Expected behavior after fix:
|
|
8
|
+
* - host: '0.0.0.0' is passed through to startWebSocketServer
|
|
9
|
+
* - path: '/api/tunnel' is passed through to startWebSocketServer
|
|
10
|
+
*
|
|
11
|
+
* Current (defective) behavior:
|
|
12
|
+
* - host: '0.0.0.0' becomes ''
|
|
13
|
+
* - path: '/api/tunnel' becomes ''
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
|
|
19
|
+
|
|
20
|
+
// Mock stopWebSocketServer — resolves synchronously to avoid timing issues
|
|
21
|
+
const mockStopWebSocketServer = jest.fn(() => Promise.resolve());
|
|
22
|
+
|
|
23
|
+
// Mock startWebSocketServer — captures the options passed to it
|
|
24
|
+
const mockStartWebSocketServer = jest.fn((opts) =>
|
|
25
|
+
Promise.resolve({
|
|
26
|
+
[opts.port]: {
|
|
27
|
+
webSocketServer: {
|
|
28
|
+
on: jest.fn(),
|
|
29
|
+
clients: new Set(),
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Mock logger
|
|
36
|
+
const mockLogger = {
|
|
37
|
+
info: jest.fn(),
|
|
38
|
+
warn: jest.fn(),
|
|
39
|
+
error: jest.fn(),
|
|
40
|
+
debug: jest.fn(),
|
|
41
|
+
trace: jest.fn(),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Mock setLogContext and setLogLevel
|
|
45
|
+
const mockSetLogContext = jest.fn();
|
|
46
|
+
const mockSetLogLevel = jest.fn();
|
|
47
|
+
|
|
48
|
+
// Creates a mock RED object with configurable getNode return value
|
|
49
|
+
function createMockRED(configOverrides = {}) {
|
|
50
|
+
const registeredTypes = {};
|
|
51
|
+
const defaultConfig = {
|
|
52
|
+
name: 'test-tunnel',
|
|
53
|
+
host: 'localhost',
|
|
54
|
+
port: 1880,
|
|
55
|
+
path: '',
|
|
56
|
+
tunnelIdHeaderName: 'x-tunnel-id',
|
|
57
|
+
logLevel: 'info',
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
nodes: {
|
|
61
|
+
createNode: jest.fn((node, config) => {
|
|
62
|
+
node.status = jest.fn();
|
|
63
|
+
node.on = jest.fn((event, handler) => {
|
|
64
|
+
node._handlers = node._handlers || {};
|
|
65
|
+
node._handlers[event] = handler;
|
|
66
|
+
});
|
|
67
|
+
}),
|
|
68
|
+
registerType: jest.fn((type, constructor) => {
|
|
69
|
+
registeredTypes[type] = constructor;
|
|
70
|
+
}),
|
|
71
|
+
getNode: jest.fn(() => ({
|
|
72
|
+
options: { ...defaultConfig, ...configOverrides },
|
|
73
|
+
})),
|
|
74
|
+
},
|
|
75
|
+
_registeredTypes: registeredTypes,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Flush the promise chain: stopWebSocketServer().then(startWebSocketServer(...))
|
|
80
|
+
async function flushPromises() {
|
|
81
|
+
// Advance past setImmediate and all microtasks
|
|
82
|
+
await new Promise((resolve) => {
|
|
83
|
+
setImmediate(resolve);
|
|
84
|
+
});
|
|
85
|
+
// One more tick to handle the chained .then()
|
|
86
|
+
await new Promise((resolve) => {
|
|
87
|
+
setImmediate(resolve);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('KI-001 — host/path value passthrough (regression)', () => {
|
|
92
|
+
beforeAll(() => {
|
|
93
|
+
const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
|
|
94
|
+
serverModule.stopWebSocketServer = mockStopWebSocketServer;
|
|
95
|
+
serverModule.startWebSocketServer = mockStartWebSocketServer;
|
|
96
|
+
|
|
97
|
+
const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
|
|
98
|
+
utilsModule.logger = mockLogger;
|
|
99
|
+
utilsModule.setLogContext = mockSetLogContext;
|
|
100
|
+
utilsModule.setLogLevel = mockSetLogLevel;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
mockStopWebSocketServer.mockClear();
|
|
105
|
+
mockStartWebSocketServer.mockClear();
|
|
106
|
+
mockLogger.info.mockClear();
|
|
107
|
+
mockSetLogContext.mockClear();
|
|
108
|
+
mockSetLogLevel.mockClear();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('configured host "0.0.0.0" is passed through to startWebSocketServer', async () => {
|
|
112
|
+
const RED = createMockRED({ host: '0.0.0.0' });
|
|
113
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
114
|
+
tunnelModule(RED);
|
|
115
|
+
|
|
116
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
117
|
+
const node = {};
|
|
118
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
119
|
+
|
|
120
|
+
await flushPromises();
|
|
121
|
+
|
|
122
|
+
expect(mockStartWebSocketServer).toHaveBeenCalledTimes(1);
|
|
123
|
+
const passedOptions = mockStartWebSocketServer.mock.calls[0][0];
|
|
124
|
+
|
|
125
|
+
// ASSERTION: host must be '0.0.0.0', not ''
|
|
126
|
+
// This FAILS with current code because the ternary converts truthy → ''
|
|
127
|
+
expect(passedOptions.host).toBe('0.0.0.0');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('configured path "/api/tunnel" is passed through to startWebSocketServer', async () => {
|
|
131
|
+
const RED = createMockRED({ path: '/api/tunnel' });
|
|
132
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
133
|
+
tunnelModule(RED);
|
|
134
|
+
|
|
135
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
136
|
+
const node = {};
|
|
137
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
138
|
+
|
|
139
|
+
await flushPromises();
|
|
140
|
+
|
|
141
|
+
expect(mockStartWebSocketServer).toHaveBeenCalledTimes(1);
|
|
142
|
+
const passedOptions = mockStartWebSocketServer.mock.calls[0][0];
|
|
143
|
+
|
|
144
|
+
// ASSERTION: path must be '/api/tunnel', not ''
|
|
145
|
+
// This FAILS with current code because the ternary converts truthy → ''
|
|
146
|
+
expect(passedOptions.path).toBe('/api/tunnel');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('empty path becomes undefined (semantically equivalent to unset)', async () => {
|
|
150
|
+
const RED = createMockRED({ path: '' });
|
|
151
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
152
|
+
tunnelModule(RED);
|
|
153
|
+
|
|
154
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
155
|
+
const node = {};
|
|
156
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
157
|
+
|
|
158
|
+
await flushPromises();
|
|
159
|
+
|
|
160
|
+
expect(mockStartWebSocketServer).toHaveBeenCalledTimes(1);
|
|
161
|
+
const passedOptions = mockStartWebSocketServer.mock.calls[0][0];
|
|
162
|
+
|
|
163
|
+
// ASSERTION: empty path should become undefined (ws library default = accept all)
|
|
164
|
+
// The ws library treats undefined/null the same as falsy for path filtering
|
|
165
|
+
expect(passedOptions.path).toBeUndefined();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('both host and path are passed through simultaneously', async () => {
|
|
169
|
+
const RED = createMockRED({ host: '0.0.0.0', path: '/api/tunnel' });
|
|
170
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
171
|
+
tunnelModule(RED);
|
|
172
|
+
|
|
173
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
174
|
+
const node = {};
|
|
175
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
176
|
+
|
|
177
|
+
await flushPromises();
|
|
178
|
+
|
|
179
|
+
expect(mockStartWebSocketServer).toHaveBeenCalledTimes(1);
|
|
180
|
+
const passedOptions = mockStartWebSocketServer.mock.calls[0][0];
|
|
181
|
+
|
|
182
|
+
// ASSERTION: both values must be preserved
|
|
183
|
+
// This FAILS with current code — both become ''
|
|
184
|
+
expect(passedOptions.host).toBe('0.0.0.0');
|
|
185
|
+
expect(passedOptions.path).toBe('/api/tunnel');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KI-002 — tunnelIdHeaderName Default Mismatch (Regression Test)
|
|
3
|
+
*
|
|
4
|
+
* This test proves that the HTML editor default for tunnelIdHeaderName
|
|
5
|
+
* ('') does not match the constructor default ('x-tunnel-id').
|
|
6
|
+
*
|
|
7
|
+
* Expected behavior after fix:
|
|
8
|
+
* - HTML default is 'x-tunnel-id', matching the constructor and documentation.
|
|
9
|
+
*
|
|
10
|
+
* Current (defective) behavior:
|
|
11
|
+
* - HTML default is '', which is inconsistent.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const HTML_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.html');
|
|
18
|
+
const JS_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
|
|
19
|
+
|
|
20
|
+
describe('KI-002 — tunnelIdHeaderName default consistency (regression)', () => {
|
|
21
|
+
let htmlContent;
|
|
22
|
+
let jsContent;
|
|
23
|
+
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
htmlContent = fs.readFileSync(HTML_PATH, 'utf8');
|
|
26
|
+
jsContent = fs.readFileSync(JS_PATH, 'utf8');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('HTML wstunnel registration default for tunnelIdHeaderName is "x-tunnel-id"', () => {
|
|
30
|
+
const match = htmlContent.match(
|
|
31
|
+
/tunnelIdHeaderName:\s*\{\s*value:\s*['"]([^'"]*)['"]\s*\}/
|
|
32
|
+
);
|
|
33
|
+
expect(match).not.toBeNull();
|
|
34
|
+
|
|
35
|
+
// ASSERTION: HTML default must be 'x-tunnel-id'
|
|
36
|
+
// This FAILS with current code because HTML default is ''
|
|
37
|
+
expect(match[1]).toBe('x-tunnel-id');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('constructor default for tunnelIdHeaderName is "x-tunnel-id"', () => {
|
|
41
|
+
const match = jsContent.match(
|
|
42
|
+
/tunnelIdHeaderName:\s*n\.tunnelIdHeaderName\s*\|\|\s*['"]([^'"]*)['"]/
|
|
43
|
+
);
|
|
44
|
+
expect(match).not.toBeNull();
|
|
45
|
+
expect(match[1]).toBe('x-tunnel-id');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('HTML and constructor defaults for tunnelIdHeaderName are identical', () => {
|
|
49
|
+
const htmlMatch = htmlContent.match(
|
|
50
|
+
/tunnelIdHeaderName:\s*\{\s*value:\s*['"]([^'"]*)['"]\s*\}/
|
|
51
|
+
);
|
|
52
|
+
const jsMatch = jsContent.match(
|
|
53
|
+
/tunnelIdHeaderName:\s*n\.tunnelIdHeaderName\s*\|\|\s*['"]([^'"]*)['"]/
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(htmlMatch).not.toBeNull();
|
|
57
|
+
expect(jsMatch).not.toBeNull();
|
|
58
|
+
expect(htmlMatch[1]).toBe(jsMatch[1]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KI-003 — Leftover Debug Log Statement (Regression Test)
|
|
3
|
+
*
|
|
4
|
+
* This test proves that the close handler logs a [DEBUG] message at info level
|
|
5
|
+
* instead of using logger.debug() without the [DEBUG] prefix.
|
|
6
|
+
*
|
|
7
|
+
* Expected behavior after fix:
|
|
8
|
+
* - Close handler logs at debug level (not info)
|
|
9
|
+
* - Message does not contain [DEBUG] prefix
|
|
10
|
+
*
|
|
11
|
+
* Current (defective) behavior:
|
|
12
|
+
* - Close handler logs at info level with [DEBUG] prefix
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const WS_TUNNEL_PATH = path.join(__dirname, '..', 'nodes', 'wsTunnel.js');
|
|
18
|
+
|
|
19
|
+
// Mock stopWebSocketServer
|
|
20
|
+
const mockStopWebSocketServer = jest.fn(() => Promise.resolve());
|
|
21
|
+
|
|
22
|
+
// Mock startWebSocketServer
|
|
23
|
+
const mockStartWebSocketServer = jest.fn((opts) =>
|
|
24
|
+
Promise.resolve({
|
|
25
|
+
[opts.port]: {
|
|
26
|
+
webSocketServer: {
|
|
27
|
+
on: jest.fn(),
|
|
28
|
+
clients: new Set(),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Mock logger
|
|
35
|
+
const mockLogger = {
|
|
36
|
+
info: jest.fn(),
|
|
37
|
+
warn: jest.fn(),
|
|
38
|
+
error: jest.fn(),
|
|
39
|
+
debug: jest.fn(),
|
|
40
|
+
trace: jest.fn(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Mock setLogContext and setLogLevel
|
|
44
|
+
const mockSetLogContext = jest.fn();
|
|
45
|
+
const mockSetLogLevel = jest.fn();
|
|
46
|
+
|
|
47
|
+
// Mock RED object
|
|
48
|
+
function createMockRED() {
|
|
49
|
+
const registeredTypes = {};
|
|
50
|
+
return {
|
|
51
|
+
nodes: {
|
|
52
|
+
createNode: jest.fn((node, config) => {
|
|
53
|
+
node.status = jest.fn();
|
|
54
|
+
node.on = jest.fn((event, handler) => {
|
|
55
|
+
node._handlers = node._handlers || {};
|
|
56
|
+
node._handlers[event] = handler;
|
|
57
|
+
});
|
|
58
|
+
}),
|
|
59
|
+
registerType: jest.fn((type, constructor) => {
|
|
60
|
+
registeredTypes[type] = constructor;
|
|
61
|
+
}),
|
|
62
|
+
getNode: jest.fn(() => ({
|
|
63
|
+
options: {
|
|
64
|
+
name: 'test-tunnel',
|
|
65
|
+
host: 'localhost',
|
|
66
|
+
port: 1880,
|
|
67
|
+
path: '',
|
|
68
|
+
tunnelIdHeaderName: 'x-tunnel-id',
|
|
69
|
+
logLevel: 'info',
|
|
70
|
+
},
|
|
71
|
+
})),
|
|
72
|
+
},
|
|
73
|
+
_registeredTypes: registeredTypes,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createNode(RED) {
|
|
78
|
+
const WSTunnelServer = RED._registeredTypes['wstunnel server'];
|
|
79
|
+
const node = {};
|
|
80
|
+
WSTunnelServer.call(node, { wstunnel: 'test-config-id', name: 'test-server' });
|
|
81
|
+
return node;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('KI-003 — close handler debug log level (regression)', () => {
|
|
85
|
+
beforeAll(() => {
|
|
86
|
+
const serverModule = require('@remotelinker/reverse-ws-tunnel/server');
|
|
87
|
+
serverModule.stopWebSocketServer = mockStopWebSocketServer;
|
|
88
|
+
serverModule.startWebSocketServer = mockStartWebSocketServer;
|
|
89
|
+
|
|
90
|
+
const utilsModule = require('@remotelinker/reverse-ws-tunnel/utils');
|
|
91
|
+
utilsModule.logger = mockLogger;
|
|
92
|
+
utilsModule.setLogContext = mockSetLogContext;
|
|
93
|
+
utilsModule.setLogLevel = mockSetLogLevel;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
mockStopWebSocketServer.mockClear();
|
|
98
|
+
mockStartWebSocketServer.mockClear();
|
|
99
|
+
mockLogger.info.mockClear();
|
|
100
|
+
mockLogger.debug.mockClear();
|
|
101
|
+
mockLogger.warn.mockClear();
|
|
102
|
+
mockLogger.error.mockClear();
|
|
103
|
+
mockSetLogContext.mockClear();
|
|
104
|
+
mockSetLogLevel.mockClear();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('close handler does NOT log [DEBUG] at info level', async () => {
|
|
108
|
+
const RED = createMockRED();
|
|
109
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
110
|
+
tunnelModule(RED);
|
|
111
|
+
|
|
112
|
+
// Both mocks are pre-resolved; microtask flush is sufficient
|
|
113
|
+
await Promise.resolve();
|
|
114
|
+
|
|
115
|
+
const node = createNode(RED);
|
|
116
|
+
const done = jest.fn();
|
|
117
|
+
|
|
118
|
+
mockLogger.info.mockClear();
|
|
119
|
+
|
|
120
|
+
node._handlers.close.call(node, false, done);
|
|
121
|
+
|
|
122
|
+
// Close handler logs synchronously; microtask flush ensures done() path is reached
|
|
123
|
+
await Promise.resolve();
|
|
124
|
+
|
|
125
|
+
// ASSERTION: info must NOT be called with [DEBUG] prefix
|
|
126
|
+
// This FAILS with current code because line 117 uses logger.info('[DEBUG]...')
|
|
127
|
+
const infoCalls = mockLogger.info.mock.calls.map((c) => c[0]);
|
|
128
|
+
expect(infoCalls.some((msg) => msg.includes('[DEBUG]'))).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('close handler logs close message at debug level', async () => {
|
|
132
|
+
const RED = createMockRED();
|
|
133
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
134
|
+
tunnelModule(RED);
|
|
135
|
+
|
|
136
|
+
// Both mocks are pre-resolved; microtask flush is sufficient
|
|
137
|
+
await Promise.resolve();
|
|
138
|
+
|
|
139
|
+
const node = createNode(RED);
|
|
140
|
+
const done = jest.fn();
|
|
141
|
+
|
|
142
|
+
mockLogger.debug.mockClear();
|
|
143
|
+
|
|
144
|
+
node._handlers.close.call(node, false, done);
|
|
145
|
+
|
|
146
|
+
// Close handler logs synchronously; microtask flush ensures done() path is reached
|
|
147
|
+
await Promise.resolve();
|
|
148
|
+
|
|
149
|
+
// ASSERTION: debug must be called with close message
|
|
150
|
+
// This FAILS with current code because the message is at info level, not debug
|
|
151
|
+
const debugCalls = mockLogger.debug.mock.calls.map((c) => c[0]);
|
|
152
|
+
expect(debugCalls.some((msg) => msg.includes('Close called for node'))).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('close message does not contain [DEBUG] prefix', async () => {
|
|
156
|
+
const RED = createMockRED();
|
|
157
|
+
const tunnelModule = require(WS_TUNNEL_PATH);
|
|
158
|
+
tunnelModule(RED);
|
|
159
|
+
|
|
160
|
+
// Both mocks are pre-resolved; microtask flush is sufficient
|
|
161
|
+
await Promise.resolve();
|
|
162
|
+
|
|
163
|
+
const node = createNode(RED);
|
|
164
|
+
const done = jest.fn();
|
|
165
|
+
|
|
166
|
+
mockLogger.debug.mockClear();
|
|
167
|
+
|
|
168
|
+
node._handlers.close.call(node, false, done);
|
|
169
|
+
|
|
170
|
+
// Close handler logs synchronously; microtask flush ensures done() path is reached
|
|
171
|
+
await Promise.resolve();
|
|
172
|
+
|
|
173
|
+
// ASSERTION: debug message must not contain [DEBUG] prefix
|
|
174
|
+
const debugCalls = mockLogger.debug.mock.calls.map((c) => c[0]);
|
|
175
|
+
const closeMsg = debugCalls.find((msg) => msg.includes('Close called for node'));
|
|
176
|
+
if (closeMsg) {
|
|
177
|
+
expect(closeMsg).not.toContain('[DEBUG]');
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|