@livedesk/hub 0.1.55 → 0.1.57
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/package.json +4 -3
- package/src/console-direct.js +1538 -0
- package/src/console-direct.test.mjs +984 -0
- package/src/server.js +55 -43
- package/src/settings/settings-schema.js +13 -8
- package/src/settings/settings-store.js +16 -6
- package/src/console-relay.js +0 -424
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import nodeDataChannel from 'node-datachannel';
|
|
6
|
+
import {
|
|
7
|
+
createDirectConsoleWireAssembler,
|
|
8
|
+
encodeDirectConsoleWireMessage
|
|
9
|
+
} from '../../runtime-core/src/console-direct-wire.js';
|
|
10
|
+
import { consoleDirectContract, createHubConsoleDirect } from './console-direct.js';
|
|
11
|
+
|
|
12
|
+
assert.equal(consoleDirectContract.maxDefaultRetryDelayMs, 20_000);
|
|
13
|
+
assert.equal(consoleDirectContract.peerDisconnectedTimeoutMs, 5_000);
|
|
14
|
+
assert.equal(consoleDirectContract.maxPendingMediaWireMessages, 8);
|
|
15
|
+
assert.equal(consoleDirectContract.reliableAssemblyTimeoutMs, 15_000);
|
|
16
|
+
|
|
17
|
+
const HUB_EPOCH = '11111111-1111-4111-8111-111111111111';
|
|
18
|
+
const HUB_EPOCH_TWO = '77777777-7777-4777-8777-777777777777';
|
|
19
|
+
const CONSOLE_ID = '22222222-2222-4222-8222-222222222222';
|
|
20
|
+
const CONNECTION_ONE = '33333333-3333-4333-8333-333333333333';
|
|
21
|
+
const CONNECTION_TWO = '44444444-4444-4444-8444-444444444444';
|
|
22
|
+
const CHANNEL_ID = '55555555-5555-4555-8555-555555555555';
|
|
23
|
+
const REQUEST_ID = '66666666-6666-4666-8666-666666666666';
|
|
24
|
+
|
|
25
|
+
class FakeSocket extends EventEmitter {
|
|
26
|
+
static CONNECTING = 0;
|
|
27
|
+
static OPEN = 1;
|
|
28
|
+
static CLOSING = 2;
|
|
29
|
+
static CLOSED = 3;
|
|
30
|
+
static instances = [];
|
|
31
|
+
|
|
32
|
+
constructor(url, options = {}) {
|
|
33
|
+
super();
|
|
34
|
+
this.url = String(url);
|
|
35
|
+
this.options = options;
|
|
36
|
+
this.readyState = FakeSocket.CONNECTING;
|
|
37
|
+
this.bufferedAmount = 0;
|
|
38
|
+
this.sent = [];
|
|
39
|
+
this.closeCode = 0;
|
|
40
|
+
this.closeReason = '';
|
|
41
|
+
this.constructor.instances.push(this);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
open() {
|
|
45
|
+
this.readyState = FakeSocket.OPEN;
|
|
46
|
+
this.emit('open');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
send(value) {
|
|
50
|
+
if (this.readyState !== FakeSocket.OPEN) throw new Error('fake-socket-not-open');
|
|
51
|
+
this.sent.push(Buffer.isBuffer(value) ? Buffer.from(value) : value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
receive(value, binary = Buffer.isBuffer(value)) {
|
|
55
|
+
this.emit('message', value, binary);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
close(code = 1000, reason = '') {
|
|
59
|
+
if (this.readyState === FakeSocket.CLOSED) return;
|
|
60
|
+
this.closeCode = code;
|
|
61
|
+
this.closeReason = String(reason);
|
|
62
|
+
this.readyState = FakeSocket.CLOSED;
|
|
63
|
+
this.emit('close', code, Buffer.from(this.closeReason));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
terminate() {
|
|
67
|
+
this.close(1006, 'terminated');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class FakeSignalSocket extends FakeSocket {
|
|
72
|
+
static instances = [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class FakeLocalSocket extends FakeSocket {
|
|
76
|
+
static instances = [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class FakeDataChannel {
|
|
80
|
+
constructor(label) {
|
|
81
|
+
this.label = label;
|
|
82
|
+
this.opened = false;
|
|
83
|
+
this.closed = false;
|
|
84
|
+
this.buffered = 0;
|
|
85
|
+
this.sent = [];
|
|
86
|
+
this.callbacks = {};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getLabel() { return this.label; }
|
|
90
|
+
isOpen() { return this.opened && !this.closed; }
|
|
91
|
+
bufferedAmount() { return this.buffered; }
|
|
92
|
+
setBufferedAmountLowThreshold(value) { this.lowThreshold = value; }
|
|
93
|
+
onMessage(callback) { this.callbacks.message = callback; }
|
|
94
|
+
onClosed(callback) { this.callbacks.closed = callback; }
|
|
95
|
+
onError(callback) { this.callbacks.error = callback; }
|
|
96
|
+
onOpen(callback) { this.callbacks.open = callback; }
|
|
97
|
+
|
|
98
|
+
open() {
|
|
99
|
+
this.opened = true;
|
|
100
|
+
this.callbacks.open?.();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
receive(value) {
|
|
104
|
+
this.callbacks.message?.(value);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
sendMessageBinary(value) {
|
|
108
|
+
if (!this.isOpen()) return false;
|
|
109
|
+
this.sent.push(new Uint8Array(value));
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
close() {
|
|
114
|
+
if (this.closed) return;
|
|
115
|
+
this.closed = true;
|
|
116
|
+
this.callbacks.closed?.();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class FakePeerConnection {
|
|
121
|
+
static instances = [];
|
|
122
|
+
|
|
123
|
+
constructor(name, config) {
|
|
124
|
+
this.name = name;
|
|
125
|
+
this.config = config;
|
|
126
|
+
this.callbacks = {};
|
|
127
|
+
this.closed = false;
|
|
128
|
+
this.local = null;
|
|
129
|
+
this.remote = null;
|
|
130
|
+
this.remoteCandidates = [];
|
|
131
|
+
this.selectedPair = null;
|
|
132
|
+
FakePeerConnection.instances.push(this);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
onLocalDescription(callback) { this.callbacks.localDescription = callback; }
|
|
136
|
+
onLocalCandidate(callback) { this.callbacks.localCandidate = callback; }
|
|
137
|
+
onDataChannel(callback) { this.callbacks.dataChannel = callback; }
|
|
138
|
+
onStateChange(callback) { this.callbacks.state = callback; }
|
|
139
|
+
onIceStateChange(callback) { this.callbacks.iceState = callback; }
|
|
140
|
+
|
|
141
|
+
setRemoteDescription(sdp, type) {
|
|
142
|
+
this.remote = { sdp, type };
|
|
143
|
+
if (type === 'offer') {
|
|
144
|
+
this.local = { type: 'answer', sdp: `v=0\r\na=fake-${this.name}\r\n` };
|
|
145
|
+
this.callbacks.localDescription?.(this.local.sdp, this.local.type);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
setLocalDescription(type) {
|
|
150
|
+
this.local = { type, sdp: `v=0\r\na=fake-${this.name}\r\n` };
|
|
151
|
+
this.callbacks.localDescription?.(this.local.sdp, type);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
localDescription() { return this.local; }
|
|
155
|
+
addRemoteCandidate(candidate, sdpMid) { this.remoteCandidates.push({ candidate, sdpMid }); }
|
|
156
|
+
getSelectedCandidatePair() { return this.selectedPair; }
|
|
157
|
+
|
|
158
|
+
emitDataChannel(channel) { this.callbacks.dataChannel?.(channel); }
|
|
159
|
+
emitLocalCandidate(candidate, sdpMid = '0') { this.callbacks.localCandidate?.(candidate, sdpMid); }
|
|
160
|
+
emitState(state) { this.callbacks.state?.(state); }
|
|
161
|
+
|
|
162
|
+
close() { this.closed = true; }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resetFakes() {
|
|
166
|
+
FakeSignalSocket.instances.length = 0;
|
|
167
|
+
FakeLocalSocket.instances.length = 0;
|
|
168
|
+
FakePeerConnection.instances.length = 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function settle() {
|
|
172
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function waitFor(predicate, timeoutMs = 250) {
|
|
176
|
+
const deadline = Date.now() + timeoutMs;
|
|
177
|
+
while (!predicate()) {
|
|
178
|
+
if (Date.now() >= deadline) throw new Error('test-condition-timeout');
|
|
179
|
+
await new Promise(resolve => setTimeout(resolve, 2));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function signalingMessages(socket) {
|
|
184
|
+
return socket.sent
|
|
185
|
+
.filter(value => typeof value === 'string')
|
|
186
|
+
.map(value => JSON.parse(value));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function decodeWireMessages(chunks, maxMessageBytes = 12 * 1024 * 1024) {
|
|
190
|
+
const assembler = createDirectConsoleWireAssembler({ maxMessageBytes });
|
|
191
|
+
const messages = [];
|
|
192
|
+
for (const chunk of chunks) {
|
|
193
|
+
const message = assembler.push(chunk);
|
|
194
|
+
if (message) messages.push(message);
|
|
195
|
+
}
|
|
196
|
+
assembler.dispose();
|
|
197
|
+
return messages;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sendWire(channel, payload, { messageId = 1, kind = 'control', maxMessageBytes = 12 * 1024 * 1024 } = {}) {
|
|
201
|
+
const value = kind === 'binary' ? payload : typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
202
|
+
for (const chunk of encodeDirectConsoleWireMessage(value, { messageId, kind, maxMessageBytes })) {
|
|
203
|
+
channel.receive(chunk);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function connectedDirect(options = {}) {
|
|
208
|
+
resetFakes();
|
|
209
|
+
const direct = createHubConsoleDirect({
|
|
210
|
+
url: 'https://signal.example.test',
|
|
211
|
+
deviceId: 'hub-device',
|
|
212
|
+
hubInstanceId: options.hubInstanceId || HUB_EPOCH,
|
|
213
|
+
httpBaseUrl: 'http://127.0.0.1:5179',
|
|
214
|
+
stunUrls: options.stunUrls || ['stun:stun.example.test:3478'],
|
|
215
|
+
getAccessToken: async () => 'access-token',
|
|
216
|
+
SignalingWebSocketImpl: FakeSignalSocket,
|
|
217
|
+
LocalWebSocketImpl: FakeLocalSocket,
|
|
218
|
+
createPeerConnection: (name, config) => new FakePeerConnection(name, config),
|
|
219
|
+
retryDelaysMs: options.retryDelaysMs || [60_000],
|
|
220
|
+
...(options.iceConnectTimeoutMs ? { iceConnectTimeoutMs: options.iceConnectTimeoutMs } : {}),
|
|
221
|
+
...(options.peerDisconnectedTimeoutMs
|
|
222
|
+
? { peerDisconnectedTimeoutMs: options.peerDisconnectedTimeoutMs }
|
|
223
|
+
: {}),
|
|
224
|
+
...(options.mediaAssemblyTimeoutMs ? { mediaAssemblyTimeoutMs: options.mediaAssemblyTimeoutMs } : {}),
|
|
225
|
+
fetchImpl: options.fetchImpl || (async () => new Response('{}', {
|
|
226
|
+
status: 200,
|
|
227
|
+
headers: { 'Content-Type': 'application/json' }
|
|
228
|
+
}))
|
|
229
|
+
});
|
|
230
|
+
direct.start();
|
|
231
|
+
await settle();
|
|
232
|
+
const signal = FakeSignalSocket.instances[0];
|
|
233
|
+
assert.ok(signal);
|
|
234
|
+
assert.equal(new URL(signal.url).pathname, '/v2/rtc/hub');
|
|
235
|
+
assert.equal(new URL(signal.url).searchParams.get('deviceId'), 'hub-device');
|
|
236
|
+
assert.equal(new URL(signal.url).searchParams.get('hubInstanceId'), options.hubInstanceId || HUB_EPOCH);
|
|
237
|
+
signal.open();
|
|
238
|
+
signal.receive(JSON.stringify({
|
|
239
|
+
type: 'signal-ready',
|
|
240
|
+
role: 'hub',
|
|
241
|
+
deviceId: 'hub-device',
|
|
242
|
+
hubEpoch: HUB_EPOCH
|
|
243
|
+
}), false);
|
|
244
|
+
return { direct, signal };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function offer(signal, connectionId = CONNECTION_ONE, sdp = 'v=0\r\na=fake-offer\r\n') {
|
|
248
|
+
signal.receive(JSON.stringify({
|
|
249
|
+
type: 'rtc-offer',
|
|
250
|
+
consoleId: CONSOLE_ID,
|
|
251
|
+
connectionId,
|
|
252
|
+
hubEpoch: HUB_EPOCH,
|
|
253
|
+
description: { type: 'offer', sdp }
|
|
254
|
+
}), false);
|
|
255
|
+
return FakePeerConnection.instances.at(-1);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
test('STUN-only peer answers and rejects stale owner callbacks after replacement', async () => {
|
|
259
|
+
const { direct, signal } = await connectedDirect();
|
|
260
|
+
const firstPeer = offer(signal, CONNECTION_ONE);
|
|
261
|
+
assert.deepEqual(firstPeer.config.iceServers, ['stun:stun.example.test:3478']);
|
|
262
|
+
assert.equal(firstPeer.config.iceTransportPolicy, 'all');
|
|
263
|
+
assert.equal(firstPeer.config.disableFingerprintVerification, false);
|
|
264
|
+
assert.equal(signalingMessages(signal).some(message => (
|
|
265
|
+
message.type === 'rtc-answer' && message.connectionId === CONNECTION_ONE
|
|
266
|
+
)), true);
|
|
267
|
+
|
|
268
|
+
const secondPeer = offer(signal, CONNECTION_TWO);
|
|
269
|
+
assert.equal(firstPeer.closed, true);
|
|
270
|
+
const messagesBeforeStaleCandidate = signal.sent.length;
|
|
271
|
+
firstPeer.emitLocalCandidate('candidate:1 1 udp 1 192.0.2.1 5000 typ host');
|
|
272
|
+
assert.equal(signal.sent.length, messagesBeforeStaleCandidate);
|
|
273
|
+
assert.equal(direct.inspect().peerConnections, 1);
|
|
274
|
+
|
|
275
|
+
const staleControl = new FakeDataChannel('livedesk-control-v1');
|
|
276
|
+
firstPeer.emitDataChannel(staleControl);
|
|
277
|
+
assert.equal(staleControl.closed, true);
|
|
278
|
+
assert.equal(secondPeer.closed, false);
|
|
279
|
+
direct.close();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('a signaling reconnect preserves a healthy direct peer until the Hub epoch actually changes', async () => {
|
|
283
|
+
const { direct, signal } = await connectedDirect({ retryDelaysMs: [0] });
|
|
284
|
+
const peer = offer(signal);
|
|
285
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
286
|
+
peer.emitDataChannel(control);
|
|
287
|
+
control.open();
|
|
288
|
+
signal.close(1012, 'transient-signal-loss');
|
|
289
|
+
assert.equal(peer.closed, false);
|
|
290
|
+
assert.equal(direct.inspect().peerConnections, 1);
|
|
291
|
+
assert.equal(direct.inspect().hubEpoch, HUB_EPOCH);
|
|
292
|
+
|
|
293
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
294
|
+
await settle();
|
|
295
|
+
const replacementSignal = FakeSignalSocket.instances.at(-1);
|
|
296
|
+
assert.notEqual(replacementSignal, signal);
|
|
297
|
+
assert.equal(
|
|
298
|
+
new URL(replacementSignal.url).searchParams.get('hubInstanceId'),
|
|
299
|
+
new URL(signal.url).searchParams.get('hubInstanceId')
|
|
300
|
+
);
|
|
301
|
+
replacementSignal.open();
|
|
302
|
+
replacementSignal.receive(JSON.stringify({
|
|
303
|
+
type: 'signal-ready',
|
|
304
|
+
role: 'hub',
|
|
305
|
+
deviceId: 'hub-device',
|
|
306
|
+
hubEpoch: HUB_EPOCH
|
|
307
|
+
}), false);
|
|
308
|
+
assert.equal(peer.closed, false);
|
|
309
|
+
assert.equal(direct.inspect().peerConnections, 1);
|
|
310
|
+
|
|
311
|
+
replacementSignal.receive(JSON.stringify({
|
|
312
|
+
type: 'signal-ready',
|
|
313
|
+
role: 'hub',
|
|
314
|
+
deviceId: 'hub-device',
|
|
315
|
+
hubEpoch: HUB_EPOCH_TWO
|
|
316
|
+
}), false);
|
|
317
|
+
assert.equal(peer.closed, true);
|
|
318
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
319
|
+
direct.close();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('a same-account token refresh reconnects signaling without dropping a healthy direct peer', async () => {
|
|
323
|
+
const { direct, signal } = await connectedDirect();
|
|
324
|
+
const peer = offer(signal);
|
|
325
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
326
|
+
peer.emitDataChannel(control);
|
|
327
|
+
control.open();
|
|
328
|
+
direct.refresh();
|
|
329
|
+
assert.equal(peer.closed, false);
|
|
330
|
+
assert.equal(direct.inspect().hubEpoch, HUB_EPOCH);
|
|
331
|
+
await settle();
|
|
332
|
+
const replacementSignal = FakeSignalSocket.instances.at(-1);
|
|
333
|
+
assert.notEqual(replacementSignal, signal);
|
|
334
|
+
assert.equal(
|
|
335
|
+
new URL(replacementSignal.url).searchParams.get('hubInstanceId'),
|
|
336
|
+
new URL(signal.url).searchParams.get('hubInstanceId')
|
|
337
|
+
);
|
|
338
|
+
replacementSignal.open();
|
|
339
|
+
replacementSignal.receive(JSON.stringify({
|
|
340
|
+
type: 'signal-ready',
|
|
341
|
+
role: 'hub',
|
|
342
|
+
deviceId: 'hub-device',
|
|
343
|
+
hubEpoch: HUB_EPOCH
|
|
344
|
+
}), false);
|
|
345
|
+
assert.equal(peer.closed, false);
|
|
346
|
+
assert.equal(direct.inspect().peerConnections, 1);
|
|
347
|
+
direct.close();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('close revokes every direct owner and refresh can start signaling again after logout', async () => {
|
|
351
|
+
const { direct, signal } = await connectedDirect();
|
|
352
|
+
const peer = offer(signal);
|
|
353
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
354
|
+
peer.emitDataChannel(control);
|
|
355
|
+
control.open();
|
|
356
|
+
direct.close();
|
|
357
|
+
assert.equal(peer.closed, true);
|
|
358
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
359
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
360
|
+
direct.refresh();
|
|
361
|
+
await settle();
|
|
362
|
+
const restartedSignal = FakeSignalSocket.instances.at(-1);
|
|
363
|
+
assert.notEqual(restartedSignal, signal);
|
|
364
|
+
assert.equal(
|
|
365
|
+
new URL(restartedSignal.url).searchParams.get('hubInstanceId'),
|
|
366
|
+
new URL(signal.url).searchParams.get('hubInstanceId')
|
|
367
|
+
);
|
|
368
|
+
direct.close();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('a signaling loss retires only a peer that has not opened its control channel', async () => {
|
|
372
|
+
const { direct, signal } = await connectedDirect();
|
|
373
|
+
const peer = offer(signal);
|
|
374
|
+
signal.close(1012, 'signal-lost-during-negotiation');
|
|
375
|
+
assert.equal(peer.closed, true);
|
|
376
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
377
|
+
assert.equal(direct.inspect().hubEpoch, HUB_EPOCH);
|
|
378
|
+
direct.close();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('an access-token owner has one bounded deadline and late stale completion cannot reconnect', async () => {
|
|
382
|
+
resetFakes();
|
|
383
|
+
const tokenResolvers = [];
|
|
384
|
+
let tokenCalls = 0;
|
|
385
|
+
const direct = createHubConsoleDirect({
|
|
386
|
+
url: 'https://signal.example.test',
|
|
387
|
+
deviceId: 'hub-device',
|
|
388
|
+
hubInstanceId: HUB_EPOCH,
|
|
389
|
+
httpBaseUrl: 'http://127.0.0.1:5179',
|
|
390
|
+
getAccessToken: () => {
|
|
391
|
+
tokenCalls += 1;
|
|
392
|
+
return new Promise(resolve => tokenResolvers.push(resolve));
|
|
393
|
+
},
|
|
394
|
+
accessTokenTimeoutMs: 5,
|
|
395
|
+
retryDelaysMs: [1],
|
|
396
|
+
SignalingWebSocketImpl: FakeSignalSocket,
|
|
397
|
+
LocalWebSocketImpl: FakeLocalSocket,
|
|
398
|
+
createPeerConnection: (name, config) => new FakePeerConnection(name, config)
|
|
399
|
+
});
|
|
400
|
+
direct.start();
|
|
401
|
+
await waitFor(() => tokenCalls >= 2);
|
|
402
|
+
assert.equal(FakeSignalSocket.instances.length, 0);
|
|
403
|
+
assert.equal(direct.inspect().lastError, 'console-direct-access-token-timeout');
|
|
404
|
+
assert.ok(direct.inspect().resourceTimers <= 1);
|
|
405
|
+
direct.close();
|
|
406
|
+
for (const resolve of tokenResolvers) resolve('late-access-token');
|
|
407
|
+
await settle();
|
|
408
|
+
assert.equal(FakeSignalSocket.instances.length, 0);
|
|
409
|
+
assert.equal(direct.inspect().accessTokenDeadlineActive, false);
|
|
410
|
+
assert.equal(direct.inspect().signalReadyDeadlineActive, false);
|
|
411
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('one exact WebSocket deadline bounds both handshake and signal-ready before a healthy retry', async () => {
|
|
415
|
+
resetFakes();
|
|
416
|
+
const direct = createHubConsoleDirect({
|
|
417
|
+
url: 'https://signal.example.test',
|
|
418
|
+
deviceId: 'hub-device',
|
|
419
|
+
hubInstanceId: HUB_EPOCH,
|
|
420
|
+
httpBaseUrl: 'http://127.0.0.1:5179',
|
|
421
|
+
getAccessToken: async () => 'access-token',
|
|
422
|
+
accessTokenTimeoutMs: 20,
|
|
423
|
+
signalReadyTimeoutMs: 15,
|
|
424
|
+
retryDelaysMs: [0],
|
|
425
|
+
SignalingWebSocketImpl: FakeSignalSocket,
|
|
426
|
+
LocalWebSocketImpl: FakeLocalSocket,
|
|
427
|
+
createPeerConnection: (name, config) => new FakePeerConnection(name, config)
|
|
428
|
+
});
|
|
429
|
+
direct.start();
|
|
430
|
+
await waitFor(() => FakeSignalSocket.instances.length === 1);
|
|
431
|
+
const connectingSocket = FakeSignalSocket.instances[0];
|
|
432
|
+
assert.equal(direct.inspect().state, 'connecting');
|
|
433
|
+
assert.equal(direct.inspect().signalReadyDeadlineActive, true);
|
|
434
|
+
|
|
435
|
+
await waitFor(() => FakeSignalSocket.instances.length === 2);
|
|
436
|
+
assert.equal(connectingSocket.readyState, FakeSignalSocket.CLOSED);
|
|
437
|
+
const authenticatingSocket = FakeSignalSocket.instances[1];
|
|
438
|
+
authenticatingSocket.open();
|
|
439
|
+
assert.equal(direct.inspect().state, 'authenticating');
|
|
440
|
+
|
|
441
|
+
await waitFor(() => FakeSignalSocket.instances.length === 3);
|
|
442
|
+
assert.equal(authenticatingSocket.readyState, FakeSignalSocket.CLOSED);
|
|
443
|
+
const healthySocket = FakeSignalSocket.instances[2];
|
|
444
|
+
healthySocket.open();
|
|
445
|
+
healthySocket.receive(JSON.stringify({
|
|
446
|
+
type: 'signal-ready',
|
|
447
|
+
role: 'hub',
|
|
448
|
+
deviceId: 'hub-device',
|
|
449
|
+
hubEpoch: HUB_EPOCH
|
|
450
|
+
}), false);
|
|
451
|
+
assert.equal(direct.inspect().state, 'connected');
|
|
452
|
+
assert.equal(direct.inspect().signalReadyDeadlineActive, false);
|
|
453
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
454
|
+
assert.equal(FakeSignalSocket.instances.length, 3);
|
|
455
|
+
direct.close();
|
|
456
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test('reliable control channel bridges bounded HTTP over fragmented wire messages', async () => {
|
|
460
|
+
let fetchCalls = 0;
|
|
461
|
+
const { direct, signal } = await connectedDirect({
|
|
462
|
+
fetchImpl: async (url, init) => {
|
|
463
|
+
fetchCalls += 1;
|
|
464
|
+
assert.equal(url, 'http://127.0.0.1:5179/api/update/apply');
|
|
465
|
+
assert.equal(init.method, 'POST');
|
|
466
|
+
assert.equal(init.headers['content-type'], 'application/json');
|
|
467
|
+
assert.equal(init.headers['x-livedesk-csrf'], 'csrf-proof');
|
|
468
|
+
assert.equal(Buffer.from(init.body).toString('utf8'), '{"apply":true}');
|
|
469
|
+
return new Response('{"ok":true}', {
|
|
470
|
+
status: 200,
|
|
471
|
+
statusText: 'OK',
|
|
472
|
+
headers: { 'Content-Type': 'application/json' }
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
const peer = offer(signal);
|
|
477
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
478
|
+
peer.emitDataChannel(control);
|
|
479
|
+
control.open();
|
|
480
|
+
let messages = decodeWireMessages(control.sent);
|
|
481
|
+
assert.deepEqual(JSON.parse(messages[0].data), {
|
|
482
|
+
type: 'direct-ready',
|
|
483
|
+
connectionId: CONNECTION_ONE,
|
|
484
|
+
hubEpoch: HUB_EPOCH
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
sendWire(control, {
|
|
488
|
+
type: 'http-request',
|
|
489
|
+
requestId: REQUEST_ID,
|
|
490
|
+
method: 'POST',
|
|
491
|
+
path: '/api/update/apply',
|
|
492
|
+
headers: {
|
|
493
|
+
accept: 'application/json',
|
|
494
|
+
'content-type': 'application/json',
|
|
495
|
+
'x-livedesk-csrf': 'csrf-proof'
|
|
496
|
+
},
|
|
497
|
+
bodyBase64: Buffer.from('{"apply":true}').toString('base64')
|
|
498
|
+
});
|
|
499
|
+
await settle();
|
|
500
|
+
messages = decodeWireMessages(control.sent);
|
|
501
|
+
const response = messages.map(message => JSON.parse(message.data))
|
|
502
|
+
.find(message => message.type === 'http-response');
|
|
503
|
+
assert.equal(fetchCalls, 1);
|
|
504
|
+
assert.equal(response.requestId, REQUEST_ID);
|
|
505
|
+
assert.equal(response.status, 200);
|
|
506
|
+
assert.equal(Buffer.from(response.bodyBase64, 'base64').toString('utf8'), '{"ok":true}');
|
|
507
|
+
assert.equal(direct.inspect().pendingHttpRequests, 0);
|
|
508
|
+
direct.close();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test('canonical HTTP routing rejects encoded, backslash, duplicate-slash, and external-origin bypasses', async () => {
|
|
512
|
+
const fetchedUrls = [];
|
|
513
|
+
const { direct, signal } = await connectedDirect({
|
|
514
|
+
fetchImpl: async url => {
|
|
515
|
+
fetchedUrls.push(String(url));
|
|
516
|
+
return new Response('{"ok":true}', {
|
|
517
|
+
status: 200,
|
|
518
|
+
headers: { 'Content-Type': 'application/json' }
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
const peer = offer(signal);
|
|
523
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
524
|
+
peer.emitDataChannel(control);
|
|
525
|
+
control.open();
|
|
526
|
+
|
|
527
|
+
const bypasses = [
|
|
528
|
+
['/api/settings/%2e%2e/remote/registry-credentials', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'],
|
|
529
|
+
['/api/settings/%252e%252e/remote/registry-credentials', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2'],
|
|
530
|
+
['/api/settings\\..\\remote\\registry-credentials', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3'],
|
|
531
|
+
['/api/settings//profile', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4'],
|
|
532
|
+
['//attacker.example.test/api/settings/profile', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5'],
|
|
533
|
+
['https://attacker.example.test/api/settings/profile', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa6'],
|
|
534
|
+
['/api/settings/%2f..%2fremote/registry-credentials', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa7']
|
|
535
|
+
];
|
|
536
|
+
for (const [path, requestId] of bypasses) {
|
|
537
|
+
sendWire(control, {
|
|
538
|
+
type: 'http-request',
|
|
539
|
+
requestId,
|
|
540
|
+
method: 'GET',
|
|
541
|
+
path
|
|
542
|
+
}, { messageId: Number(requestId.at(-1)) + 10 });
|
|
543
|
+
}
|
|
544
|
+
await settle();
|
|
545
|
+
assert.deepEqual(fetchedUrls, []);
|
|
546
|
+
const rejected = decodeWireMessages(control.sent)
|
|
547
|
+
.map(message => JSON.parse(message.data))
|
|
548
|
+
.filter(message => bypasses.some(([, requestId]) => requestId === message.requestId));
|
|
549
|
+
assert.equal(rejected.length, bypasses.length);
|
|
550
|
+
assert.ok(rejected.every(message => message.error === 'console-route-not-allowed'));
|
|
551
|
+
|
|
552
|
+
sendWire(control, {
|
|
553
|
+
type: 'http-request',
|
|
554
|
+
requestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa8',
|
|
555
|
+
method: 'GET',
|
|
556
|
+
path: '/api/settings/profile?section=general%20settings'
|
|
557
|
+
}, { messageId: 18 });
|
|
558
|
+
await settle();
|
|
559
|
+
assert.deepEqual(fetchedUrls, [
|
|
560
|
+
'http://127.0.0.1:5179/api/settings/profile?section=general%20settings'
|
|
561
|
+
]);
|
|
562
|
+
direct.close();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test('reliable lifecycle control survives logical lane registration reordering and the lane carries payload only', async () => {
|
|
566
|
+
const { direct, signal } = await connectedDirect();
|
|
567
|
+
const peer = offer(signal);
|
|
568
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
569
|
+
peer.emitDataChannel(control);
|
|
570
|
+
control.open();
|
|
571
|
+
|
|
572
|
+
// Model external SCTP scheduling: reliable ws-open arrives before the
|
|
573
|
+
// separately negotiated logical lane is surfaced to the Hub callback.
|
|
574
|
+
sendWire(control, {
|
|
575
|
+
type: 'ws-open',
|
|
576
|
+
connectionId: CONNECTION_ONE,
|
|
577
|
+
hubEpoch: HUB_EPOCH,
|
|
578
|
+
channelId: CHANNEL_ID,
|
|
579
|
+
purpose: 'frame',
|
|
580
|
+
path: '/api/remote/frames/ws?devices=device-a'
|
|
581
|
+
});
|
|
582
|
+
assert.equal(FakeLocalSocket.instances.length, 0);
|
|
583
|
+
assert.equal(direct.inspect().pendingLogicalControlRequests, 1);
|
|
584
|
+
|
|
585
|
+
const lane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:frame`);
|
|
586
|
+
peer.emitDataChannel(lane);
|
|
587
|
+
lane.open();
|
|
588
|
+
const local = FakeLocalSocket.instances[0];
|
|
589
|
+
assert.ok(local);
|
|
590
|
+
assert.equal(new URL(local.url).pathname, '/api/remote/frames/ws');
|
|
591
|
+
assert.equal(direct.inspect().pendingLogicalControlRequests, 0);
|
|
592
|
+
local.open();
|
|
593
|
+
|
|
594
|
+
const controlMessages = decodeWireMessages(control.sent)
|
|
595
|
+
.filter(message => message.kind === 'control')
|
|
596
|
+
.map(message => JSON.parse(message.data));
|
|
597
|
+
assert.deepEqual(controlMessages.find(message => message.type === 'ws-opened'), {
|
|
598
|
+
type: 'ws-opened',
|
|
599
|
+
channelId: CHANNEL_ID,
|
|
600
|
+
connectionId: CONNECTION_ONE,
|
|
601
|
+
hubEpoch: HUB_EPOCH
|
|
602
|
+
});
|
|
603
|
+
assert.equal(lane.sent.length, 0);
|
|
604
|
+
|
|
605
|
+
local.receive(Buffer.from([1, 2, 3, 4]), true);
|
|
606
|
+
const laneMessages = decodeWireMessages(lane.sent);
|
|
607
|
+
const binary = laneMessages.find(message => message.kind === 'binary');
|
|
608
|
+
assert.deepEqual([...binary.data], [1, 2, 3, 4]);
|
|
609
|
+
assert.equal(laneMessages.some(message => message.kind === 'control'), false);
|
|
610
|
+
|
|
611
|
+
sendWire(lane, 'browser-text-message', { messageId: 2, kind: 'text' });
|
|
612
|
+
sendWire(lane, new Uint8Array([5, 6, 7]), { messageId: 3, kind: 'binary' });
|
|
613
|
+
assert.equal(local.sent[0], 'browser-text-message');
|
|
614
|
+
assert.deepEqual([...local.sent[1]], [5, 6, 7]);
|
|
615
|
+
assert.equal(direct.inspect().logicalWebSocketChannels, 1);
|
|
616
|
+
|
|
617
|
+
sendWire(control, {
|
|
618
|
+
type: 'ws-close',
|
|
619
|
+
connectionId: CONNECTION_ONE,
|
|
620
|
+
hubEpoch: HUB_EPOCH,
|
|
621
|
+
channelId: CHANNEL_ID,
|
|
622
|
+
code: 1000,
|
|
623
|
+
reason: 'test-complete'
|
|
624
|
+
}, { messageId: 4 });
|
|
625
|
+
assert.equal(lane.closed, true);
|
|
626
|
+
assert.equal(local.readyState, FakeLocalSocket.CLOSED);
|
|
627
|
+
assert.equal(direct.inspect().logicalWebSocketChannels, 0);
|
|
628
|
+
direct.close();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test('logical lifecycle cannot bind a different channel id or stale connection owner', async () => {
|
|
632
|
+
{
|
|
633
|
+
const { direct, signal } = await connectedDirect();
|
|
634
|
+
const peer = offer(signal);
|
|
635
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
636
|
+
peer.emitDataChannel(control);
|
|
637
|
+
control.open();
|
|
638
|
+
const lane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:input`);
|
|
639
|
+
peer.emitDataChannel(lane);
|
|
640
|
+
lane.open();
|
|
641
|
+
sendWire(control, {
|
|
642
|
+
type: 'ws-open',
|
|
643
|
+
connectionId: CONNECTION_ONE,
|
|
644
|
+
hubEpoch: HUB_EPOCH,
|
|
645
|
+
channelId: REQUEST_ID,
|
|
646
|
+
purpose: 'input',
|
|
647
|
+
path: '/api/remote/input/ws'
|
|
648
|
+
});
|
|
649
|
+
assert.equal(FakeLocalSocket.instances.length, 0);
|
|
650
|
+
assert.equal(direct.inspect().pendingLogicalControlRequests, 1);
|
|
651
|
+
assert.equal(lane.closed, false);
|
|
652
|
+
direct.close();
|
|
653
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
{
|
|
657
|
+
const { direct, signal } = await connectedDirect();
|
|
658
|
+
const peer = offer(signal);
|
|
659
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
660
|
+
peer.emitDataChannel(control);
|
|
661
|
+
control.open();
|
|
662
|
+
const lane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:input`);
|
|
663
|
+
peer.emitDataChannel(lane);
|
|
664
|
+
lane.open();
|
|
665
|
+
sendWire(control, {
|
|
666
|
+
type: 'ws-open',
|
|
667
|
+
connectionId: CONNECTION_TWO,
|
|
668
|
+
hubEpoch: HUB_EPOCH,
|
|
669
|
+
channelId: CHANNEL_ID,
|
|
670
|
+
purpose: 'input',
|
|
671
|
+
path: '/api/remote/input/ws'
|
|
672
|
+
});
|
|
673
|
+
assert.equal(peer.closed, true);
|
|
674
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
675
|
+
assert.equal(FakeLocalSocket.instances.length, 0);
|
|
676
|
+
direct.close();
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test('relay ICE candidate fails closed and exact owner returns to terminal zero', async () => {
|
|
681
|
+
const { direct, signal } = await connectedDirect();
|
|
682
|
+
const peer = offer(signal);
|
|
683
|
+
signal.receive(JSON.stringify({
|
|
684
|
+
type: 'rtc-ice',
|
|
685
|
+
consoleId: CONSOLE_ID,
|
|
686
|
+
connectionId: CONNECTION_ONE,
|
|
687
|
+
hubEpoch: HUB_EPOCH,
|
|
688
|
+
candidate: 'candidate:1 1 udp 1 203.0.113.10 5000 typ relay raddr 0.0.0.0 rport 0',
|
|
689
|
+
sdpMid: '0'
|
|
690
|
+
}), false);
|
|
691
|
+
assert.equal(peer.closed, true);
|
|
692
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
693
|
+
assert.equal(direct.inspect().rejectedRelayCandidates, 1);
|
|
694
|
+
assert.equal(signalingMessages(signal).some(message => (
|
|
695
|
+
message.type === 'rtc-close'
|
|
696
|
+
&& message.connectionId === CONNECTION_ONE
|
|
697
|
+
&& message.reason === 'console-direct-relay-candidate-rejected'
|
|
698
|
+
)), true);
|
|
699
|
+
direct.close();
|
|
700
|
+
assert.deepEqual({
|
|
701
|
+
peers: direct.inspect().peerConnections,
|
|
702
|
+
channels: direct.inspect().logicalWebSocketChannels,
|
|
703
|
+
requests: direct.inspect().pendingHttpRequests,
|
|
704
|
+
retained: direct.inspect().retainedAssemblyBytes
|
|
705
|
+
}, { peers: 0, channels: 0, requests: 0, retained: 0 });
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test('a relay candidate embedded in the offer SDP is rejected before peer allocation', async () => {
|
|
709
|
+
const { direct, signal } = await connectedDirect();
|
|
710
|
+
offer(signal, CONNECTION_ONE, 'v=0\r\na=candidate:1 1 udp 1 203.0.113.10 5000 typ relay\r\n');
|
|
711
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
712
|
+
assert.equal(direct.inspect().rejectedRelayCandidates, 1);
|
|
713
|
+
assert.equal(signalingMessages(signal).some(message => (
|
|
714
|
+
message.type === 'rtc-close'
|
|
715
|
+
&& message.connectionId === CONNECTION_ONE
|
|
716
|
+
&& message.reason === 'console-direct-relay-candidate-rejected'
|
|
717
|
+
)), true);
|
|
718
|
+
direct.close();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test('the peer-wide DataChannel buffer cap closes only the overflowing logical lane', async () => {
|
|
722
|
+
const { direct, signal } = await connectedDirect();
|
|
723
|
+
const peer = offer(signal);
|
|
724
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
725
|
+
peer.emitDataChannel(control);
|
|
726
|
+
control.open();
|
|
727
|
+
const lane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:frame`);
|
|
728
|
+
peer.emitDataChannel(lane);
|
|
729
|
+
lane.open();
|
|
730
|
+
sendWire(control, {
|
|
731
|
+
type: 'ws-open',
|
|
732
|
+
connectionId: CONNECTION_ONE,
|
|
733
|
+
hubEpoch: HUB_EPOCH,
|
|
734
|
+
channelId: CHANNEL_ID,
|
|
735
|
+
purpose: 'frame',
|
|
736
|
+
path: '/api/remote/frames/ws'
|
|
737
|
+
});
|
|
738
|
+
const local = FakeLocalSocket.instances[0];
|
|
739
|
+
local.open();
|
|
740
|
+
control.buffered = consoleDirectContract.maxBufferedBytes / 2;
|
|
741
|
+
lane.buffered = consoleDirectContract.maxBufferedBytes / 2;
|
|
742
|
+
local.receive('latest-frame');
|
|
743
|
+
assert.equal(lane.sent.length, 0);
|
|
744
|
+
assert.equal(lane.closed, true);
|
|
745
|
+
assert.equal(control.closed, false);
|
|
746
|
+
assert.equal(peer.closed, false);
|
|
747
|
+
assert.equal(direct.inspect().dataChannelBackpressureCloses, 1);
|
|
748
|
+
direct.close();
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test('a reliable control response drops stale media backlog without retiring the peer', async () => {
|
|
752
|
+
const { direct, signal } = await connectedDirect({
|
|
753
|
+
fetchImpl: async () => new Response('{"online":true}', {
|
|
754
|
+
status: 200,
|
|
755
|
+
headers: { 'Content-Type': 'application/json' }
|
|
756
|
+
})
|
|
757
|
+
});
|
|
758
|
+
const peer = offer(signal);
|
|
759
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
760
|
+
peer.emitDataChannel(control);
|
|
761
|
+
control.open();
|
|
762
|
+
const lane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:frame`);
|
|
763
|
+
peer.emitDataChannel(lane);
|
|
764
|
+
lane.open();
|
|
765
|
+
sendWire(control, {
|
|
766
|
+
type: 'ws-open',
|
|
767
|
+
connectionId: CONNECTION_ONE,
|
|
768
|
+
hubEpoch: HUB_EPOCH,
|
|
769
|
+
channelId: CHANNEL_ID,
|
|
770
|
+
purpose: 'frame',
|
|
771
|
+
path: '/api/remote/frames/ws'
|
|
772
|
+
});
|
|
773
|
+
FakeLocalSocket.instances[0].open();
|
|
774
|
+
lane.buffered = consoleDirectContract.maxBufferedBytes;
|
|
775
|
+
|
|
776
|
+
sendWire(control, {
|
|
777
|
+
type: 'http-request',
|
|
778
|
+
requestId: REQUEST_ID,
|
|
779
|
+
method: 'GET',
|
|
780
|
+
path: '/api/remote/status'
|
|
781
|
+
}, { messageId: 11 });
|
|
782
|
+
await settle();
|
|
783
|
+
|
|
784
|
+
const response = decodeWireMessages(control.sent)
|
|
785
|
+
.map(message => JSON.parse(message.data))
|
|
786
|
+
.find(message => message.type === 'http-response' && message.requestId === REQUEST_ID);
|
|
787
|
+
assert.equal(response.status, 200);
|
|
788
|
+
assert.equal(Buffer.from(response.bodyBase64, 'base64').toString('utf8'), '{"online":true}');
|
|
789
|
+
assert.equal(lane.closed, true);
|
|
790
|
+
assert.equal(control.closed, false);
|
|
791
|
+
assert.equal(peer.closed, false);
|
|
792
|
+
assert.equal(direct.inspect().peerConnections, 1);
|
|
793
|
+
assert.equal(direct.inspect().dataChannelBackpressureCloses, 1);
|
|
794
|
+
direct.close();
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test('a transient disconnected state recovers or retires the exact peer on one bounded deadline', async () => {
|
|
798
|
+
const { direct, signal } = await connectedDirect({ peerDisconnectedTimeoutMs: 5 });
|
|
799
|
+
const peer = offer(signal);
|
|
800
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
801
|
+
peer.emitDataChannel(control);
|
|
802
|
+
control.open();
|
|
803
|
+
|
|
804
|
+
peer.emitState('disconnected');
|
|
805
|
+
assert.equal(direct.inspect().peerDisconnectTimers, 1);
|
|
806
|
+
peer.emitState('connected');
|
|
807
|
+
assert.equal(direct.inspect().peerDisconnectTimers, 0);
|
|
808
|
+
assert.equal(peer.closed, false);
|
|
809
|
+
|
|
810
|
+
peer.emitState('disconnected');
|
|
811
|
+
await new Promise(resolve => setTimeout(resolve, 15));
|
|
812
|
+
assert.equal(peer.closed, true);
|
|
813
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
814
|
+
assert.equal(direct.inspect().peerDisconnectTimers, 0);
|
|
815
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
816
|
+
direct.close();
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test('an oversized local frame closes only its logical media lane', async () => {
|
|
820
|
+
const { direct, signal } = await connectedDirect();
|
|
821
|
+
const peer = offer(signal);
|
|
822
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
823
|
+
peer.emitDataChannel(control);
|
|
824
|
+
control.open();
|
|
825
|
+
const lane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:frame`);
|
|
826
|
+
peer.emitDataChannel(lane);
|
|
827
|
+
lane.open();
|
|
828
|
+
sendWire(control, {
|
|
829
|
+
type: 'ws-open',
|
|
830
|
+
connectionId: CONNECTION_ONE,
|
|
831
|
+
hubEpoch: HUB_EPOCH,
|
|
832
|
+
channelId: CHANNEL_ID,
|
|
833
|
+
purpose: 'frame',
|
|
834
|
+
path: '/api/remote/frames/ws'
|
|
835
|
+
});
|
|
836
|
+
const local = FakeLocalSocket.instances[0];
|
|
837
|
+
local.open();
|
|
838
|
+
local.receive(Buffer.alloc(consoleDirectContract.maxFrameMessageBytes + 1), true);
|
|
839
|
+
assert.equal(lane.closed, true);
|
|
840
|
+
assert.equal(control.closed, false);
|
|
841
|
+
assert.equal(peer.closed, false);
|
|
842
|
+
direct.close();
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test('an unopened control channel cannot suppress the bounded ICE owner timeout', async () => {
|
|
846
|
+
const { direct, signal } = await connectedDirect({ iceConnectTimeoutMs: 5 });
|
|
847
|
+
const peer = offer(signal);
|
|
848
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
849
|
+
peer.emitDataChannel(control);
|
|
850
|
+
assert.equal(direct.inspect().iceConnectTimers, 1);
|
|
851
|
+
await new Promise(resolve => setTimeout(resolve, 15));
|
|
852
|
+
assert.equal(peer.closed, true);
|
|
853
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
854
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
855
|
+
direct.close();
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test('an incomplete partial-reliable media message owns one deadline and close returns terminal zero', async () => {
|
|
859
|
+
const { direct, signal } = await connectedDirect({ mediaAssemblyTimeoutMs: 5 });
|
|
860
|
+
const peer = offer(signal);
|
|
861
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
862
|
+
peer.emitDataChannel(control);
|
|
863
|
+
control.open();
|
|
864
|
+
const lane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:frame`);
|
|
865
|
+
peer.emitDataChannel(lane);
|
|
866
|
+
lane.open();
|
|
867
|
+
const chunks = encodeDirectConsoleWireMessage(new Uint8Array(40 * 1024), {
|
|
868
|
+
messageId: 9,
|
|
869
|
+
kind: 'binary',
|
|
870
|
+
maxMessageBytes: 12 * 1024 * 1024
|
|
871
|
+
});
|
|
872
|
+
assert.ok(chunks.length > 1);
|
|
873
|
+
lane.receive(chunks[0]);
|
|
874
|
+
assert.equal(direct.inspect().assemblyDeadlineTimers, 1);
|
|
875
|
+
assert.ok(direct.inspect().retainedAssemblyBytes > 0);
|
|
876
|
+
await new Promise(resolve => setTimeout(resolve, 15));
|
|
877
|
+
assert.equal(lane.closed, true);
|
|
878
|
+
assert.equal(peer.closed, false);
|
|
879
|
+
assert.equal(direct.inspect().assemblyDeadlineTimers, 0);
|
|
880
|
+
assert.equal(direct.inspect().retainedAssemblyBytes, 0);
|
|
881
|
+
direct.close();
|
|
882
|
+
assert.deepEqual({
|
|
883
|
+
peers: direct.inspect().peerConnections,
|
|
884
|
+
channels: direct.inspect().logicalWebSocketChannels,
|
|
885
|
+
localSockets: direct.inspect().localWebSocketChannels,
|
|
886
|
+
retained: direct.inspect().retainedAssemblyBytes,
|
|
887
|
+
timers: direct.inspect().resourceTimers
|
|
888
|
+
}, { peers: 0, channels: 0, localSockets: 0, retained: 0, timers: 0 });
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
test('TURN and credential-like override values are filtered before peer configuration', async () => {
|
|
892
|
+
const { direct, signal } = await connectedDirect({
|
|
893
|
+
stunUrls: [
|
|
894
|
+
'turn:turn.example.test:3478',
|
|
895
|
+
'stun:user@example.test:3478',
|
|
896
|
+
'stun:approved.example.test:3478'
|
|
897
|
+
]
|
|
898
|
+
});
|
|
899
|
+
const peer = offer(signal);
|
|
900
|
+
assert.deepEqual(peer.config.iceServers, ['stun:approved.example.test:3478']);
|
|
901
|
+
direct.close();
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test('server session refresh preserves peers while logout uses the revoking close path', () => {
|
|
905
|
+
const source = readFileSync(new URL('./server.js', import.meta.url), 'utf8');
|
|
906
|
+
const sessionStart = source.indexOf("app.post('/api/auth/session'");
|
|
907
|
+
const logoutStart = source.indexOf("app.delete('/api/auth/session'");
|
|
908
|
+
const hubStatusStart = source.indexOf("app.get('/api/hub/status'", logoutStart);
|
|
909
|
+
assert.ok(sessionStart >= 0 && logoutStart > sessionStart && hubStatusStart > logoutStart);
|
|
910
|
+
const sessionBlock = source.slice(sessionStart, logoutStart);
|
|
911
|
+
const logoutBlock = source.slice(logoutStart, hubStatusStart);
|
|
912
|
+
assert.match(sessionBlock, /hubConsoleDirect\?\.refresh\(\)/);
|
|
913
|
+
assert.match(logoutBlock, /hubConsoleDirect\?\.close\(\)/);
|
|
914
|
+
assert.doesNotMatch(logoutBlock, /hubConsoleDirect\?\.refresh\(\)/);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
test('native node-datachannel peers carry the fragmented control wire over loopback', { timeout: 8_000 }, async () => {
|
|
918
|
+
let offerer = null;
|
|
919
|
+
let answerer = null;
|
|
920
|
+
let offerChannel = null;
|
|
921
|
+
let answerChannel = null;
|
|
922
|
+
const assembler = createDirectConsoleWireAssembler();
|
|
923
|
+
let openTimer = null;
|
|
924
|
+
let messageTimer = null;
|
|
925
|
+
try {
|
|
926
|
+
offerer = new nodeDataChannel.PeerConnection('console-direct-native-offerer', { iceServers: [] });
|
|
927
|
+
answerer = new nodeDataChannel.PeerConnection('console-direct-native-answerer', { iceServers: [] });
|
|
928
|
+
offerer.onLocalDescription((sdp, type) => {
|
|
929
|
+
answerer.setRemoteDescription(sdp, type);
|
|
930
|
+
});
|
|
931
|
+
answerer.onLocalDescription((sdp, type) => offerer.setRemoteDescription(sdp, type));
|
|
932
|
+
offerer.onLocalCandidate((candidate, sdpMid) => {
|
|
933
|
+
if (candidate) answerer.addRemoteCandidate(candidate, sdpMid);
|
|
934
|
+
});
|
|
935
|
+
answerer.onLocalCandidate((candidate, sdpMid) => {
|
|
936
|
+
if (candidate) offerer.addRemoteCandidate(candidate, sdpMid);
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
let resolveOpened;
|
|
940
|
+
const opened = new Promise((resolve, reject) => {
|
|
941
|
+
resolveOpened = resolve;
|
|
942
|
+
openTimer = setTimeout(() => reject(new Error('native-datachannel-open-timeout')), 4_000);
|
|
943
|
+
});
|
|
944
|
+
let resolveReceived;
|
|
945
|
+
const received = new Promise((resolve, reject) => {
|
|
946
|
+
resolveReceived = resolve;
|
|
947
|
+
messageTimer = setTimeout(() => reject(new Error('native-datachannel-message-timeout')), 4_000);
|
|
948
|
+
});
|
|
949
|
+
answerer.onDataChannel(channel => {
|
|
950
|
+
answerChannel = channel;
|
|
951
|
+
channel.onOpen(resolveOpened);
|
|
952
|
+
channel.onMessage(raw => {
|
|
953
|
+
const message = assembler.push(raw);
|
|
954
|
+
if (message) resolveReceived(message);
|
|
955
|
+
});
|
|
956
|
+
});
|
|
957
|
+
offerChannel = offerer.createDataChannel('livedesk-control-v1');
|
|
958
|
+
await opened;
|
|
959
|
+
clearTimeout(openTimer);
|
|
960
|
+
openTimer = null;
|
|
961
|
+
const payload = JSON.stringify({ type: 'direct-ready', connectionId: CONNECTION_ONE, hubEpoch: HUB_EPOCH });
|
|
962
|
+
const chunks = encodeDirectConsoleWireMessage(payload.repeat(400), {
|
|
963
|
+
messageId: 77,
|
|
964
|
+
kind: 'control'
|
|
965
|
+
});
|
|
966
|
+
assert.ok(chunks.length > 1);
|
|
967
|
+
for (const chunk of chunks) assert.equal(offerChannel.sendMessageBinary(chunk), true);
|
|
968
|
+
const message = await received;
|
|
969
|
+
clearTimeout(messageTimer);
|
|
970
|
+
messageTimer = null;
|
|
971
|
+
assert.equal(message.kind, 'control');
|
|
972
|
+
assert.equal(message.data, payload.repeat(400));
|
|
973
|
+
} finally {
|
|
974
|
+
if (openTimer) clearTimeout(openTimer);
|
|
975
|
+
if (messageTimer) clearTimeout(messageTimer);
|
|
976
|
+
assembler.dispose();
|
|
977
|
+
try { offerChannel?.close(); } catch {}
|
|
978
|
+
try { answerChannel?.close(); } catch {}
|
|
979
|
+
try { offerer?.close(); } catch {}
|
|
980
|
+
try { answerer?.close(); } catch {}
|
|
981
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
982
|
+
nodeDataChannel.cleanup();
|
|
983
|
+
}
|
|
984
|
+
});
|