Files
OwnCord/Client/tauri-client/tests/unit/livekit-session.test.ts
T
J3vbandClaude Opus 4.8 ef1aca8a67 fix(e2ee): pin the verified identity key on re-pin, rekey on keyed-peer leave
Multi-agent F3 security review surfaced two voice-E2EE defects:

- Re-pin TOCTOU (voice-E2EE MITM): the identity-mismatch modal showed a
  fingerprint from one membersStore read, but rePinPeerIdentity re-read the
  server-writable store to decide what to pin. A malicious server (F3's threat
  model) could swap in an attacker key via a user_update during the human
  out-of-band verification window and have it pinned, silently defeating the
  mismatch prompt. rePinPeerIdentity now takes the exact verified key as a
  parameter; ChannelSidebar passes the bytes whose fingerprint it displayed.

- Membership forward secrecy: the key holder rotated the room key only when the
  holder ROLE transferred, so a departed non-key-holder kept a valid room key
  until the next periodic (<=5 min) rotation. The holder now also rotates when a
  peer that held the key leaves (reusing rotateKeyPeriodically), gated on the
  leaver having actually held a key.

Client gates green: typecheck, lint (0 errors), prettier, vitest (3361).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 08:55:07 +02:00

2347 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// --- Mocks must be declared before imports ---
const mockVoiceState = vi.hoisted(() => ({
localMuted: false,
localDeafened: false,
}));
const mockRoom = vi.hoisted(() => ({
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
on: vi.fn().mockReturnThis(),
removeAllListeners: vi.fn(),
localParticipant: {
setMicrophoneEnabled: vi.fn().mockResolvedValue(undefined),
setCameraEnabled: vi.fn().mockResolvedValue(undefined),
getTrackPublication: vi.fn().mockReturnValue(undefined),
unpublishTrack: vi.fn().mockResolvedValue(undefined),
trackPublications: new Map(),
identity: "user-1",
},
remoteParticipants: new Map(),
switchActiveDevice: vi.fn().mockResolvedValue(undefined),
startAudio: vi.fn().mockResolvedValue(undefined),
canPlaybackAudio: true,
state: "connected" as string,
name: "test-room",
}));
vi.mock("livekit-client", () => ({
Room: vi.fn(() => mockRoom),
RoomEvent: {
TrackSubscribed: "trackSubscribed",
TrackUnsubscribed: "trackUnsubscribed",
Disconnected: "disconnected",
ActiveSpeakersChanged: "activeSpeakersChanged",
AudioPlaybackStatusChanged: "audioPlaybackStatusChanged",
LocalTrackPublished: "localTrackPublished",
},
Track: {
Source: {
Microphone: "microphone",
Camera: "camera",
ScreenShare: "screenShare",
ScreenShareAudio: "screenShareAudio",
},
Kind: { Audio: "audio", Video: "video" },
},
VideoPresets: {
h360: { resolution: { width: 640, height: 360 } },
h720: { resolution: { width: 1280, height: 720 } },
h1080: { resolution: { width: 1920, height: 1080 } },
},
ScreenSharePresets: {
h720fps5: { resolution: { width: 1280, height: 720 } },
h1080fps15: { resolution: { width: 1920, height: 1080 } },
h1080fps30: { resolution: { width: 1920, height: 1080 } },
},
DisconnectReason: { CLIENT_INITIATED: 0 },
ExternalE2EEKeyProvider: vi.fn(() => ({
setKey: vi.fn(),
getKeys: vi.fn().mockReturnValue([]),
})),
createLocalVideoTrack: vi.fn(async () => ({
kind: "video",
mediaStreamTrack: new MediaStreamTrack(),
})),
createLocalScreenTracks: vi.fn(async () => [
{ kind: "video", mediaStreamTrack: new MediaStreamTrack() },
]),
}));
vi.mock("@stores/voice.store", () => ({
voiceStore: {
getState: vi.fn(() => mockVoiceState),
get: vi.fn(() => ({})),
set: vi.fn(),
subscribe: vi.fn(),
},
setLocalMuted: vi.fn(),
setLocalDeafened: vi.fn(),
setLocalCamera: vi.fn(),
setLocalScreenshare: vi.fn(),
setSpeakers: vi.fn(),
leaveVoiceChannel: vi.fn(),
setListenOnly: vi.fn(),
setVoiceStatus: vi.fn(),
setPeerVerification: vi.fn(),
clearPeerVerification: vi.fn(),
clearPeerVerifications: vi.fn(),
}));
const mockInvoke = vi.hoisted(() =>
vi.fn((cmd: string, _payload?: unknown) => {
if (cmd === "start_livekit_proxy") return Promise.resolve(7881);
if (cmd === "stop_livekit_proxy") return Promise.resolve();
return Promise.resolve();
}),
);
vi.mock("@tauri-apps/api/core", () => ({
invoke: (cmd: string, payload?: unknown) => mockInvoke(cmd, payload),
}));
const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({
mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal),
mockSavePref: vi.fn(),
}));
vi.mock("@components/settings/helpers", () => ({
loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal),
savePref: (key: string, val: unknown) => mockSavePref(key, val),
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("@lib/noise-suppression", () => ({
createRNNoiseProcessor: vi.fn(),
}));
const mockKeyPair = vi.hoisted(() => ({
publicKey: { type: "public" } as unknown as CryptoKey,
privateKey: { type: "private" } as unknown as CryptoKey,
}));
const mockIdentityKeyPair = vi.hoisted(() => ({
publicKey: { type: "id-public" } as unknown as CryptoKey,
privateKey: { type: "id-private" } as unknown as CryptoKey,
}));
vi.mock("@lib/e2eeCrypto", () => ({
generateECDHKeyPair: vi.fn(async () => mockKeyPair),
exportPublicKey: vi.fn(async () => "bW9ja2VwaGVtZXJhbA=="),
importPublicKey: vi.fn(async () => ({ type: "public" }) as unknown as CryptoKey),
generateRoomKey: vi.fn(() => new Uint8Array(32)),
roomKeyToBase64: vi.fn(() => "mock-room-key-base64"),
wrapRoomKey: vi.fn(async () => ({ encryptedKey: "enc", iv: "iv" })),
unwrapRoomKey: vi.fn(async () => new Uint8Array(32)),
// F3 TOFU identity signing/verification
signEphemeralKey: vi.fn(async () => "mock-signature"),
verifyEphemeralKeySignature: vi.fn(async () => true),
importIdentityPublicKey: vi.fn(
async () => ({ type: "id-public-imported" }) as unknown as CryptoKey,
),
computeKeyFingerprint: vi.fn(async () => "AB12 CD34 EF56 7890"),
}));
// F3 TOFU: identity keyring + peer pin store (Tauri-backed; mocked here).
vi.mock("@lib/identity", () => ({
getOrCreateIdentityKeyPair: vi.fn(async () => mockIdentityKeyPair),
getIdentityPin: vi.fn(async () => null),
storeIdentityPin: vi.fn(async () => true),
}));
// Stub Worker for E2EE web worker (not available in Node/vitest)
globalThis.Worker = vi.fn() as unknown as typeof Worker;
// Now import
import { parseUserId, LiveKitSession, getRoomForStats } from "../../src/lib/livekitSession";
import {
setLocalMuted,
setLocalDeafened,
setLocalCamera,
setLocalScreenshare,
setListenOnly,
leaveVoiceChannel,
setVoiceStatus,
setPeerVerification,
clearPeerVerifications,
} from "@stores/voice.store";
import { getIdentityPin, storeIdentityPin } from "@lib/identity";
import { verifyEphemeralKeySignature } from "@lib/e2eeCrypto";
import { setMembers } from "@stores/members.store";
import type { ReadyMember } from "../../src/lib/types";
import {
isVoiceConnected,
leaveVoice as boundLeaveVoice,
setMuted as boundSetMuted,
setDeafened as boundSetDeafened,
cleanupAll as boundCleanupAll,
} from "../../src/lib/livekitSession";
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("getRoomForStats (pre-refactor lock)", () => {
it("returns null when no session is active", () => {
expect(getRoomForStats()).toBeNull();
});
});
describe("parseUserId", () => {
it("parses a valid user identity", () => {
expect(parseUserId("user-42")).toBe(42);
});
it("parses user-0", () => {
expect(parseUserId("user-0")).toBe(0);
});
it("parses large user IDs", () => {
expect(parseUserId("user-999999")).toBe(999999);
});
it("returns 0 for empty string", () => {
expect(parseUserId("")).toBe(0);
});
it("returns 0 for missing prefix", () => {
expect(parseUserId("42")).toBe(0);
});
it("returns 0 for wrong prefix", () => {
expect(parseUserId("bot-42")).toBe(0);
});
it("returns 0 for non-numeric suffix", () => {
expect(parseUserId("user-abc")).toBe(0);
});
it("returns 0 for partial match with trailing characters", () => {
expect(parseUserId("user-42-extra")).toBe(0);
});
it("returns 0 for user- with no number", () => {
expect(parseUserId("user-")).toBe(0);
});
it("returns 0 for negative numbers", () => {
expect(parseUserId("user--1")).toBe(0);
});
it("returns 0 for floating point numbers", () => {
expect(parseUserId("user-3.14")).toBe(0);
});
it("parses single digit user IDs", () => {
expect(parseUserId("user-1")).toBe(1);
});
it("parses identity with voiceJoinToken suffix", () => {
expect(parseUserId("user-42:abc123def")).toBe(42);
});
it("parses identity with long token suffix", () => {
expect(parseUserId("user-999:a1b2c3d4-e5f6-7890-abcd-ef1234567890")).toBe(999);
});
it("returns 0 for colon with no token", () => {
expect(parseUserId("user-:token")).toBe(0);
});
});
describe("LiveKitSession", () => {
let session: LiveKitSession;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
mockVoiceState.localMuted = false;
mockVoiceState.localDeafened = false;
session = new LiveKitSession();
// Reset mockRoom state
mockRoom.state = "connected";
mockRoom.remoteParticipants = new Map();
mockRoom.localParticipant.getTrackPublication.mockReturnValue(undefined);
mockRoom.localParticipant.trackPublications = new Map();
mockRoom.connect.mockResolvedValue(undefined);
mockRoom.localParticipant.setMicrophoneEnabled.mockResolvedValue(undefined);
});
afterEach(() => {
session.cleanupAll();
vi.useRealTimers();
});
describe("setters and getters", () => {
it("setWsClient stores the client used by leaveVoice", () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
session.leaveVoice(true);
expect(mockWs.send).toHaveBeenCalledWith({ type: "voice_leave", payload: {} });
});
it("setServerHost stores the host and session remains functional", () => {
session.setServerHost("myhost:9443");
// Overwriting with a new host should succeed
session.setServerHost("another:8080");
// Verify the session is still in a valid disconnected state after setting host
expect(isVoiceConnected()).toBe(false);
// leaveVoice should still work (no room to disconnect from)
session.leaveVoice(false);
expect(setLocalCamera).toHaveBeenCalledWith(false);
});
it("setOnError stores callback and clearOnError removes it", () => {
const cb = vi.fn();
session.setOnError(cb);
// Callback should not be invoked by the setter itself
expect(cb).not.toHaveBeenCalled();
session.clearOnError();
// After clear, leaveVoice (which touches error paths) should not invoke cb
session.leaveVoice(false);
expect(cb).not.toHaveBeenCalled();
// Verify the session is still usable after clearing error callback
expect(isVoiceConnected()).toBe(false);
});
it("setOnRemoteVideo stores callbacks and clearOnRemoteVideo removes them", () => {
const videoCb = vi.fn();
const removedCb = vi.fn();
session.setOnRemoteVideo(videoCb);
session.setOnRemoteVideoRemoved(removedCb);
session.clearOnRemoteVideo();
// After clear, leaving voice (which cleans up tracks) should not invoke old callbacks
session.leaveVoice(false);
expect(videoCb).not.toHaveBeenCalled();
expect(removedCb).not.toHaveBeenCalled();
// Verify the session state is consistent after clearing callbacks
expect(isVoiceConnected()).toBe(false);
});
});
describe("leaveVoice", () => {
it("sends voice_leave when sendWs is true and ws is set", () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
session.leaveVoice(true);
expect(mockWs.send).toHaveBeenCalledWith({ type: "voice_leave", payload: {} });
});
it("does not send voice_leave when sendWs is false", () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
session.leaveVoice(false);
expect(mockWs.send).not.toHaveBeenCalled();
});
it("calls setLocalCamera(false)", () => {
session.leaveVoice(false);
expect(setLocalCamera).toHaveBeenCalledWith(false);
});
it("calls setLocalScreenshare(false)", () => {
session.leaveVoice(false);
expect(setLocalScreenshare).toHaveBeenCalledWith(false);
});
});
describe("cleanupAll", () => {
it("resets session to disconnected state after cleanup", () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
session.setServerHost("localhost:8080");
session.setOnError(vi.fn());
session.setOnRemoteVideo(vi.fn());
session.setOnRemoteVideoRemoved(vi.fn());
session.cleanupAll();
// After cleanup, voice should be disconnected
expect(isVoiceConnected()).toBe(false);
// Camera and screenshare state should be reset
expect(setLocalCamera).toHaveBeenCalledWith(false);
expect(setLocalScreenshare).toHaveBeenCalledWith(false);
});
});
describe("setMuted", () => {
it("calls setLocalMuted with the given value", () => {
session.setMuted(true);
expect(setLocalMuted).toHaveBeenCalledWith(true);
});
it("calls setLocalMuted(false) when unmuting", () => {
session.setMuted(false);
expect(setLocalMuted).toHaveBeenCalledWith(false);
});
});
describe("setDeafened", () => {
it("calls setLocalDeafened with the given value", () => {
session.setDeafened(true);
expect(setLocalDeafened).toHaveBeenCalledWith(true);
});
it("calls setLocalDeafened(false) when undeafening", () => {
session.setDeafened(false);
expect(setLocalDeafened).toHaveBeenCalledWith(false);
});
});
describe("enableCamera", () => {
it("shows error when no active voice session", async () => {
const errorCb = vi.fn();
session.setOnError(errorCb);
await session.enableCamera();
expect(errorCb).toHaveBeenCalledWith("Join a voice channel first");
});
it("calls setLocalCamera(false) when no room or ws", async () => {
await session.enableCamera();
// setLocalCamera should not have been called with true (no ws)
// Actually it warns and returns early
expect(setLocalCamera).not.toHaveBeenCalledWith(true);
});
});
describe("disableCamera", () => {
it("calls setLocalCamera(false) even without a room", async () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
await session.disableCamera();
expect(setLocalCamera).toHaveBeenCalledWith(false);
});
it("sends voice_camera disabled message when ws is set", async () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
await session.disableCamera();
expect(mockWs.send).toHaveBeenCalledWith({
type: "voice_camera",
payload: { enabled: false },
});
});
});
describe("switchInputDevice", () => {
it("does nothing when no active room", async () => {
// Should not throw
await session.switchInputDevice("device-1");
});
});
describe("switchOutputDevice", () => {
it("does nothing when no active room", async () => {
await session.switchOutputDevice("device-1");
});
});
describe("setUserVolume", () => {
it("saves clamped volume to preferences", () => {
session.setUserVolume(42, 150);
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 150);
});
it("clamps volume to 0-200 range", () => {
session.setUserVolume(42, -10);
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 0);
session.setUserVolume(42, 300);
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 200);
});
});
describe("getUserVolume", () => {
it("returns default volume of 100", () => {
expect(session.getUserVolume(42)).toBe(100);
});
});
describe("setInputVolume", () => {
it("saves clamped input volume to preferences", () => {
session.setInputVolume(150);
expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 150);
});
it("clamps to 0-200 range", () => {
session.setInputVolume(-50);
expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0);
session.setInputVolume(999);
expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200);
});
});
describe("setOutputVolume", () => {
it("saves clamped output volume to preferences", () => {
session.setOutputVolume(80);
expect(mockSavePref).toHaveBeenCalledWith("outputVolume", 80);
});
it("clamps to 0-200 range", () => {
session.setOutputVolume(-10);
expect(mockSavePref).toHaveBeenCalledWith("outputVolume", 0);
});
it("updates existing screenshare audio elements when master output changes", () => {
const screenshareAudio = document.createElement("audio");
(session as any)._audioElements.screenshareAudioElements = new Map([
[42, new Set([screenshareAudio])],
]);
session.setOutputVolume(80);
expect(screenshareAudio.volume).toBe(0.8);
});
it("clamps existing screenshare audio elements to the browser volume range", () => {
const screenshareAudio = document.createElement("audio");
(session as any)._audioElements.screenshareAudioElements = new Map([
[42, new Set([screenshareAudio])],
]);
session.setOutputVolume(150);
expect(screenshareAudio.volume).toBe(1);
});
});
describe("setVoiceSensitivity", () => {
it("does not throw (no-op, handled by LiveKit VAD)", () => {
expect(() => session.setVoiceSensitivity(50)).not.toThrow();
});
});
describe("getLocalCameraStream", () => {
it("returns null when no room", () => {
expect(session.getLocalCameraStream()).toBeNull();
});
});
describe("getSessionDebugInfo", () => {
it("returns basic info when no room is active", () => {
const info = session.getSessionDebugInfo();
expect(info.hasRoom).toBe(false);
expect(info.hasRNNoiseProcessor).toBe(false);
expect(info.currentChannelId).toBeNull();
});
});
describe("handleVoiceToken", () => {
it("connects to LiveKit and sets up voice session", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
expect(mockRoom.connect).toHaveBeenCalledWith("ws://localhost:7880", "test-token");
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
});
it("uses proxy URL for non-local hosts", async () => {
session.setServerHost("example.com:443");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("test-token", "/livekit", 1, undefined, true);
expect(mockInvoke).toHaveBeenCalledWith("start_livekit_proxy", {
remoteHost: "example.com:443",
});
expect(mockRoom.connect).toHaveBeenCalledWith("ws://127.0.0.1:7881/livekit", "test-token");
});
it("handles mic permission denied gracefully", async () => {
const errorCb = vi.fn();
session.setOnError(errorCb);
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
const domErr = new DOMException("Permission denied", "NotAllowedError");
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(domErr);
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
expect(errorCb).toHaveBeenCalledWith(
"Microphone permission denied — joined in listen-only mode",
);
});
it("handles mic not found gracefully", async () => {
const errorCb = vi.fn();
session.setOnError(errorCb);
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
const domErr = new DOMException("No device", "NotFoundError");
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(domErr);
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
expect(errorCb).toHaveBeenCalledWith("No microphone found — joined in listen-only mode");
});
it("handles generic mic error gracefully", async () => {
const errorCb = vi.fn();
session.setOnError(errorCb);
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(new Error("unknown"));
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
expect(errorCb).toHaveBeenCalledWith("Microphone unavailable — joined in listen-only mode");
});
it("handles connection failure", async () => {
const errorCb = vi.fn();
session.setOnError(errorCb);
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
mockRoom.connect.mockRejectedValue(new Error("connection refused"));
// handleVoiceToken has retry logic with setTimeout delays.
// We need to advance fake timers to let the retries proceed.
const tokenPromise = session.handleVoiceToken(
"test-token",
"/livekit",
1,
"ws://localhost:7880",
true,
);
// Advance through all retry delays (3 retries x 2000ms each)
for (let i = 0; i < 3; i++) {
await vi.advanceTimersByTimeAsync(2100);
}
await tokenPromise;
expect(errorCb).toHaveBeenCalledWith("Failed to join voice — connection error");
});
it("queues the latest join request that arrives while connecting", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
const firstConnect = createDeferred<void>();
mockRoom.connect
.mockImplementationOnce(() => firstConnect.promise)
.mockResolvedValueOnce(undefined);
const firstJoin = session.handleVoiceToken(
"first-token",
"/livekit-one",
1,
"ws://localhost:7881",
true,
);
// Flush microtasks so E2EE async steps resolve before connect
await vi.advanceTimersByTimeAsync(0);
await session.handleVoiceToken(
"second-token",
"/livekit-two",
2,
"ws://localhost:7882",
true,
);
expect(mockRoom.connect).toHaveBeenCalledTimes(1);
firstConnect.resolve(undefined);
await firstJoin;
expect(mockRoom.connect).toHaveBeenCalledTimes(2);
expect(mockRoom.connect).toHaveBeenNthCalledWith(1, "ws://localhost:7881", "first-token");
expect(mockRoom.connect).toHaveBeenNthCalledWith(2, "ws://localhost:7882", "second-token");
expect(mockRoom.startAudio).toHaveBeenCalledTimes(1);
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledTimes(1);
});
});
describe("voiceStatus transitions (voice-and-e2ee.md §12)", () => {
function statusCalls(): string[] {
return (setVoiceStatus as any).mock.calls.map((c: unknown[]) => c[0] as string);
}
it("writes joining → securing → connected on a successful join", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
(setVoiceStatus as any).mockClear();
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
const calls = statusCalls();
expect(calls).toContain("joining");
expect(calls).toContain("securing");
expect(calls).toContain("connected");
// Ordering: joining before securing before connected.
expect(calls.indexOf("joining")).toBeLessThan(calls.indexOf("securing"));
expect(calls.indexOf("securing")).toBeLessThan(calls.indexOf("connected"));
});
it("writes idle on leaveVoice", () => {
(setVoiceStatus as any).mockClear();
session.leaveVoice(false);
expect(setVoiceStatus).toHaveBeenCalledWith("idle");
});
it("writes reconnecting when the room drops unexpectedly", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
// Capture the Disconnected handler registered during room creation.
let disconnectedHandler: ((reason?: number) => void) | undefined;
mockRoom.on.mockImplementation((event: string, handler: any) => {
if (event === "disconnected") disconnectedHandler = handler;
return mockRoom;
});
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
expect(disconnectedHandler).toBeDefined();
expect((session as any)._state.type).toBe("connected");
// Isolate the write triggered purely by the unexpected room drop.
(setVoiceStatus as any).mockClear();
// Fire an unexpected disconnect (non-CLIENT_INITIATED) — this is the primary
// reconnecting write, from setReconnectAc via handleDisconnected.
disconnectedHandler!(/* SERVER_SHUTDOWN */ 1);
expect((session as any)._state.type).toBe("reconnecting");
expect(setVoiceStatus).toHaveBeenCalledWith("reconnecting");
});
it("writes connected after a successful auto-reconnect", async () => {
(session as any)._state = {
type: "reconnecting",
channelId: 7,
latestToken: "reconnect-token",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
ac: new AbortController(),
};
(setVoiceStatus as any).mockClear();
const ac = new AbortController();
const reconnectPromise = (session as any).attemptAutoReconnect(
"reconnect-token",
"/livekit",
7,
"ws://localhost:7880",
ac.signal,
);
await vi.advanceTimersByTimeAsync(3100);
await reconnectPromise;
expect(setVoiceStatus).toHaveBeenCalledWith("connected");
});
});
describe("handleVoiceTokenRefresh", () => {
it("stores the token and restarts the timer", () => {
session.handleVoiceTokenRefresh("new-token");
// No throw — timer is started internally
});
it("handles undefined token", () => {
expect(() => session.handleVoiceTokenRefresh(undefined)).not.toThrow();
});
});
describe("auto reconnect", () => {
it("preserves local mute state on reconnect", async () => {
mockVoiceState.localMuted = true;
mockVoiceState.localDeafened = false;
(session as any)._state = {
type: "reconnecting",
channelId: 7,
latestToken: "reconnect-token",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
ac: new AbortController(),
};
const ac = new AbortController();
const reconnectPromise = (session as any).attemptAutoReconnect(
"reconnect-token",
"/livekit",
7,
"ws://localhost:7880",
ac.signal,
);
await vi.advanceTimersByTimeAsync(3100);
await reconnectPromise;
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
});
it("re-applies deafened remote subscriptions on reconnect", async () => {
mockVoiceState.localMuted = true;
mockVoiceState.localDeafened = true;
(session as any)._state = {
type: "reconnecting",
channelId: 9,
latestToken: "reconnect-token",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
ac: new AbortController(),
};
const setSubscribed = vi.fn();
mockRoom.remoteParticipants = new Map([
[
"remote-user",
{
audioTrackPublications: new Map([["audio", { setSubscribed }]]),
},
],
]);
const ac = new AbortController();
const reconnectPromise = (session as any).attemptAutoReconnect(
"reconnect-token",
"/livekit",
9,
"ws://localhost:7880",
ac.signal,
);
await vi.advanceTimersByTimeAsync(3100);
await reconnectPromise;
expect(setSubscribed).toHaveBeenCalledWith(false);
});
});
describe("teardownForReconnect video track cleanup (BUG-098)", () => {
it("stops manual camera and screen tracks on unexpected disconnect", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
mockRoom.localParticipant.unpublishTrack = vi.fn();
// Capture the Disconnected handler during room creation
let disconnectedHandler: ((reason?: number) => void) | undefined;
mockRoom.on.mockImplementation((event: string, handler: any) => {
if (event === "disconnected") disconnectedHandler = handler;
return mockRoom;
});
// Connect to create the room and register handlers
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
expect(disconnectedHandler).toBeDefined();
// Inject fake manual tracks as if camera/screen were enabled
const mockCamTrack = { stop: vi.fn(), mediaStreamTrack: { id: "cam" } };
const mockScreenTrack = { stop: vi.fn(), mediaStreamTrack: { id: "screen" } };
(session as any)._cameraState.manualCameraTrack = mockCamTrack;
(session as any)._screenState.manualScreenTracks = [mockScreenTrack];
// Clear mocks so we can assert only the teardown calls
(setLocalCamera as any).mockClear();
(setLocalScreenshare as any).mockClear();
// Fire unexpected disconnect (non-CLIENT_INITIATED triggers reconnect path)
disconnectedHandler!(/* SERVER_SHUTDOWN */ 1);
// Camera track stopped and state reset
expect(mockCamTrack.stop).toHaveBeenCalled();
expect((session as any)._cameraState.manualCameraTrack).toBeNull();
expect(setLocalCamera).toHaveBeenCalledWith(false);
// Screen track stopped and state reset
expect(mockScreenTrack.stop).toHaveBeenCalled();
expect((session as any)._screenState.manualScreenTracks).toEqual([]);
expect(setLocalScreenshare).toHaveBeenCalledWith(false);
});
});
describe("handleDisconnected during initial connect", () => {
it("does not null the room when connecting flag is true", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
// Make connect hang so we can trigger Disconnected mid-connect
const connectDeferred = createDeferred<void>();
mockRoom.connect.mockImplementation(() => connectDeferred.promise);
// Capture the Disconnected handler registered via room.on()
let disconnectedHandler: ((reason?: number) => void) | undefined;
mockRoom.on.mockImplementation((event: string, handler: any) => {
if (event === "disconnected") disconnectedHandler = handler;
return mockRoom;
});
const tokenPromise = session.handleVoiceToken(
"test-token",
"/livekit",
1,
"ws://localhost:7880",
true,
);
await Promise.resolve(); // Let handleVoiceToken reach room.connect()
// Simulate LiveKit emitting Disconnected with JOIN_FAILURE (reason 7)
// while the connect() is still in progress
expect(disconnectedHandler).toBeDefined();
disconnectedHandler!(7);
// The session should NOT have been reset to idle — retry loop is still in control
expect((session as any)._state.type).not.toBe("idle");
// Resolve connect to let the flow complete normally
connectDeferred.resolve(undefined);
await tokenPromise;
});
});
// -----------------------------------------------------------------------
// Screenshare audio controls (Spec 1)
// -----------------------------------------------------------------------
describe("setScreenshareAudioVolume", () => {
it("silently skips when no audio element exists for userId", () => {
// Should return early without error — no element to set volume on
session.setScreenshareAudioVolume(999, 0.5);
// Verify no screenshare state was created for the unknown user
expect(session.getScreenshareAudioMuted(999)).toBe(false);
});
});
describe("screenshare audio subscription", () => {
it("clamps screenshare audio element volume when output is boosted", () => {
session.setOutputVolume(150);
const audioEl = document.createElement("audio");
const track = {
kind: "audio",
sid: "track-1",
detach: vi.fn(() => []),
attach: vi.fn(() => audioEl),
};
const publication = { source: "screenShareAudio" };
const participant = { identity: "user-42" };
expect(() =>
(session as any)._eventHandlers.handleTrackSubscribed(track, publication, participant),
).not.toThrow();
expect(audioEl.volume).toBe(1);
});
it("keeps a replacement screenshare audio element tracked when an older track unsubscribes", () => {
const firstAudioEl = document.createElement("audio");
const secondAudioEl = document.createElement("audio");
const firstTrack = {
kind: "audio",
sid: "track-1",
detach: vi.fn(() => [firstAudioEl]),
attach: vi.fn(() => firstAudioEl),
};
const secondTrack = {
kind: "audio",
sid: "track-2",
detach: vi.fn(() => [secondAudioEl]),
attach: vi.fn(() => secondAudioEl),
};
const publication = { source: "screenShareAudio" };
const participant = { identity: "user-42" };
(session as any)._eventHandlers.handleTrackSubscribed(firstTrack, publication, participant);
(session as any)._eventHandlers.handleTrackSubscribed(secondTrack, publication, participant);
(session as any)._eventHandlers.handleTrackUnsubscribed(firstTrack, publication, participant);
session.muteScreenshareAudio(42, true);
expect(secondAudioEl.muted).toBe(true);
expect((session as any)._audioElements.screenshareAudioElements.get(42)).toEqual(
new Set([secondAudioEl]),
);
});
it("applies the stored mute state to replacement screenshare audio tracks", () => {
const firstAudioEl = document.createElement("audio");
const secondAudioEl = document.createElement("audio");
const firstTrack = {
kind: "audio",
sid: "track-1",
detach: vi.fn(() => [firstAudioEl]),
attach: vi.fn(() => firstAudioEl),
};
const secondTrack = {
kind: "audio",
sid: "track-2",
detach: vi.fn(() => [secondAudioEl]),
attach: vi.fn(() => secondAudioEl),
};
const publication = { source: "screenShareAudio" };
const participant = { identity: "user-42" };
(session as any)._eventHandlers.handleTrackSubscribed(firstTrack, publication, participant);
session.muteScreenshareAudio(42, true);
(session as any)._eventHandlers.handleTrackSubscribed(secondTrack, publication, participant);
expect(secondAudioEl.muted).toBe(true);
expect(session.getScreenshareAudioMuted(42)).toBe(true);
});
});
describe("muteScreenshareAudio", () => {
it("stores mute state even when no audio element exists for userId", () => {
session.muteScreenshareAudio(999, true);
// Mute state is persisted so late-arriving audio elements inherit it
expect(session.getScreenshareAudioMuted(999)).toBe(true);
});
});
describe("getScreenshareAudioMuted", () => {
it("returns false when no audio element exists for userId", () => {
expect(session.getScreenshareAudioMuted(999)).toBe(false);
});
});
// === PRE-REFACTOR BEHAVIORAL SNAPSHOT TESTS ===
// These lock the public API behavior before the 4-module split.
// Every test here must still pass after the refactor.
describe("enableScreenshare (pre-refactor lock)", () => {
it("shows error when no active voice session", async () => {
const onError = vi.fn();
session.setOnError(onError);
await session.enableScreenshare();
expect(onError).toHaveBeenCalledWith(expect.stringContaining("voice"));
});
it("does not enable screenshare when no room available", async () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
await session.enableScreenshare();
// Should not send WS message without an active room
expect(mockWs.send).not.toHaveBeenCalled();
});
});
describe("disableScreenshare (pre-refactor lock)", () => {
it("calls setLocalScreenshare(false) even without a room", async () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
await session.disableScreenshare();
expect(setLocalScreenshare).toHaveBeenCalledWith(false);
});
it("sends voice_screenshare disabled message when ws is set", async () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
await session.disableScreenshare();
expect(mockWs.send).toHaveBeenCalledWith({
type: "voice_screenshare",
payload: { enabled: false },
});
});
});
describe("reapplyAudioProcessing (pre-refactor lock)", () => {
it("does not throw when no room is active", () => {
expect(() => session.reapplyAudioProcessing()).not.toThrow();
});
});
describe("getLocalScreenshareStream (pre-refactor lock)", () => {
it("returns null when no room", () => {
expect(session.getLocalScreenshareStream()).toBeNull();
});
});
// -----------------------------------------------------------------------
// Mutant-killing tests: leaveVoice (deep assertions)
// -----------------------------------------------------------------------
describe("leaveVoice (state management)", () => {
it("aborts reconnectAc when reconnect is in progress", () => {
const ac = new AbortController();
const abortSpy = vi.spyOn(ac, "abort");
(session as any)._state = {
type: "reconnecting",
channelId: 1,
latestToken: "t",
lastUrl: "/lk",
lastDirectUrl: undefined,
ac,
};
session.leaveVoice(false);
expect(abortSpy).toHaveBeenCalled();
expect((session as any)._state.type).toBe("idle");
});
it("clears the token refresh timer so it does not fire after leave", () => {
// Set up a timer that would fail if it fires
(session as any).tokenRefreshTimer = setTimeout(() => {
throw new Error("Timer should have been cleared");
}, 100);
session.leaveVoice(false);
expect((session as any).tokenRefreshTimer).toBeNull();
// Advance past when it would have fired — should not throw
vi.advanceTimersByTime(200);
});
it("calls teardownAudioPipeline on _audioPipeline", () => {
const teardownSpy = vi.spyOn((session as any)._audioPipeline, "teardownAudioPipeline");
session.leaveVoice(false);
expect(teardownSpy).toHaveBeenCalled();
teardownSpy.mockRestore();
});
it("nulls pendingJoin", () => {
(session as any)._state = {
type: "connecting",
pendingJoin: { token: "t", url: "/lk", channelId: 1 },
joinGeneration: 1,
};
session.leaveVoice(false);
expect((session as any)._state.type).toBe("idle");
});
it("calls cleanupAllAudioElementsFull on _audioElements", () => {
const cleanupSpy = vi.spyOn((session as any)._audioElements, "cleanupAllAudioElementsFull");
session.leaveVoice(false);
expect(cleanupSpy).toHaveBeenCalled();
cleanupSpy.mockRestore();
});
it("calls room.removeAllListeners before disconnect when room exists", async () => {
// Set up a room via handleVoiceToken
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
expect((session as any)._state.type).toBe("connected");
const room = (session as any)._state.room;
session.leaveVoice(false);
expect(mockRoom.removeAllListeners).toHaveBeenCalled();
expect(mockRoom.disconnect).toHaveBeenCalled();
});
it("sets currentChannelId to null after leave", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 5, "ws://localhost:7880", true);
expect((session as any)._state.channelId).toBe(5);
session.leaveVoice(false);
expect((session as any)._state.type).toBe("idle");
});
it("sets latestToken to null after leave", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("my-token", "/lk", 1, "ws://localhost:7880", true);
expect((session as any)._state.latestToken).toBe("my-token");
session.leaveVoice(false);
expect((session as any)._state.type).toBe("idle");
});
it("sets lastUrl to null and lastDirectUrl to undefined after leave", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
session.leaveVoice(false);
expect((session as any)._state.type).toBe("idle");
});
});
// -----------------------------------------------------------------------
// Mutant-killing tests: cleanupAll
// -----------------------------------------------------------------------
describe("cleanupAll (deep assertions)", () => {
it("invokes stop_livekit_proxy", () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
session.setServerHost("localhost:8080");
session.cleanupAll();
expect(mockInvoke).toHaveBeenCalledWith("stop_livekit_proxy", undefined);
});
it("nulls ws, serverHost, and callbacks", () => {
session.setWsClient({ send: vi.fn() } as any);
session.setServerHost("localhost:8080");
session.setOnError(vi.fn());
session.setOnRemoteVideo(vi.fn());
session.setOnRemoteVideoRemoved(vi.fn());
session.cleanupAll();
expect((session as any).ws).toBeNull();
expect((session as any).serverHost).toBeNull();
expect((session as any).onErrorCallback).toBeNull();
expect((session as any).onRemoteVideoCallback).toBeNull();
expect((session as any).onRemoteVideoRemovedCallback).toBeNull();
});
it("nulls liveKitProxyPort", () => {
(session as any).liveKitProxyPort = 7881;
session.cleanupAll();
expect((session as any).liveKitProxyPort).toBeNull();
});
});
// -----------------------------------------------------------------------
// Mutant-killing tests: setMuted / setDeafened with active room
// -----------------------------------------------------------------------
describe("setMuted (with active room)", () => {
beforeEach(async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
vi.clearAllMocks();
});
it("muting calls setMicrophoneEnabled(false) on the room", async () => {
session.setMuted(true);
// applyMicMuteState is async fire-and-forget, flush microtasks
await vi.advanceTimersByTimeAsync(0);
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
});
it("unmuting calls setMicrophoneEnabled(true) and rebuilds pipeline", async () => {
const setupSpy = vi.spyOn((session as any)._audioPipeline, "setupAudioPipeline");
session.setMuted(false);
await vi.advanceTimersByTimeAsync(0);
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
expect(setupSpy).toHaveBeenCalled();
setupSpy.mockRestore();
});
});
describe("setDeafened (with active room)", () => {
beforeEach(async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
vi.clearAllMocks();
});
it("deafening when already muted keeps mic disabled", async () => {
mockVoiceState.localMuted = true;
session.setDeafened(true);
await vi.advanceTimersByTimeAsync(0);
expect(setLocalDeafened).toHaveBeenCalledWith(true);
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
});
it("undeafening when localMuted is true keeps mic muted", async () => {
mockVoiceState.localMuted = true;
mockVoiceState.localDeafened = false;
session.setDeafened(false);
await vi.advanceTimersByTimeAsync(0);
expect(setLocalDeafened).toHaveBeenCalledWith(false);
// shouldMute = deafened(false) || localMuted(true) = true
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
});
it("calls applyRemoteAudioSubscriptionState with deafened value", () => {
const subSpy = vi.spyOn((session as any)._audioElements, "applyRemoteAudioSubscriptionState");
session.setDeafened(true);
expect(subSpy).toHaveBeenCalledWith(true);
subSpy.mockRestore();
});
});
// -----------------------------------------------------------------------
// Mutant-killing tests: retryMicPermission
// -----------------------------------------------------------------------
describe("retryMicPermission", () => {
it("returns immediately (no-op) when no room exists", async () => {
vi.clearAllMocks();
await session.retryMicPermission();
// No calls should have been made to setMicrophoneEnabled
expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled();
expect(setListenOnly).not.toHaveBeenCalled();
});
it("enables mic and exits listen-only on success", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
vi.clearAllMocks();
await session.retryMicPermission();
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
expect(setListenOnly).toHaveBeenCalledWith(false);
expect(setLocalMuted).toHaveBeenCalledWith(false);
});
it("applies noise suppressor when enhancedNoiseSuppression pref is true", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
vi.clearAllMocks();
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "enhancedNoiseSuppression") return true;
return defaultVal;
});
const noiseSpy = vi
.spyOn((session as any)._audioPipeline, "applyNoiseSuppressor")
.mockResolvedValue(undefined);
await session.retryMicPermission();
expect(noiseSpy).toHaveBeenCalled();
noiseSpy.mockRestore();
mockLoadPref.mockImplementation((_key: string, defaultVal: unknown) => defaultVal);
});
it("calls error callback and remains listen-only when mic fails", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
const errorCb = vi.fn();
session.setOnError(errorCb);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
vi.clearAllMocks();
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(
new Error("permission denied"),
);
await session.retryMicPermission();
expect(errorCb).toHaveBeenCalledWith(
"Microphone still unavailable — check your browser permissions",
);
// setListenOnly(false) should NOT have been called on failure
expect(setListenOnly).not.toHaveBeenCalled();
});
it("sets up audio pipeline on success", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
vi.clearAllMocks();
const setupSpy = vi.spyOn((session as any)._audioPipeline, "setupAudioPipeline");
await session.retryMicPermission();
expect(setupSpy).toHaveBeenCalled();
setupSpy.mockRestore();
});
});
// -----------------------------------------------------------------------
// Mutant-killing tests: restoreLocalVoiceState
// -----------------------------------------------------------------------
describe("restoreLocalVoiceState", () => {
beforeEach(async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
});
it("applies noise suppressor when enhancedNoiseSuppression pref is true on join", async () => {
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "enhancedNoiseSuppression") return true;
return defaultVal;
});
const noiseSpy = vi
.spyOn((session as any)._audioPipeline, "applyNoiseSuppressor")
.mockResolvedValue(undefined);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
expect(noiseSpy).toHaveBeenCalled();
noiseSpy.mockRestore();
mockLoadPref.mockImplementation((_key: string, defaultVal: unknown) => defaultVal);
});
it("mode reconnect with mic error logs warn but does NOT call error callback", async () => {
const errorCb = vi.fn();
session.setOnError(errorCb);
mockVoiceState.localMuted = false;
mockVoiceState.localDeafened = false;
await session.handleVoiceToken("tok", "/lk", 7, "ws://localhost:7880", true);
vi.clearAllMocks();
// Session is already in "connected" state with channelId=7 after handleVoiceToken
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(new Error("mic gone"));
const ac = new AbortController();
const reconnectPromise = (session as any).attemptAutoReconnect(
"reconnect-token",
"/lk",
7,
"ws://localhost:7880",
ac.signal,
);
await vi.advanceTimersByTimeAsync(3100);
await reconnectPromise;
// On reconnect mic failure, error callback should NOT be called
expect(errorCb).not.toHaveBeenCalledWith(
expect.stringContaining("Microphone permission denied"),
);
expect(errorCb).not.toHaveBeenCalledWith(expect.stringContaining("Microphone unavailable"));
});
it("mode join with generic mic error calls error callback", async () => {
const errorCb = vi.fn();
session.setOnError(errorCb);
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(new Error("some error"));
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
expect(errorCb).toHaveBeenCalledWith("Microphone unavailable — joined in listen-only mode");
});
it("localDeafened true calls applyRemoteAudioSubscriptionState(true)", async () => {
mockVoiceState.localDeafened = true;
mockVoiceState.localMuted = false;
const subSpy = vi.spyOn((session as any)._audioElements, "applyRemoteAudioSubscriptionState");
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
// Should be called with the deafened state
expect(subSpy).toHaveBeenCalledWith(true);
subSpy.mockRestore();
});
it("localMuted true but not deafened disables mic and calls applyMicMuteState", async () => {
mockVoiceState.localMuted = true;
mockVoiceState.localDeafened = false;
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
// setMicrophoneEnabled(false) should have been called (shouldEnableMicrophone = false when muted)
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
});
it("sets listenOnly(false) on successful mic acquisition", async () => {
mockVoiceState.localMuted = false;
mockVoiceState.localDeafened = false;
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
expect(setListenOnly).toHaveBeenCalledWith(false);
});
it("sets listenOnly(true) when mic fails", async () => {
mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(new Error("fail"));
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
expect(setListenOnly).toHaveBeenCalledWith(true);
});
});
// -----------------------------------------------------------------------
// Mutant-killing tests: delegation methods
// -----------------------------------------------------------------------
describe("delegation methods (video)", () => {
it("getRemoteVideoStream returns null with no room", () => {
expect(session.getRemoteVideoStream(42, "camera")).toBeNull();
});
it("getRemoteVideoStream returns null with no room for screenshare", () => {
expect(session.getRemoteVideoStream(42, "screenshare")).toBeNull();
});
it("getLocalCameraStream returns null with no room", () => {
expect(session.getLocalCameraStream()).toBeNull();
});
it("enableCamera delegates to doEnableCamera and shows error without ws", async () => {
const errorCb = vi.fn();
session.setOnError(errorCb);
await session.enableCamera();
expect(errorCb).toHaveBeenCalledWith("Join a voice channel first");
});
it("disableCamera sends voice_camera disabled when ws is set", async () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
await session.disableCamera();
expect(mockWs.send).toHaveBeenCalledWith({
type: "voice_camera",
payload: { enabled: false },
});
});
it("enableScreenshare shows error without active voice session", async () => {
const errorCb = vi.fn();
session.setOnError(errorCb);
await session.enableScreenshare();
expect(errorCb).toHaveBeenCalledWith(expect.stringContaining("voice"));
});
it("disableScreenshare sends voice_screenshare disabled when ws is set", async () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
await session.disableScreenshare();
expect(mockWs.send).toHaveBeenCalledWith({
type: "voice_screenshare",
payload: { enabled: false },
});
});
});
// -----------------------------------------------------------------------
// Mutant-killing tests: singleton exports
// -----------------------------------------------------------------------
describe("singleton exports", () => {
it("isVoiceConnected returns false when no session is active", () => {
expect(isVoiceConnected()).toBe(false);
});
it("bound leaveVoice is callable without throwing", () => {
expect(() => boundLeaveVoice(false)).not.toThrow();
});
it("bound setMuted is callable and calls setLocalMuted", () => {
boundSetMuted(true);
expect(setLocalMuted).toHaveBeenCalledWith(true);
});
it("bound setDeafened is callable and calls setLocalDeafened", () => {
boundSetDeafened(true);
expect(setLocalDeafened).toHaveBeenCalledWith(true);
});
it("bound cleanupAll is callable without throwing", () => {
expect(() => boundCleanupAll()).not.toThrow();
});
});
// =================================================================
// Mutant-killing tests — connection lifecycle methods
// =================================================================
describe("resolveLiveKitUrl", () => {
it("returns proxyPath unchanged when serverHost is null", async () => {
const url = await (session as any).resolveLiveKitUrl("/livekit");
expect(url).toBe("/livekit");
});
it("returns directUrl when serverHost is localhost", async () => {
session.setServerHost("localhost:7880");
const url = await (session as any).resolveLiveKitUrl(
"/livekit",
"ws://localhost:7880/livekit",
);
expect(url).toBe("ws://localhost:7880/livekit");
});
it("returns directUrl when serverHost is 127.0.0.1", async () => {
session.setServerHost("127.0.0.1:7880");
const url = await (session as any).resolveLiveKitUrl(
"/livekit",
"ws://127.0.0.1:7880/livekit",
);
expect(url).toBe("ws://127.0.0.1:7880/livekit");
});
it("returns directUrl when serverHost is bare ::1", async () => {
session.setServerHost("::1");
const url = await (session as any).resolveLiveKitUrl("/livekit", "ws://[::1]:7880/livekit");
// Bare IPv6 with multiple colons — detected as local, returns directUrl
expect(url).toBe("ws://[::1]:7880/livekit");
});
it("returns directUrl when serverHost is bracketed [::1]:7880", async () => {
session.setServerHost("[::1]:7880");
const url = await (session as any).resolveLiveKitUrl("/livekit", "ws://[::1]:7880/livekit");
// Bracketed IPv6 — host extracted as "::1", detected as local
expect(url).toBe("ws://[::1]:7880/livekit");
});
it("calls ensureLiveKitProxy and returns proxy URL for remote host with slash path", async () => {
session.setServerHost("example.com:443");
const url = await (session as any).resolveLiveKitUrl("/livekit");
expect(mockInvoke).toHaveBeenCalledWith("start_livekit_proxy", {
remoteHost: "example.com:443",
});
expect(url).toBe("ws://127.0.0.1:7881/livekit");
});
it("passes through proxyPath that does not start with / for remote host", async () => {
session.setServerHost("example.com:443");
const url = await (session as any).resolveLiveKitUrl("wss://example.com/livekit");
expect(mockInvoke).not.toHaveBeenCalled();
expect(url).toBe("wss://example.com/livekit");
});
it("does not return directUrl when serverHost is remote even if directUrl is provided", async () => {
session.setServerHost("example.com:443");
const url = await (session as any).resolveLiveKitUrl(
"/livekit",
"ws://example.com:7880/livekit",
);
expect(url).toBe("ws://127.0.0.1:7881/livekit");
});
it("does not return directUrl for localhost when directUrl is undefined", async () => {
session.setServerHost("localhost:7880");
const url = await (session as any).resolveLiveKitUrl("/livekit");
// No directUrl provided, isLocal but directUrl falsy -> falls to proxy
expect(url).toBe("ws://127.0.0.1:7881/livekit");
});
});
describe("ensureLiveKitProxy", () => {
it("invokes start_livekit_proxy on first call and caches port", async () => {
session.setServerHost("example.com:443");
const port1 = await (session as any).ensureLiveKitProxy();
expect(port1).toBe(7881);
expect(mockInvoke).toHaveBeenCalledTimes(1);
expect(mockInvoke).toHaveBeenCalledWith("start_livekit_proxy", {
remoteHost: "example.com:443",
});
mockInvoke.mockClear();
const port2 = await (session as any).ensureLiveKitProxy();
expect(port2).toBe(7881);
expect(mockInvoke).not.toHaveBeenCalled();
});
it("appends :443 when serverHost has no port", async () => {
session.setServerHost("example.com");
await (session as any).ensureLiveKitProxy();
expect(mockInvoke).toHaveBeenCalledWith("start_livekit_proxy", {
remoteHost: "example.com:443",
});
});
it("throws when serverHost is null", async () => {
await expect((session as any).ensureLiveKitProxy()).rejects.toThrow(
"no server host for LiveKit proxy",
);
});
});
describe("connectAndSetup retry logic", () => {
it("retries on first failure and succeeds on second attempt", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
mockRoom.connect
.mockRejectedValueOnce(new Error("transient"))
.mockResolvedValueOnce(undefined);
const resultPromise = (session as any).connectAndSetup(
"token",
"/livekit",
1,
"ws://localhost:7880",
true,
);
await vi.advanceTimersByTimeAsync(2100);
const result = await resultPromise;
expect(mockRoom.connect).toHaveBeenCalledTimes(2);
expect(result).toBe(true);
});
it("fails after all 3 attempts and calls error callback + leaveVoice", async () => {
const errorCb = vi.fn();
session.setOnError(errorCb);
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
mockRoom.connect.mockRejectedValue(new Error("persistent failure"));
const resultPromise = (session as any).connectAndSetup(
"token",
"/livekit",
1,
"ws://localhost:7880",
true,
);
for (let i = 0; i < 3; i++) {
await vi.advanceTimersByTimeAsync(2100);
}
const result = await resultPromise;
expect(result).toBe(false);
expect(errorCb).toHaveBeenCalledWith("Failed to join voice — connection error");
});
it("discards stale join when pendingJoin arrives during connect", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
const connectDeferred = createDeferred<void>();
mockRoom.connect.mockImplementationOnce(() => connectDeferred.promise);
const resultPromise = (session as any).connectAndSetup(
"token-1",
"/livekit",
1,
"ws://localhost:7880",
true,
);
// Inject a pendingJoin into the current "connecting" state
const currentState = (session as any)._state;
(session as any)._state = {
...currentState,
pendingJoin: {
token: "token-2",
url: "/livekit-2",
channelId: 2,
directUrl: "ws://localhost:7882",
},
};
connectDeferred.resolve(undefined);
const result = await resultPromise;
expect(result).toBe(false);
});
it("continues when saved input device is unavailable", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
mockRoom.connect.mockResolvedValue(undefined);
mockRoom.switchActiveDevice.mockRejectedValueOnce(new Error("device not found"));
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "audioInputDevice") return "nonexistent-device";
return defaultVal;
});
const result = await (session as any).connectAndSetup(
"token",
"/livekit",
1,
"ws://localhost:7880",
true,
);
expect(result).toBe(true);
});
it("calls leaveVoice(false) when room is non-null at entry", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
mockRoom.connect.mockResolvedValue(undefined);
await (session as any).connectAndSetup("token-1", "/livekit", 1, "ws://localhost:7880", true);
expect((session as any)._state.type).toBe("connected");
const leaveSpy = vi.spyOn(session, "leaveVoice");
await (session as any).connectAndSetup("token-2", "/livekit", 2, "ws://localhost:7880", true);
expect(leaveSpy).toHaveBeenCalledWith(false);
leaveSpy.mockRestore();
});
});
describe("handleVoiceToken pending join drain", () => {
it("calls handleVoiceTokenRefresh when already connected to same channel", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
mockRoom.connect.mockResolvedValue(undefined);
await session.handleVoiceToken("token-1", "/livekit", 1, "ws://localhost:7880", true);
mockRoom.state = "connected";
const refreshSpy = vi.spyOn(session, "handleVoiceTokenRefresh");
await session.handleVoiceToken("token-2", "/livekit", 1, "ws://localhost:7880", true);
expect(refreshSpy).toHaveBeenCalledWith("token-2");
expect(mockRoom.connect).toHaveBeenCalledTimes(1);
refreshSpy.mockRestore();
});
it("executes only the latest pending join when two are queued", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
const firstConnect = createDeferred<void>();
mockRoom.connect
.mockImplementationOnce(() => firstConnect.promise)
.mockResolvedValue(undefined);
const firstJoin = session.handleVoiceToken(
"token-1",
"/livekit-1",
1,
"ws://localhost:7881",
true,
);
// Flush microtasks so E2EE async steps resolve before connect
await vi.advanceTimersByTimeAsync(0);
await session.handleVoiceToken("token-2", "/livekit-2", 2, "ws://localhost:7882", true);
await session.handleVoiceToken("token-3", "/livekit-3", 3, "ws://localhost:7883", true);
const s = (session as any)._state;
expect(s.type).toBe("connecting");
expect(s.pendingJoin.token).toBe("token-3");
expect(s.pendingJoin.channelId).toBe(3);
firstConnect.resolve(undefined);
await firstJoin;
const lastCall = mockRoom.connect.mock.calls[mockRoom.connect.mock.calls.length - 1]!;
expect(lastCall[1]).toBe("token-3");
});
});
describe("attemptAutoReconnect (lifecycle)", () => {
it("returns without reconnecting when signal is aborted during delay", async () => {
(session as any)._state = {
type: "reconnecting",
channelId: 5,
latestToken: "token",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
ac: new AbortController(),
};
const ac = new AbortController();
const reconnectPromise = (session as any).attemptAutoReconnect(
"token",
"/livekit",
5,
"ws://localhost:7880",
ac.signal,
);
ac.abort();
await vi.advanceTimersByTimeAsync(3100);
await reconnectPromise;
expect(mockRoom.connect).not.toHaveBeenCalled();
});
it("aborts when currentChannelId changes during delay", async () => {
(session as any)._state = {
type: "reconnecting",
channelId: 5,
latestToken: "token",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
ac: new AbortController(),
};
const ac = new AbortController();
const reconnectPromise = (session as any).attemptAutoReconnect(
"token",
"/livekit",
5,
"ws://localhost:7880",
ac.signal,
);
(session as any)._state = { type: "idle" };
await vi.advanceTimersByTimeAsync(3100);
await reconnectPromise;
expect(mockRoom.connect).not.toHaveBeenCalled();
});
it("succeeds on second attempt after first fails", async () => {
(session as any)._state = {
type: "reconnecting",
channelId: 5,
latestToken: "token",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
ac: new AbortController(),
};
session.setServerHost("localhost:7880");
const ac = new AbortController();
mockRoom.connect
.mockRejectedValueOnce(new Error("first attempt failed"))
.mockResolvedValueOnce(undefined);
const reconnectPromise = (session as any).attemptAutoReconnect(
"token",
"/livekit",
5,
"ws://localhost:7880",
ac.signal,
);
await vi.advanceTimersByTimeAsync(3100);
await vi.advanceTimersByTimeAsync(3100);
await reconnectPromise;
expect(mockRoom.connect).toHaveBeenCalledTimes(2);
});
it("calls leaveVoice, leaveVoiceChannel, and error callback after all attempts fail", async () => {
(session as any)._state = {
type: "reconnecting",
channelId: 5,
latestToken: "token",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
ac: new AbortController(),
};
session.setServerHost("localhost:7880");
const errorCb = vi.fn();
session.setOnError(errorCb);
const ac = new AbortController();
mockRoom.connect.mockRejectedValue(new Error("always fails"));
const reconnectPromise = (session as any).attemptAutoReconnect(
"token",
"/livekit",
5,
"ws://localhost:7880",
ac.signal,
);
await vi.advanceTimersByTimeAsync(3100);
await vi.advanceTimersByTimeAsync(3100);
await reconnectPromise;
expect(leaveVoiceChannel).toHaveBeenCalled();
expect(errorCb).toHaveBeenCalledWith("Voice connection lost — failed to reconnect");
});
it("catches room disconnect failure during cleanup without throwing", async () => {
(session as any)._state = {
type: "reconnecting",
channelId: 5,
latestToken: "token",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
ac: new AbortController(),
};
session.setServerHost("localhost:7880");
const ac = new AbortController();
mockRoom.connect.mockRejectedValue(new Error("connect failed"));
mockRoom.disconnect.mockRejectedValueOnce(new Error("disconnect also failed"));
const reconnectPromise = (session as any).attemptAutoReconnect(
"token",
"/livekit",
5,
"ws://localhost:7880",
ac.signal,
);
await vi.advanceTimersByTimeAsync(3100);
await vi.advanceTimersByTimeAsync(3100);
await reconnectPromise;
expect(leaveVoiceChannel).toHaveBeenCalled();
});
it("cleans up reconnect room when signal aborts after connect resolves (BUG-070)", async () => {
(session as any)._state = {
type: "reconnecting",
channelId: 5,
latestToken: "token",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
ac: new AbortController(),
};
session.setServerHost("localhost:7880");
const ac = new AbortController();
mockRoom.connect.mockImplementationOnce(async () => {
ac.abort();
});
const reconnectPromise = (session as any).attemptAutoReconnect(
"token",
"/livekit",
5,
"ws://localhost:7880",
ac.signal,
);
await vi.advanceTimersByTimeAsync(3100);
await reconnectPromise;
expect(mockRoom.disconnect).toHaveBeenCalled();
expect((session as any)._state.type).not.toBe("connected");
});
});
describe("token refresh timer", () => {
it("fires after TOKEN_REFRESH_MS and sends voice_token_refresh WS message", async () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
session.setServerHost("localhost:7880");
mockRoom.connect.mockResolvedValue(undefined);
await session.handleVoiceToken("token", "/livekit", 1, "ws://localhost:7880", true);
mockWs.send.mockClear();
await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000 + 100);
expect(mockWs.send).toHaveBeenCalledWith({
type: "voice_token_refresh",
payload: {},
});
});
it("requestTokenRefresh skips silently when ws is null", () => {
// ws is null (not set) — requestTokenRefresh should skip without throwing
expect(() => (session as any).requestTokenRefresh()).not.toThrow();
});
it("requestTokenRefresh skips silently when room is null", () => {
session.setWsClient({ send: vi.fn() } as any);
expect(() => (session as any).requestTokenRefresh()).not.toThrow();
});
it("handleVoiceTokenRefresh stores valid token and restarts timer", () => {
(session as any)._state = {
type: "connected",
room: mockRoom,
channelId: 1,
latestToken: "old-token",
lastUrl: "/lk",
lastDirectUrl: undefined,
};
session.handleVoiceTokenRefresh("fresh-token");
expect((session as any)._state.latestToken).toBe("fresh-token");
expect((session as any).tokenRefreshTimer).not.toBeNull();
});
it("clearTokenRefreshTimer prevents pending refresh from firing", async () => {
const mockWs = { send: vi.fn() } as any;
session.setWsClient(mockWs);
session.setServerHost("localhost:7880");
mockRoom.connect.mockResolvedValue(undefined);
await session.handleVoiceToken("token", "/livekit", 1, "ws://localhost:7880", true);
mockWs.send.mockClear();
(session as any).clearTokenRefreshTimer();
await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000 + 100);
expect(mockWs.send).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "voice_token_refresh" }),
);
});
});
// -----------------------------------------------------------------------
// F3: Voice E2EE identity-key signing + TOFU verification (receive path)
// -----------------------------------------------------------------------
describe("E2EE announce verification (F3 TOFU)", () => {
const HOST = "localhost:7880";
const PEER_ID = 42;
function seedPeer(identityPublicKey: string | null): void {
const peer: ReadyMember = {
id: PEER_ID,
username: "peer",
avatar: null,
role: "member",
status: "online",
identity_public_key: identityPublicKey,
};
setMembers([peer]);
}
async function joinAsKeyHolder(ws: { send: ReturnType<typeof vi.fn> }): Promise<void> {
session.setServerHost(HOST);
session.setWsClient(ws as any);
await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880", true);
}
function offerSends(ws: { send: ReturnType<typeof vi.fn> }): unknown[] {
return ws.send.mock.calls.map((c) => c[0]).filter((m: any) => m?.type === "voice_e2ee_offer");
}
beforeEach(() => {
// Restore TOFU mock defaults — persistent overrides survive clearAllMocks.
(getIdentityPin as any).mockResolvedValue(null);
(storeIdentityPin as any).mockResolvedValue(true);
(verifyEphemeralKeySignature as any).mockResolvedValue(true);
});
it("signs the ephemeral announce sent on join", async () => {
seedPeer("peer-identity-b64");
const ws = { send: vi.fn() };
await joinAsKeyHolder(ws);
const announce = ws.send.mock.calls
.map((c) => c[0])
.find((m: any) => m?.type === "voice_e2ee_announce");
expect(announce).toBeDefined();
expect((announce as any).payload.signature).toBe("mock-signature");
});
it("rejects a server-substituted peer ephemeral key (signature verify fails)", async () => {
seedPeer("peer-identity-b64");
(verifyEphemeralKeySignature as any).mockResolvedValue(false);
const ws = { send: vi.fn() };
await joinAsKeyHolder(ws);
ws.send.mockClear();
await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig");
// No room-key offer wrapped for an unverifiable peer, key not stored.
expect(offerSends(ws)).toHaveLength(0);
expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(false);
expect(setPeerVerification).toHaveBeenCalledWith(
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
);
expect(storeIdentityPin).not.toHaveBeenCalled();
});
it("pins the peer identity key on first sight and marks it verified", async () => {
seedPeer("peer-identity-b64");
(getIdentityPin as any).mockResolvedValue(null);
const ws = { send: vi.fn() };
await joinAsKeyHolder(ws);
ws.send.mockClear();
await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig");
expect(storeIdentityPin).toHaveBeenCalledWith(HOST, String(PEER_ID), "peer-identity-b64");
expect(setPeerVerification).toHaveBeenCalledWith(
expect.objectContaining({
userId: PEER_ID,
status: "verified",
safetyNumber: "AB12 CD34 EF56 7890",
}),
);
// Verified peer is stored and (we are key holder) receives a room-key offer.
expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(true);
expect(offerSends(ws)).toHaveLength(1);
});
it("blocks and emits identity-tofu when the pinned identity key changed", async () => {
seedPeer("new-identity-b64");
(getIdentityPin as any).mockResolvedValue("old-identity-b64");
const ws = { send: vi.fn() };
await joinAsKeyHolder(ws);
ws.send.mockClear();
await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig");
expect(setPeerVerification).toHaveBeenCalledWith(
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
);
// Blocked before verify — no pin overwrite, no signature check, no offer.
expect(storeIdentityPin).not.toHaveBeenCalled();
expect(verifyEphemeralKeySignature).not.toHaveBeenCalled();
expect(offerSends(ws)).toHaveLength(0);
expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(false);
});
it("blocks a pinned peer when the server strips its published identity key", async () => {
// Peer was pinned before; the server now omits identity_public_key to
// shove the peer onto the legacy accept path (finding #2). A pinned peer
// must never fall back to legacy — this is an identity mismatch.
seedPeer(null);
(getIdentityPin as any).mockResolvedValue("old-identity-b64");
const ws = { send: vi.fn() };
await joinAsKeyHolder(ws);
ws.send.mockClear();
await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig");
expect(setPeerVerification).toHaveBeenCalledWith(
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
);
// Blocked: not accepted as legacy/unverified, key not stored, no offer.
expect(setPeerVerification).not.toHaveBeenCalledWith(
expect.objectContaining({ userId: PEER_ID, status: "unverified" }),
);
expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(false);
expect(offerSends(ws)).toHaveLength(0);
expect(storeIdentityPin).not.toHaveBeenCalled();
expect(verifyEphemeralKeySignature).not.toHaveBeenCalled();
});
it("accepts a legacy peer with no identity key but marks it unverified", async () => {
seedPeer(null);
const ws = { send: vi.fn() };
await joinAsKeyHolder(ws);
ws.send.mockClear();
await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", undefined);
expect(setPeerVerification).toHaveBeenCalledWith(
expect.objectContaining({ userId: PEER_ID, status: "unverified", safetyNumber: null }),
);
// Legacy peer still works: stored + wrapped, without verify or pin.
expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(true);
expect(offerSends(ws)).toHaveLength(1);
expect(verifyEphemeralKeySignature).not.toHaveBeenCalled();
expect(storeIdentityPin).not.toHaveBeenCalled();
});
it("re-pin recovers a mismatched peer so a later valid announce verifies", async () => {
// Peer legitimately rotated its identity key (reinstall / new device).
// Its pinned key mismatches the new published one → blocked.
seedPeer("new-identity-b64");
(getIdentityPin as any).mockResolvedValue("old-identity-b64");
const ws = { send: vi.fn() };
await joinAsKeyHolder(ws);
ws.send.mockClear();
await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig");
expect(setPeerVerification).toHaveBeenLastCalledWith(
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
);
// User accepts the new key (analogous to accepting a changed TLS cert):
// re-pin overwrites the stored pin with the verified key and clears the
// mismatch block.
const recovered = await session.rePinPeerIdentity(PEER_ID, "new-identity-b64");
expect(recovered).toBe(true);
expect(storeIdentityPin).toHaveBeenCalledWith(HOST, String(PEER_ID), "new-identity-b64");
// Store now holds the new pin; a fresh valid announce verifies.
(getIdentityPin as any).mockResolvedValue("new-identity-b64");
(storeIdentityPin as any).mockClear();
ws.send.mockClear();
await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig");
expect(setPeerVerification).toHaveBeenLastCalledWith(
expect.objectContaining({ userId: PEER_ID, status: "verified" }),
);
expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(true);
expect(offerSends(ws)).toHaveLength(1);
});
it("re-pins the verified key, not a store re-read a malicious server mutated (TOCTOU)", async () => {
// The store holds whatever the server most recently pushed. If re-pin
// re-read the store it would pin the attacker's swapped-in key; it must
// instead pin the exact key it was handed — the one whose fingerprint the
// user verified out-of-band.
seedPeer("attacker-swapped-key-b64");
const ws = { send: vi.fn() };
await joinAsKeyHolder(ws);
const pinned = await session.rePinPeerIdentity(PEER_ID, "verified-key-b64");
expect(pinned).toBe(true);
expect(storeIdentityPin).toHaveBeenCalledWith(HOST, String(PEER_ID), "verified-key-b64");
expect(storeIdentityPin).not.toHaveBeenCalledWith(
HOST,
String(PEER_ID),
"attacker-swapped-key-b64",
);
});
it("rotates the room key when a keyed peer leaves while I stay key holder (forward secrecy)", async () => {
seedPeer("peer-identity-b64");
const ws = { send: vi.fn() };
await joinAsKeyHolder(ws); // I hold the key for channel 1
await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig"); // peer now holds the room key
// A participant remains, so the leave handler proceeds past the empty check.
(mockVoiceState as any).voiceUsers = new Map([[1, new Map([[1, {}]])]]);
const epochBefore = (session as any)._e2eeEpoch;
await session.handleParticipantLeft(PEER_ID);
// Room key rotated (epoch advanced) so the departed peer's copy is dead.
expect((session as any)._e2eeEpoch).toBe(epochBefore + 1);
});
it("does not rotate the room key when the leaver never held it", async () => {
const ws = { send: vi.fn() };
await joinAsKeyHolder(ws);
(mockVoiceState as any).voiceUsers = new Map([[1, new Map([[1, {}]])]]);
const epochBefore = (session as any)._e2eeEpoch;
await session.handleParticipantLeft(999); // 999 never announced → held no key
expect((session as any)._e2eeEpoch).toBe(epochBefore);
});
it("verifies a server-substituted key when drained from the pending queue", async () => {
seedPeer("peer-identity-b64");
(verifyEphemeralKeySignature as any).mockResolvedValue(false);
const ws = { send: vi.fn() };
// Announce arrives BEFORE the keypair is ready → queued, drained on join.
await session.handleE2EEAnnounce(PEER_ID, "cGVlcg==", "sig");
expect((session as any)._pendingAnnounces).toHaveLength(1);
await joinAsKeyHolder(ws);
// Drain ran through the verifying path → substituted key rejected.
expect((session as any)._peerPublicKeys.has(PEER_ID)).toBe(false);
expect(offerSends(ws)).toHaveLength(0);
});
});
});