Files
securebit-chat/tests/ice-gathering-patience.test.mjs
T

151 lines
6.3 KiB
JavaScript
Raw Normal View History

// ICE gathering only reaches 'complete' once every configured STUN/TURN server
// has answered or timed out. On a network that blocks them — a VPN, a captive
// portal, an interface the browser cannot route from — that never happens, even
// though host candidates are available immediately and are enough to connect on
// a LAN.
//
// The old code waited a flat 10 s and then failed the whole handshake if the SDP
// happened to be empty at that instant. That made success a coin flip: the same
// device failed one attempt and connected on the next with gathering still in
// progress (observed in the field, 11 candidates at 10001 ms).
import assert from 'node:assert/strict';
globalThis.window = { EnhancedSecureCryptoUtils: { secureLog: { log() {} } } };
globalThis.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i?.detail; } };
globalThis.document = { dispatchEvent() {} };
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
const P = EnhancedSecureWebRTCManager.prototype;
const T = EnhancedSecureWebRTCManager.TIMEOUTS;
// Fake timers so the 10 s / 25 s deadlines can be driven by hand.
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
let timers = [];
globalThis.setTimeout = (fn, delay) => {
const t = { fn, delay, cleared: false };
timers.push(t);
return t;
};
globalThis.clearTimeout = (t) => { if (t) t.cleared = true; };
const fireDelay = (delay) => {
for (const t of timers.filter((x) => !x.cleared && x.delay === delay)) {
t.cleared = true;
t.fn();
}
};
const sdpWithCandidates = (n) =>
'v=0\r\n' + Array.from({ length: n }, (_, i) =>
`a=candidate:${i} 1 udp 2122260223 192.168.1.${i + 2} 5000${i} typ host\r\n`).join('');
function makeManager(candidateCount) {
const listeners = [];
const ui = [];
return {
ui,
listeners,
peerConnection: {
iceGatheringState: 'gathering',
localDescription: { sdp: sdpWithCandidates(candidateCount) },
addEventListener: (name, fn) => listeners.push({ name, fn }),
removeEventListener: () => {}
},
_activeTimers: new Set(),
_secureLog() {},
deliverMessageToUI: (m) => ui.push(m),
_trackActiveTimer: P._trackActiveTimer,
_untrackActiveTimer: P._untrackActiveTimer,
_summarizeIceCandidatesInSDP: P._summarizeIceCandidatesInSDP,
_countIceCandidatesInSDP: P._countIceCandidatesInSDP,
waitForIceGathering: P.waitForIceGathering
};
}
try {
// ── the budget must actually be longer than the soft deadline ────────────
assert.ok(T.ICE_GATHERING_HARD_TIMEOUT > T.ICE_GATHERING_TIMEOUT,
'the hard ceiling must leave room past the soft deadline');
// ── already complete: return immediately, no waiting ─────────────────────
{
timers = [];
const mgr = makeManager(3);
mgr.peerConnection.iceGatheringState = 'complete';
assert.equal(await mgr.waitForIceGathering(), true);
assert.deepEqual(timers.filter((t) => !t.cleared), [], 'no timers left behind');
}
// ── gathering finishes on its own: resolve true and drop the timers ──────
{
timers = [];
const mgr = makeManager(3);
const pending = mgr.waitForIceGathering();
mgr.peerConnection.iceGatheringState = 'complete';
mgr.listeners.forEach((l) => l.fn());
assert.equal(await pending, true);
assert.deepEqual(timers.filter((t) => !t.cleared), [],
'a completed gather must not leave the hard timer armed');
}
// ── soft deadline WITH candidates: stop waiting, report "not complete" ───
// This is the common case on a restricted network, and it must succeed: the
// caller only refuses to export when there is nothing at all.
{
timers = [];
const mgr = makeManager(11);
const pending = mgr.waitForIceGathering();
fireDelay(T.ICE_GATHERING_TIMEOUT);
assert.equal(await pending, false, 'gathering did not complete...');
// ...but the caller's guard is `!completed && count === 0`, so 11
// candidates mean the handshake proceeds.
assert.ok(mgr._summarizeIceCandidatesInSDP(mgr.peerConnection.localDescription.sdp).total > 0);
}
// ── soft deadline with NOTHING: keep waiting instead of failing ──────────
// The regression under test. Previously this resolved at 10 s with an empty
// SDP and the handshake threw.
{
timers = [];
const mgr = makeManager(0);
let settled = false;
const pending = mgr.waitForIceGathering().then((v) => { settled = true; return v; });
fireDelay(T.ICE_GATHERING_TIMEOUT);
await Promise.resolve();
assert.equal(settled, false, 'an empty SDP at the soft deadline must not end the wait');
// A candidate arriving late is exactly what the extra patience buys.
mgr.peerConnection.localDescription.sdp = sdpWithCandidates(4);
mgr.peerConnection.iceGatheringState = 'complete';
mgr.listeners.forEach((l) => l.fn());
assert.equal(await pending, true, 'a late completion must still be picked up');
}
// ── a genuinely dead network still fails, at the hard ceiling ────────────
{
timers = [];
const mgr = makeManager(0);
const pending = mgr.waitForIceGathering();
fireDelay(T.ICE_GATHERING_TIMEOUT);
fireDelay(T.ICE_GATHERING_HARD_TIMEOUT);
assert.equal(await pending, false, 'nothing gathered at all must eventually give up');
}
// ── a caller-supplied budget shorter than the hard default is honoured ───
// Session recovery passes 4 s and must not silently wait 25 s instead.
{
timers = [];
const mgr = makeManager(0);
const pending = mgr.waitForIceGathering(T.ICE_RESTART_GATHERING, T.ICE_RESTART_GATHERING);
fireDelay(T.ICE_RESTART_GATHERING);
assert.equal(await pending, false, 'the recovery path keeps its short budget');
}
console.log('ice-gathering-patience.test.mjs: all assertions passed');
} finally {
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
}