Files

98 lines
4.3 KiB
JavaScript
Raw Permalink Normal View History

// One leg of a group call must never sit there ringing.
//
// A group call is N-1 ordinary 1:1 calls, and each of them is answered without
// prompting because the user already consented once, by joining. That answer is
// decided when the offer LANDS — so an offer that arrives before this session
// has been told it is a call leg falls through the check and rings instead.
//
// The window is real. A member who joins can place their offers immediately,
// and those offers can overtake the group frame announcing that they joined. The
// symptom was asymmetric and confusing: one member's tile read "connecting" for
// the whole call while the other member's browser was quietly ringing for a call
// they had already agreed to be in.
//
// The ordering that opens the window is fixed elsewhere (the app announces
// before it connects). This covers the safety net underneath it: being told,
// late, that a session is a call leg has to pick up whatever is already ringing.
import assert from 'node:assert/strict';
globalThis.window = { EnhancedSecureCryptoUtils: { secureLog: { log() {} } } };
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
/** A manager with only the call-state machinery a leg touches. */
function callLeg(phase, { pending = null } = {}) {
const leg = Object.create(EnhancedSecureWebRTCManager.prototype);
leg.callState = {
active: phase !== 'idle', phase, withVideo: false, micEnabled: true,
cameraEnabled: false, remoteHasVideo: false, callId: 'c1', quality: null,
groupCallId: null, error: null,
};
leg._callGroupContext = null;
leg._callStateListeners = new Set();
leg._pendingCallOffer = pending;
leg.onCallStateChanged = null;
leg.accepted = 0;
leg.acceptCall = async function () { this.accepted += 1; };
leg._startAdaptation = () => {};
leg._stopAdaptation = () => {};
leg._secureLog = () => {};
return leg;
}
const offer = { sdp: 'v=0', callId: 'c1', withVideo: false };
// ── an offer that was already ringing is answered when the leg is claimed ────
{
const leg = callLeg('incoming', { pending: offer });
leg.setCallGroupContext('group-call-1');
await new Promise((r) => setTimeout(r, 0));
assert.equal(leg.accepted, 1, 'a ringing group leg must be answered as soon as it is claimed');
assert.equal(leg.getCallState().groupCallId, 'group-call-1',
'and the state must say which group call it belongs to, so the 1:1 UI stays out of the way');
}
// ── an idle session is left alone: there is nothing to answer ────────────────
{
const leg = callLeg('idle');
leg.setCallGroupContext('group-call-1');
await new Promise((r) => setTimeout(r, 0));
assert.equal(leg.accepted, 0, 'claiming an idle session must not invent a call');
}
// ── a call already up is not answered a second time ─────────────────────────
{
const leg = callLeg('active', { pending: offer });
leg.setCallGroupContext('group-call-1');
await new Promise((r) => setTimeout(r, 0));
assert.equal(leg.accepted, 0, 'a live call must not be re-answered');
}
// ── THE guard: releasing a leg must never auto-answer anything ───────────────
//
// This is the part that would be a security bug rather than a display one. The
// flag is what allows a microphone to open without asking, so clearing it — which
// is what leaving a call does — must not be a path to opening one.
{
const leg = callLeg('incoming', { pending: offer });
leg._callGroupContext = 'group-call-1';
leg.setCallGroupContext(null);
await new Promise((r) => setTimeout(r, 0));
assert.equal(leg.accepted, 0, 'clearing the group context must never answer a call');
assert.equal(leg.getCallState().groupCallId, null);
}
// ── and a session that is no longer a leg rings normally again ──────────────
{
const leg = callLeg('idle');
leg.setCallGroupContext('group-call-1');
leg.setCallGroupContext(null);
assert.equal(leg._callGroupContext, null,
'a cleared context must not linger — it is what lets a call open a microphone unasked');
}
console.log('group-call-autoanswer: ok');