Files
securebit-chat/tests/session-recovery.test.mjs
T

746 lines
37 KiB
JavaScript
Raw Normal View History

import assert from 'node:assert/strict';
globalThis.window = {
EnhancedSecureCryptoUtils: { secureLog: { log() {} } }
};
globalThis.CustomEvent = class CustomEvent {
constructor(type, init) { this.type = type; this.detail = init?.detail; }
};
const dispatched = [];
globalThis.document = { dispatchEvent(e) { dispatched.push(e.type); } };
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
const P = EnhancedSecureWebRTCManager.prototype;
const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
const TIMEOUTS = EnhancedSecureWebRTCManager.TIMEOUTS;
// Fake timers: the retry cycle is self-rescheduling by design (bounded only by
// RECONNECT_MAX_DURATION), so real timers would keep the test process alive.
// Driving them by hand also lets the backoff schedule itself be asserted.
const realTimers = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval
};
let scheduled = [];
globalThis.setTimeout = (callback, delay) => {
const timer = { kind: 'timeout', callback, delay, cleared: false };
scheduled.push(timer);
return timer;
};
globalThis.setInterval = (callback, delay) => {
const timer = { kind: 'interval', callback, delay, cleared: false };
scheduled.push(timer);
return timer;
};
globalThis.clearTimeout = (timer) => { if (timer) timer.cleared = true; };
globalThis.clearInterval = (timer) => { if (timer) timer.cleared = true; };
const pending = () => scheduled.filter((t) => !t.cleared);
// A real timeout stops pending once it fires; mirror that so the next lookup
// finds the newly scheduled retry rather than the spent one.
const fire = (timer) => { timer.cleared = true; return timer.callback(); };
// Let queued microtasks settle: several recovery entry points fire an async
// restart without awaiting it.
const settle = async () => { for (let i = 0; i < 8; i += 1) await Promise.resolve(); };
const FP_A = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99';
const FP_B = '99:88:77:66:55:44:33:22:11:00:FF:EE:DD:CC:BB:AA';
const sdpWith = (fp) => `v=0\r\no=- 1 1 IN IP4 0.0.0.0\r\ns=-\r\na=fingerprint:sha-256 ${fp}\r\na=setup:actpass\r\n`;
// Minimal manager stub carrying only the recovery surface under test.
function makeManager(overrides = {}) {
const statuses = [];
const sent = [];
const ui = [];
const mgr = {
isVerified: true,
isInitiator: true,
intentionalDisconnect: false,
reconnectionFailedNotificationSent: false,
// Raw frames written straight to the channel (heartbeats/probes), as
// opposed to `sent`, which collects sendSystemMessage payloads.
_sentFrames: [],
_heartbeatConfig: { enabled: true, lastHeartbeat: 0 },
_livenessProbeAt: 0,
_livenessArmed: true,
isConnected: () => true,
dataChannel: { readyState: 'open', send: (raw) => { mgr._sentFrames.push(JSON.parse(raw)); } },
peerConnection: {
connectionState: 'disconnected',
signalingState: 'stable',
currentRemoteDescription: { sdp: sdpWith(FP_A) },
localDescription: { sdp: sdpWith(FP_B) },
createOffer: async () => ({ type: 'offer', sdp: sdpWith(FP_B) }),
createAnswer: async () => ({ type: 'answer', sdp: sdpWith(FP_B) }),
setLocalDescription: async () => {},
setRemoteDescription: async (d) => { mgr._applied.push(d.type); }
},
_applied: [],
_reconnect: {
phase: 'idle', attempts: 0, startedAt: 0,
graceTimer: null, retryTimer: null, restartTimer: null, pendingRole: null
},
_activeTimers: new Set(),
_lastInboundAt: 0,
_livenessTimer: null,
_heartbeatTimer: null,
_secureLog() {},
_clearVerificationStates() { mgr._verificationCleared = true; },
_verificationCleared: false,
onStatusChange: (s) => statuses.push(s),
deliverMessageToUI: (m) => ui.push(m),
processMessageQueue() { mgr._queueFlushed = true; },
_queueFlushed: false,
waitForIceGathering: async () => true,
sendSystemMessage: async (m) => { sent.push(m); return true; },
// Real implementations under test
_trackActiveTimer: P._trackActiveTimer,
_noteInboundActivity: P._noteInboundActivity,
handleHeartbeat: P.handleHeartbeat,
_sendHeartbeat: P._sendHeartbeat,
_checkLiveness: P._checkLiveness,
isReconnecting: P.isReconnecting,
_resetReconnectState: P._resetReconnectState,
_onPathDegraded: P._onPathDegraded,
_onPathLost: P._onPathLost,
_onPathRecovered: P._onPathRecovered,
_attemptIceRestart: P._attemptIceRestart,
_scheduleReconnectRetry: P._scheduleReconnectRetry,
_sendIceRestartOffer: P._sendIceRestartOffer,
_currentRemoteDtlsFingerprint: P._currentRemoteDtlsFingerprint,
_assertSameRemoteIdentity: P._assertSameRemoteIdentity,
_handleIceRestartSignal: P._handleIceRestartSignal,
_giveUpAutoReconnect: P._giveUpAutoReconnect,
_extractDTLSFingerprintFromSDP: P._extractDTLSFingerprintFromSDP,
_validateDTLSFingerprint: P._validateDTLSFingerprint,
...overrides
};
scheduled = [];
return { mgr, statuses, sent, ui };
}
try {
// ── the heartbeat handler exists and answers probes ──────────────────────
// It used to be dispatched to but never defined, so every inbound heartbeat
// threw a TypeError and liveness was never actually observed.
{
assert.equal(typeof P.handleHeartbeat, 'function', 'handleHeartbeat must exist');
// A probe must be answered: that reply is the only thing that proves this
// side is alive when its own timers are throttled by a backgrounded tab.
const { mgr } = makeManager();
mgr._lastInboundAt = 0;
mgr.handleHeartbeat({ type: 'heartbeat', ack: false });
assert.ok(mgr._lastInboundAt > 0, 'heartbeat must refresh the liveness clock');
assert.equal(mgr._sentFrames.length, 1);
assert.equal(mgr._sentFrames[0].ack, true, 'a probe must be acked');
// An ack must NOT be acked, or the two sides ping-pong forever.
const { mgr: acked } = makeManager();
acked.handleHeartbeat({ type: 'heartbeat', ack: true });
assert.deepEqual(acked._sentFrames, [], 'an ack must not be answered');
}
// ── the answerer starts its watchdog even on an already-open channel ─────
// `ondatachannel` can hand over a channel that is ALREADY 'open', so the
// 'open' event has been dispatched before onopen is assigned. That left the
// answering side with no heartbeats and no liveness watchdog at all — the
// peer whose network was fine kept showing "connected" indefinitely because
// nothing on that side was running to notice the other one had vanished.
{
const started = [];
const base = {
isVerified: false,
pendingSASCode: null,
_secureLog() {},
establishConnection: async () => {},
initializeFileTransfer() {},
_notifyVerificationReadyIfPossible() {},
initiateVerification() {},
processMessageQueue() {},
onStatusChange() {},
_resetReconnectState() {},
_teardownRecoveryLifecycleListeners() {},
_noteInboundActivity() {},
startHeartbeat() { started.push(Date.now()); },
setupDataChannel: P.setupDataChannel
};
// Channel already open when handed over: the handler must still run.
const already = { ...base };
already.setupDataChannel({ readyState: 'open', send() {} });
await settle();
assert.equal(started.length, 1, 'an already-open channel must still start the watchdog');
// Normal case: the event fires, and the handler must not run twice.
started.length = 0;
const later = { ...base };
const channel = { readyState: 'connecting', send() {} };
later.setupDataChannel(channel);
channel.readyState = 'open';
await channel.onopen();
await settle();
assert.equal(started.length, 1, 'the open handler must run exactly once');
}
// ── a peer never heard from is not a dead peer ───────────────────────────
// Right after connecting, the two sides finish SAS verification at different
// moments, so one can be probing while the other still cannot answer.
// Without a baseline, that silence used to read as death and tore down a
// healthy, freshly established session.
{
const { mgr, statuses } = makeManager({ _livenessArmed: false });
mgr._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 10_000);
mgr._checkLiveness();
await settle();
assert.deepEqual(statuses, [], 'silence before the first frame must not start recovery');
assert.deepEqual(mgr._sentFrames, [], 'and must not even probe yet');
// One frame from the peer establishes the baseline; from then on it counts.
mgr._noteInboundActivity();
assert.equal(mgr._livenessArmed, true);
mgr._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 10_000);
mgr._checkLiveness();
assert.equal(mgr._sentFrames.length, 1, 'a known peer that goes quiet is probed');
mgr._resetReconnectState();
}
// ── a peer answers probes before it has finished verifying ───────────────
// The heartbeat path is gated on the channel, not on isVerified: a human has
// to compare the SAS code, and for that whole window the other side would
// otherwise be unable to answer and would be declared dead.
{
const { mgr } = makeManager({ isVerified: false, isConnected: () => false });
mgr.handleHeartbeat({ type: 'heartbeat', ack: false });
assert.equal(mgr._sentFrames.length, 1, 'an unverified peer must still answer a probe');
assert.equal(mgr._sentFrames[0].ack, true);
}
// ── liveness: silence prompts a probe, only an unanswered probe kills ────
{
// Busy conversation: recent inbound activity must NOT trip the watchdog.
const { mgr, statuses } = makeManager();
mgr._lastInboundAt = Date.now();
mgr._checkLiveness();
assert.deepEqual(statuses, [], 'recent traffic must not trigger recovery');
assert.deepEqual(mgr._sentFrames, [], 'no probe while the peer is chatting');
// Silence alone must NOT declare the path dead — a backgrounded tab has
// its timers throttled and legitimately goes quiet. It only earns a probe.
const { mgr: quiet, statuses: quietStatuses } = makeManager();
quiet._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 1000);
quiet._checkLiveness();
assert.deepEqual(quietStatuses, [], 'silence alone must not start recovery');
assert.equal(quiet._sentFrames.length, 1, 'silence must trigger a probe');
assert.equal(quiet._sentFrames[0].ack, false);
assert.ok(quiet._livenessProbeAt > 0);
// The peer answers → still alive, no recovery, probe cleared.
quiet.handleHeartbeat({ type: 'heartbeat', ack: true });
quiet._checkLiveness();
assert.deepEqual(quietStatuses, [], 'an answered probe proves the path is alive');
assert.equal(quiet._livenessProbeAt, 0);
// A peer whose tab the OS froze cannot answer anything — no JavaScript
// runs in it at all. But ICE consent checks live in the browser's network
// stack, not the page's thread, so a 'connected' ICE state proves the peer
// is still reachable and the silence is a sleeping tab. Tearing the
// session down here is what broke a healthy chat every time a phone
// locked its screen.
const { mgr: asleep, statuses: asleepStatuses } = makeManager();
asleep.peerConnection.connectionState = 'connected';
asleep._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 1000);
asleep._checkLiveness();
asleep._livenessProbeAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_TIMEOUT + 1000);
asleep._checkLiveness();
await settle();
assert.deepEqual(asleepStatuses, [], 'a silent peer on a healthy ICE path must be left alone');
assert.equal(asleep.isReconnecting(), false);
// And since the answer would change nothing, it must not even be asked:
// probing here was pure traffic for as long as the peer's phone slept.
assert.deepEqual(asleep._sentFrames, [], 'a healthy ICE path must not be probed at all');
assert.equal(asleep._livenessProbeAt, 0);
asleep._resetReconnectState();
// Probe goes unanswered past its deadline → the path really is dead.
// This is the Wi-Fi → LTE case: nothing closes, nothing errors, packets
// just stop and readyState still reads 'open'.
const { mgr: dead, statuses: deadStatuses } = makeManager();
dead._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 1000);
dead._checkLiveness(); // sends the probe
dead._livenessProbeAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_TIMEOUT + 1000);
dead._checkLiveness(); // deadline passed
await settle();
assert.ok(deadStatuses.includes('reconnecting'), 'an unanswered probe must start recovery');
dead._resetReconnectState();
}
// ── an unverified session is never dragged into recovery ────────────────
{
const { mgr, statuses } = makeManager({ isVerified: false });
mgr._lastInboundAt = Date.now() - 10 * 60 * 1000;
mgr._checkLiveness();
mgr._onPathDegraded('ice_disconnected');
mgr._onPathLost('ice_failed');
await settle();
assert.deepEqual(statuses, [], 'recovery must not run before verification');
}
// ── degraded path: UI says reconnecting, session is NOT torn down ────────
{
const { mgr, statuses } = makeManager();
mgr._onPathDegraded('ice_disconnected');
assert.deepEqual(statuses, ['reconnecting']);
assert.equal(mgr._verificationCleared, false, 'a transient drop must not clear verification');
assert.equal(mgr.isVerified, true, 'keys and SAS verification survive a path glitch');
assert.equal(mgr.isReconnecting(), true);
// The restart is held back for the grace window, because 'disconnected'
// usually clears on its own.
const grace = pending().find((t) => t.delay === TIMEOUTS.ICE_DISCONNECT_GRACE);
assert.ok(grace, 'a grace window must be scheduled before spending a restart');
// A second event while already recovering must not restart the cycle.
mgr._onPathDegraded('ice_disconnected');
assert.deepEqual(statuses, ['reconnecting'], 'recovery must not be re-entered');
mgr._resetReconnectState();
assert.equal(grace.cleared, true, 'reset must clear the grace timer');
}
// ── the grace window is sized to the browser's own ICE timings ───────────
// 'disconnected' arrives after ~5 s of missed consent responses — ordinary
// packet loss reaches that — and the browser then holds it ~25 s before
// 'failed'. That window is where self-healing happens, and a phone with its
// screen off produces these episodes constantly. Restarting at the start of
// it answered every one with a renegotiation that broke a connection about
// to recover. https://blog.mozilla.org/webrtc/ice-disconnected-not/
{
assert.ok(TIMEOUTS.ICE_DISCONNECT_GRACE >= 5000,
'must outlast the ~5 s of loss that produces "disconnected" in the first place');
assert.ok(TIMEOUTS.ICE_DISCONNECT_GRACE + TIMEOUTS.ICE_RESTART_TIMEOUT < 30000,
'but a restart must still complete before the browser gives up at ~30 s');
}
// ── the path recovering during the grace window costs no restart ─────────
{
const { mgr, statuses, sent } = makeManager();
mgr._onPathDegraded('ice_disconnected');
const grace = pending().find((t) => t.delay === TIMEOUTS.ICE_DISCONNECT_GRACE);
mgr.peerConnection.connectionState = 'connected';
fire(grace);
await settle();
assert.deepEqual(sent, [], 'a self-healing glitch must not renegotiate');
assert.deepEqual(statuses, ['reconnecting', 'connected']);
mgr._resetReconnectState();
}
// ── role split: the offerer restarts, the answerer asks ─────────────────
{
const { mgr, sent } = makeManager({ isInitiator: true });
mgr._reconnect.startedAt = Date.now();
await mgr._attemptIceRestart();
assert.equal(sent.length, 1);
assert.equal(sent[0].type, T.ICE_RESTART_OFFER, 'offerer drives the restart');
assert.ok(sent[0].sdp, 'restart offer carries SDP');
mgr._resetReconnectState();
// Both sides offering at once is glare, and with no signalling server
// there is no referee — so the answerer asks instead of acting.
const { mgr: answerer, sent: answererSent } = makeManager({ isInitiator: false });
answerer._reconnect.startedAt = Date.now();
await answerer._attemptIceRestart();
assert.equal(answererSent[0].type, T.ICE_RESTART_REQUEST, 'answerer must not create a competing offer');
answerer._resetReconnectState();
}
// ── retries back off, and stop the moment the path is back ──────────────
{
const { mgr, sent } = makeManager({ isInitiator: true });
mgr._reconnect.startedAt = Date.now();
const backoff = EnhancedSecureWebRTCManager.RECONNECT_BACKOFF;
await mgr._attemptIceRestart();
let retry = pending().find((t) => t.kind === 'timeout');
assert.equal(retry.delay, backoff[0], 'first retry uses the head of the backoff');
// The round-trip completes but the pair still fails to connect, so the
// next retry is a genuine second attempt and backs off further.
mgr.peerConnection.signalingState = 'have-local-offer';
await mgr._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_A) });
fire(retry);
await settle();
retry = pending().find((t) => t.kind === 'timeout');
assert.equal(retry.delay, backoff[1], 'each failed attempt backs off further');
assert.equal(sent.length, 2, 'each attempt re-sends the restart offer');
mgr.peerConnection.signalingState = 'have-local-offer';
await mgr._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_A) });
const sentAfterTwoAttempts = sent.length;
// Path comes back: the pending retry must not fire another restart.
mgr.peerConnection.connectionState = 'connected';
fire(retry);
await settle();
assert.equal(sent.length, sentAfterTwoAttempts, 'recovery stops as soon as the path is up');
assert.equal(mgr.isReconnecting(), false);
mgr._resetReconnectState();
}
// ── a healthy connection is never renegotiated ───────────────────────────
// Recovery can be entered on a stale reading — a mobile tab thawing out of
// the background is the common one, since its timers were frozen and
// connectionState is still catching up. Restarting from there broke a
// working session and stranded it in "reconnecting", where sending is
// blocked while messages keep arriving.
{
const { mgr, sent, statuses } = makeManager();
mgr._reconnect.phase = 'grace';
mgr._reconnect.startedAt = Date.now();
mgr.peerConnection.connectionState = 'connected';
await mgr._attemptIceRestart();
assert.deepEqual(sent, [], 'a connected path must not be renegotiated');
assert.equal(mgr.isReconnecting(), false, 'recovery must stand down, not linger');
assert.equal(statuses.at(-1), 'connected');
assert.equal(mgr._queueFlushed, true, 'anything queued meanwhile must go out');
}
// ── returning to the foreground asks the peer, it does not accuse it ─────
{
const { mgr, statuses } = makeManager();
mgr._setupRecoveryLifecycleListeners = P._setupRecoveryLifecycleListeners;
const listeners = {};
globalThis.window = {
...globalThis.window,
addEventListener: (name, fn) => { listeners[name] = fn; },
removeEventListener: () => {}
};
const realDocument = globalThis.document;
globalThis.document = {
visibilityState: 'visible',
addEventListener: (name, fn) => { listeners[name] = fn; },
removeEventListener: () => {},
dispatchEvent: () => {}
};
try {
mgr._setupRecoveryLifecycleListeners();
// Long silence, but only because the tab's timers were frozen.
mgr._lastInboundAt = Date.now() - 10 * 60 * 1000;
listeners.visibilitychange();
await settle();
assert.deepEqual(statuses, [], 'coming back must not declare the session broken');
assert.equal(mgr._sentFrames.length, 1, 'it must probe the peer instead');
assert.equal(mgr._sentFrames[0].ack, false);
assert.ok(mgr._livenessProbeAt > 0, 'the probe deadline must be armed');
} finally {
globalThis.document = realDocument;
}
mgr._resetReconnectState();
}
// ── one round-trip at a time ─────────────────────────────────────────────
// A restart takes seconds end to end, far longer than the head of the
// backoff. Without this guard each retry replaced the local description out
// from under the attempt already in flight, so every try cancelled the last
// and recovery never converged — observed as a long run of attempts that
// ended in a plain timeout.
{
const { mgr, sent } = makeManager({ isInitiator: true });
mgr._reconnect.startedAt = Date.now();
await mgr._attemptIceRestart();
assert.equal(sent.length, 1);
assert.ok(mgr._reconnect.inFlightAt > 0, 'the round-trip must be marked in flight');
// The retry fires while the round-trip is still running: it must wait,
// not launch a competing restart.
let retry = pending().find((t) => t.kind === 'timeout');
fire(retry);
await settle();
assert.equal(sent.length, 1, 'no competing restart while one is in flight');
assert.ok(pending().some((t) => t.kind === 'timeout'), 'it must keep waiting, not give up');
// Round-trip completes (answer applied) → a further attempt is allowed.
mgr.peerConnection.signalingState = 'have-local-offer';
await mgr._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_A) });
assert.equal(mgr._reconnect.inFlightAt, 0, 'a completed round-trip clears the in-flight mark');
retry = pending().find((t) => t.kind === 'timeout');
fire(retry);
await settle();
assert.equal(sent.length, 2, 'the next attempt runs once the round-trip is done');
mgr._resetReconnectState();
}
// ── a restart gathers candidates on a budget that fits the round-trip ─────
{
const budget = TIMEOUTS.ICE_RESTART_GATHERING;
assert.ok(budget < TIMEOUTS.ICE_GATHERING_TIMEOUT, 'recovery must gather faster than a fresh handshake');
assert.ok(budget * 2 < TIMEOUTS.ICE_RESTART_TIMEOUT, 'both gathering legs must fit inside the round-trip budget');
const { mgr } = makeManager({ isInitiator: true });
const budgets = [];
mgr.waitForIceGathering = async (ms) => { budgets.push(ms); return true; };
mgr._reconnect.startedAt = Date.now();
await mgr._attemptIceRestart();
assert.deepEqual(budgets, [budget], 'the restart must pass the short budget');
mgr._resetReconnectState();
}
// ── MITM guard: a restart must never re-point the session at a new identity
{
// Same fingerprint as the live session → accepted, answer goes back.
const { mgr, sent } = makeManager({ isInitiator: false });
await mgr._handleIceRestartSignal(T.ICE_RESTART_OFFER, { sdp: sdpWith(FP_A) });
assert.deepEqual(mgr._applied, ['offer']);
assert.equal(sent.at(-1).type, T.ICE_RESTART_ANSWER);
mgr._resetReconnectState();
// Different fingerprint → refused before the peer connection is touched.
const { mgr: attacked, sent: attackedSent } = makeManager({ isInitiator: false });
await assert.rejects(
() => attacked._handleIceRestartSignal(T.ICE_RESTART_OFFER, { sdp: sdpWith(FP_B) }),
/mismatch/i,
'a restart offer with a different DTLS fingerprint must be refused'
);
assert.deepEqual(attacked._applied, [], 'nothing may be applied to the peer connection');
assert.deepEqual(attackedSent, [], 'no answer may be sent to an unverified identity');
attacked._resetReconnectState();
// Same guard on the answer leg.
const { mgr: offerer } = makeManager({ isInitiator: true });
offerer.peerConnection.signalingState = 'have-local-offer';
await assert.rejects(
() => offerer._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_B) }),
/mismatch/i,
'a restart answer with a different DTLS fingerprint must be refused'
);
assert.deepEqual(offerer._applied, []);
offerer._resetReconnectState();
// With no live session to compare against, recovery refuses rather than
// accepting an unverifiable identity.
const { mgr: blind } = makeManager({ isInitiator: false });
blind.peerConnection.currentRemoteDescription = null;
blind.peerConnection.remoteDescription = null;
await assert.rejects(
() => blind._handleIceRestartSignal(T.ICE_RESTART_OFFER, { sdp: sdpWith(FP_A) }),
/identity/i,
'no baseline fingerprint must mean refusal, not blind trust'
);
blind._resetReconnectState();
}
// ── a restart request is only honoured by the offerer ───────────────────
{
const { mgr, sent } = makeManager({ isInitiator: false });
await mgr._handleIceRestartSignal(T.ICE_RESTART_REQUEST, {});
assert.deepEqual(sent, [], 'the answerer must ignore a restart request');
mgr._resetReconnectState();
const { mgr: offerer, sent: offererSent } = makeManager({ isInitiator: true });
await offerer._handleIceRestartSignal(T.ICE_RESTART_REQUEST, {});
assert.equal(offererSent[0].type, T.ICE_RESTART_OFFER);
offerer._resetReconnectState();
}
// ── recovery completes: the same session resumes, the queue drains ───────
{
dispatched.length = 0;
const { mgr, statuses } = makeManager();
mgr._onPathDegraded('ice_disconnected');
statuses.length = 0;
mgr.peerConnection.connectionState = 'connected';
mgr._onPathRecovered();
assert.deepEqual(statuses, ['connected']);
assert.equal(mgr._queueFlushed, true, 'messages sent into the dead path must go out');
assert.equal(mgr.isReconnecting(), false);
assert.equal(mgr._reconnect.attempts, 0, 'attempt counter resets for the next drop');
assert.ok(dispatched.includes('connection-recovered'));
// Recovering from an idle state is a no-op — no spurious 'connected'.
const { mgr: idle, statuses: idleStatuses } = makeManager();
idle._onPathRecovered();
assert.deepEqual(idleStatuses, [], 'no status churn when nothing was broken');
}
// ── recovery stops when the channel cannot carry the renegotiation ───────
// Every route out of a broken path runs over the data channel. If the peer
// has been completely silent since recovery began, it carries nothing in
// either direction and no further attempt can succeed. This matters most for
// the answerer, which cannot renegotiate on its own and would otherwise sit
// out the whole deadline sending requests nobody can receive — observed as a
// long run of attempts ending in a plain timeout.
{
for (const role of [true, false]) {
const { mgr, statuses, ui } = makeManager({ isInitiator: role });
mgr._reconnect.startedAt = Date.now() - 60_000;
mgr._lastInboundAt = Date.now() - 60_000;
mgr._reconnect.attempts = 3;
await mgr._attemptIceRestart();
assert.equal(mgr._reconnect.phase, 'exhausted',
`a silent channel must end recovery (${role ? 'offerer' : 'answerer'})`);
assert.equal(statuses.at(-1), 'recovery_failed',
'the UI must be told to tear the conversation down, not merely show a drop');
assert.match(ui[0], /wiped/i, 'and the user must be told the chat is being closed');
}
// A peer that is still answering keeps recovery alive.
const { mgr: alive, sent } = makeManager();
alive._reconnect.startedAt = Date.now() - 60_000;
alive._lastInboundAt = Date.now(); // heard from just now
alive._reconnect.attempts = 3;
await alive._attemptIceRestart();
assert.notEqual(alive._reconnect.phase, 'exhausted', 'a responsive peer must not be abandoned');
assert.equal(sent.length, 1, 'and the attempt goes out');
alive._resetReconnectState();
// Early attempts are never cut short on silence alone — a restart
// round-trip has to be given a chance to produce its first reply.
const { mgr: early } = makeManager();
early._reconnect.startedAt = Date.now() - 60_000;
early._lastInboundAt = Date.now() - 60_000;
early._reconnect.attempts = 0;
await early._attemptIceRestart();
assert.notEqual(early._reconnect.phase, 'exhausted', 'the first attempt must still be tried');
early._resetReconnectState();
}
// ── an ICE agent that cannot gather at all is not worth retrying ─────────
// After a network change a PeerConnection is often left bound to interfaces
// that no longer exist: every STUN and TURN request times out and each
// restart fails with zero candidate pairs. restartIce() cannot rebind it, so
// spending the whole two-minute deadline on it is two minutes of the user
// watching nothing happen when the outcome was already decided.
{
const { mgr, statuses, ui } = makeManager();
mgr._noteIceFailureDiagnostics = P._noteIceFailureDiagnostics;
mgr._reconnect.phase = 'restarting';
mgr._reconnect.startedAt = Date.now();
// A failure that still produced pairs is an ordinary flaky path.
mgr._noteIceFailureDiagnostics({ pairCount: 3 });
assert.equal(mgr._reconnect.barrenFailures, 0);
assert.notEqual(statuses.at(-1), 'disconnected');
// Barren failures accumulate; one alone is not enough to conclude.
mgr._noteIceFailureDiagnostics({ pairCount: 0 });
assert.equal(mgr.isReconnecting(), true, 'one barren failure must not end recovery');
// A pair appearing resets the count — the agent is evidently alive.
mgr._noteIceFailureDiagnostics({ pairCount: 1 });
assert.equal(mgr._reconnect.barrenFailures, 0, 'evidence of life resets the count');
mgr._noteIceFailureDiagnostics({ pairCount: 0 });
mgr._noteIceFailureDiagnostics({ pairCount: 0 });
assert.equal(mgr._reconnect.phase, 'exhausted', 'a barren agent ends recovery promptly');
assert.equal(statuses.at(-1), 'recovery_failed');
assert.equal(ui.length, 1, 'and the user is told once, with the way out');
}
// ── a closed data channel cannot be repaired by an ICE restart ───────────
// SCTP goes with it, so there is nothing left to carry the renegotiation.
{
const { mgr, statuses, ui } = makeManager();
mgr.dataChannel = { readyState: 'closed' };
mgr._reconnect.startedAt = Date.now();
await mgr._attemptIceRestart();
assert.equal(mgr._reconnect.phase, 'exhausted');
assert.equal(statuses.at(-1), 'recovery_failed');
assert.equal(mgr._verificationCleared, true, 'an unrecoverable session clears verification');
assert.equal(ui.length, 1);
assert.match(ui[0], /wiped/i, 'the user is told the chat is being closed and wiped');
assert.deepEqual(pending().filter((t) => t.kind === 'timeout'), [], 'giving up leaves no timers behind');
}
// ── a device with no network holds the session instead of losing it ──────
// A five-minute tunnel must not cost the user their session: no restart can
// succeed with the radio off, so the give-up deadline is held open.
{
// Node exposes navigator as a getter-only global, so swap the descriptor.
const realNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
const fakeNavigator = { onLine: false };
Object.defineProperty(globalThis, 'navigator', { value: fakeNavigator, configurable: true });
try {
const { mgr, sent, statuses } = makeManager();
mgr._reconnect.startedAt = Date.now() - (TIMEOUTS.RECONNECT_MAX_DURATION + 60_000);
await mgr._attemptIceRestart();
assert.equal(mgr._reconnect.phase, 'waiting', 'recovery waits rather than giving up');
assert.notEqual(statuses.at(-1), 'disconnected', 'an offline device must not end the session');
assert.deepEqual(sent, [], 'nothing is transmitted with no network');
// The hold must schedule a real retry, not a 0 ms spin.
const retry = pending().find((t) => t.kind === 'timeout');
assert.ok(retry && retry.delay > 0, 'the offline hold must schedule a sane retry delay');
// Network comes back → retry immediately, deadline starts fresh.
fakeNavigator.onLine = true;
await mgr._attemptIceRestart();
assert.equal(sent.at(-1).type, T.ICE_RESTART_OFFER, 'recovery resumes once the radio is back');
mgr._resetReconnectState();
} finally {
if (realNavigator) Object.defineProperty(globalThis, 'navigator', realNavigator);
else delete globalThis.navigator;
}
}
// ── giving up is bounded by the overall deadline ─────────────────────────
{
const { mgr, statuses } = makeManager();
mgr._reconnect.startedAt = Date.now() - (TIMEOUTS.RECONNECT_MAX_DURATION + 1000);
await mgr._attemptIceRestart();
assert.equal(mgr._reconnect.phase, 'exhausted');
assert.equal(statuses.at(-1), 'recovery_failed');
}
// ── manual retry restarts the cycle with a fresh deadline ───────────────
{
const { mgr, sent } = makeManager();
mgr.reconnectionFailedNotificationSent = true;
mgr._reconnect.phase = 'exhausted';
const ok = P.attemptReconnection.call(mgr);
await settle();
assert.equal(ok, true);
assert.equal(mgr.reconnectionFailedNotificationSent, false, 'the user can be told again if it fails again');
assert.equal(sent.at(-1).type, T.ICE_RESTART_OFFER);
mgr._resetReconnectState();
// Nothing to restart over: report failure instead of pretending.
const { mgr: gone, ui } = makeManager();
gone.dataChannel = { readyState: 'closed' };
assert.equal(P.attemptReconnection.call(gone), false);
assert.match(ui[0], /new connection is required/i);
}
// ── backoff is bounded and monotonic ─────────────────────────────────────
{
const backoff = EnhancedSecureWebRTCManager.RECONNECT_BACKOFF;
assert.ok(backoff.length > 0);
for (let i = 1; i < backoff.length; i += 1) {
assert.ok(backoff[i] >= backoff[i - 1], 'backoff must not shrink');
}
assert.ok(Object.isFrozen(backoff));
}
// ── recovery frames never surface as chat messages ───────────────────────
{
const manager = {
_debugMode: false,
_secureLog() {},
getSecurityStatus: () => ({ activeFeaturesCount: 0 }),
_checkInboundRateLimit: () => true
};
for (const type of [T.ICE_RESTART_OFFER, T.ICE_RESTART_ANSWER, T.ICE_RESTART_REQUEST]) {
const result = await P.removeSecurityLayers.call(manager, JSON.stringify({ type, sdp: 'v=0' }));
assert.equal(result, 'SYSTEM_MESSAGE_FILTERED', `${type} must not reach the chat log`);
}
}
console.log('session-recovery.test.mjs: all assertions passed');
} finally {
Object.assign(globalThis, realTimers);
}