diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index fab61029bb0..51f9cef3f70 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -55,167 +55,169 @@ describe("MatrixRTCSession", () => { sess = undefined; }); - it("Creates a room-scoped session from room state", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].callId).toEqual(""); - expect(sess?.memberships[0].scope).toEqual("m.room"); - expect(sess?.memberships[0].application).toEqual("m.call"); - expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); - expect(sess?.memberships[0].membershipID).toEqual("bloop"); - expect(sess?.memberships[0].isExpired()).toEqual(false); - expect(sess?.callId).toEqual(""); - }); + describe("roomSessionForRoom", () => { + it("creates a room-scoped session from room state", () => { + const mockRoom = makeMockRoom([membershipTemplate]); - it("ignores expired memberships events", () => { - jest.useFakeTimers(); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.expires = 1000; - expiredMembership.device_id = "EXPIRED"; - const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]); - - jest.advanceTimersByTime(2000); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); - jest.useRealTimers(); - }); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].callId).toEqual(""); + expect(sess?.memberships[0].scope).toEqual("m.room"); + expect(sess?.memberships[0].application).toEqual("m.call"); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + expect(sess?.memberships[0].membershipID).toEqual("bloop"); + expect(sess?.memberships[0].isExpired()).toEqual(false); + expect(sess?.callId).toEqual(""); + }); - it("ignores memberships events of members not in the room", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - mockRoom.hasMembershipState = (state) => state === KnownMembership.Join; - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - expect(sess?.memberships.length).toEqual(0); - }); + it("ignores expired memberships events", () => { + jest.useFakeTimers(); + const expiredMembership = Object.assign({}, membershipTemplate); + expiredMembership.expires = 1000; + expiredMembership.device_id = "EXPIRED"; + const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]); - it("honours created_ts", () => { - jest.useFakeTimers(); - jest.setSystemTime(500); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.created_ts = 500; - expiredMembership.expires = 1000; - const mockRoom = makeMockRoom([expiredMembership]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); - jest.useRealTimers(); - }); + jest.advanceTimersByTime(2000); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + jest.useRealTimers(); + }); - it("returns empty session if no membership events are present", () => { - const mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - expect(sess?.memberships).toHaveLength(0); - }); + it("ignores memberships events of members not in the room", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + mockRoom.hasMembershipState = (state) => state === KnownMembership.Join; + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sess?.memberships.length).toEqual(0); + }); - it("safely ignores events with no memberships section", () => { - const roomId = randomString(8); - const event = { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({}), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getTs: jest.fn().mockReturnValue(1000), - getLocalAge: jest.fn().mockReturnValue(0), - }; - const mockRoom = { - ...makeMockRoom([]), - roomId, - getLiveTimeline: jest.fn().mockReturnValue({ - getState: jest.fn().mockReturnValue({ - on: jest.fn(), - off: jest.fn(), - getStateEvents: (_type: string, _stateKey: string) => [event], - events: new Map([ - [ - EventType.GroupCallMemberPrefix, - { - size: () => true, - has: (_stateKey: string) => true, - get: (_stateKey: string) => event, - values: () => [event], - }, - ], - ]), + it("honours created_ts", () => { + jest.useFakeTimers(); + jest.setSystemTime(500); + const expiredMembership = Object.assign({}, membershipTemplate); + expiredMembership.created_ts = 500; + expiredMembership.expires = 1000; + const mockRoom = makeMockRoom([expiredMembership]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); + jest.useRealTimers(); + }); + + it("returns empty session if no membership events are present", () => { + const mockRoom = makeMockRoom([]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sess?.memberships).toHaveLength(0); + }); + + it("safely ignores events with no memberships section", () => { + const roomId = randomString(8); + const event = { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getContent: jest.fn().mockReturnValue({}), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getTs: jest.fn().mockReturnValue(1000), + getLocalAge: jest.fn().mockReturnValue(0), + }; + const mockRoom = { + ...makeMockRoom([]), + roomId, + getLiveTimeline: jest.fn().mockReturnValue({ + getState: jest.fn().mockReturnValue({ + on: jest.fn(), + off: jest.fn(), + getStateEvents: (_type: string, _stateKey: string) => [event], + events: new Map([ + [ + EventType.GroupCallMemberPrefix, + { + size: () => true, + has: (_stateKey: string) => true, + get: (_stateKey: string) => event, + values: () => [event], + }, + ], + ]), + }), }), - }), - }; - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room); - expect(sess.memberships).toHaveLength(0); - }); + }; + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room); + expect(sess.memberships).toHaveLength(0); + }); - it("safely ignores events with junk memberships section", () => { - const roomId = randomString(8); - const event = { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getTs: jest.fn().mockReturnValue(1000), - getLocalAge: jest.fn().mockReturnValue(0), - }; - const mockRoom = { - ...makeMockRoom([]), - roomId, - getLiveTimeline: jest.fn().mockReturnValue({ - getState: jest.fn().mockReturnValue({ - on: jest.fn(), - off: jest.fn(), - getStateEvents: (_type: string, _stateKey: string) => [event], - events: new Map([ - [ - EventType.GroupCallMemberPrefix, - { - size: () => true, - has: (_stateKey: string) => true, - get: (_stateKey: string) => event, - values: () => [event], - }, - ], - ]), + it("safely ignores events with junk memberships section", () => { + const roomId = randomString(8); + const event = { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getTs: jest.fn().mockReturnValue(1000), + getLocalAge: jest.fn().mockReturnValue(0), + }; + const mockRoom = { + ...makeMockRoom([]), + roomId, + getLiveTimeline: jest.fn().mockReturnValue({ + getState: jest.fn().mockReturnValue({ + on: jest.fn(), + off: jest.fn(), + getStateEvents: (_type: string, _stateKey: string) => [event], + events: new Map([ + [ + EventType.GroupCallMemberPrefix, + { + size: () => true, + has: (_stateKey: string) => true, + get: (_stateKey: string) => event, + values: () => [event], + }, + ], + ]), + }), }), - }), - }; - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room); - expect(sess.memberships).toHaveLength(0); - }); + }; + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room); + expect(sess.memberships).toHaveLength(0); + }); - it("ignores memberships with no expires_ts", () => { - const expiredMembership = Object.assign({}, membershipTemplate); - (expiredMembership.expires as number | undefined) = undefined; - const mockRoom = makeMockRoom([expiredMembership]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - expect(sess.memberships).toHaveLength(0); - }); + it("ignores memberships with no expires_ts", () => { + const expiredMembership = Object.assign({}, membershipTemplate); + (expiredMembership.expires as number | undefined) = undefined; + const mockRoom = makeMockRoom([expiredMembership]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sess.memberships).toHaveLength(0); + }); - it("ignores memberships with no device_id", () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.device_id as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - expect(sess.memberships).toHaveLength(0); - }); + it("ignores memberships with no device_id", () => { + const testMembership = Object.assign({}, membershipTemplate); + (testMembership.device_id as string | undefined) = undefined; + const mockRoom = makeMockRoom([testMembership]); + const sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sess.memberships).toHaveLength(0); + }); - it("ignores memberships with no call_id", () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.call_id as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - expect(sess.memberships).toHaveLength(0); - }); + it("ignores memberships with no call_id", () => { + const testMembership = Object.assign({}, membershipTemplate); + (testMembership.call_id as string | undefined) = undefined; + const mockRoom = makeMockRoom([testMembership]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sess.memberships).toHaveLength(0); + }); - it("ignores memberships with no scope", () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.scope as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - expect(sess.memberships).toHaveLength(0); - }); + it("ignores memberships with no scope", () => { + const testMembership = Object.assign({}, membershipTemplate); + (testMembership.scope as string | undefined) = undefined; + const mockRoom = makeMockRoom([testMembership]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sess.memberships).toHaveLength(0); + }); - it("ignores anything that's not a room-scoped call (for now)", () => { - const testMembership = Object.assign({}, membershipTemplate); - testMembership.scope = "m.user"; - const mockRoom = makeMockRoom([testMembership]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - expect(sess.memberships).toHaveLength(0); + it("ignores anything that's not a room-scoped call (for now)", () => { + const testMembership = Object.assign({}, membershipTemplate); + testMembership.scope = "m.user"; + const mockRoom = makeMockRoom([testMembership]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sess.memberships).toHaveLength(0); + }); }); describe("updateCallMembershipEvent", () => { @@ -588,967 +590,905 @@ describe("MatrixRTCSession", () => { jest.useRealTimers(); } }); + }); - it("creates a key when joining", () => { - sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - const encryptionKeyChangedListener = jest.fn(); - sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); - sess?.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); - expect(encryptionKeyChangedListener).toHaveBeenCalledWith( - expect.any(Uint8Array), - 0, - "@alice:example.org:AAAAAAA", - ); - }); - - it("sends keys when joining", async () => { - jest.useFakeTimers(); - try { - const eventSentPromise = new Promise((resolve) => { - sendEventMock.mockImplementation(resolve); - }); - - sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + describe("onMembershipsChanged", () => { + it("does not emit if no membership changes", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - await eventSentPromise; + const onMembershipsChanged = jest.fn(); + sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); + sess.onMembershipUpdate(); - expect(sendEventMock).toHaveBeenCalledWith( - expect.stringMatching(".*"), - "io.element.call.encryption_keys", - { - call_id: "", - device_id: "AAAAAAA", - keys: [ - { - index: 0, - key: expect.stringMatching(".*"), - }, - ], - sent_ts: Date.now(), - }, - ); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); - } finally { - jest.useRealTimers(); - } + expect(onMembershipsChanged).not.toHaveBeenCalled(); }); - it("does not send key if join called when already joined", () => { - sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + it("emits on membership changes", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + const onMembershipsChanged = jest.fn(); + sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - expect(client.sendEvent).toHaveBeenCalledTimes(1); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); + mockRoom.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState([], mockRoom.roomId)); + sess.onMembershipUpdate(); - sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - expect(client.sendEvent).toHaveBeenCalledTimes(1); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); + expect(onMembershipsChanged).toHaveBeenCalled(); }); - it("retries key sends", async () => { + it("emits an event at the time a membership event expires", () => { jest.useFakeTimers(); - let firstEventSent = false; - try { - const eventSentPromise = new Promise((resolve) => { - sendEventMock.mockImplementation(() => { - if (!firstEventSent) { - jest.advanceTimersByTime(10000); - - firstEventSent = true; - const e = new Error() as MatrixError; - e.data = {}; - throw e; - } else { - resolve(); - } - }); - }); + const membership = Object.assign({}, membershipTemplate); + const mockRoom = makeMockRoom([membership]); - sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - jest.advanceTimersByTime(10000); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + const membershipObject = sess.memberships[0]; - await eventSentPromise; + const onMembershipsChanged = jest.fn(); + sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); - expect(sendEventMock).toHaveBeenCalledTimes(2); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2); + jest.advanceTimersByTime(61 * 1000 * 1000); + + expect(onMembershipsChanged).toHaveBeenCalledWith([membershipObject], []); + expect(sess?.memberships.length).toEqual(0); } finally { jest.useRealTimers(); } }); + }); - it("cancels key send event that fail", async () => { - const eventSentinel = {} as unknown as MatrixEvent; + describe("key management", () => { + describe("sending", () => { + let mockRoom: Room; + let sendStateEventMock: jest.Mock; + let sendDelayedStateMock: jest.Mock; + let sendEventMock: jest.Mock; + + beforeEach(() => { + sendStateEventMock = jest.fn(); + sendDelayedStateMock = jest.fn(); + sendEventMock = jest.fn(); + client.sendStateEvent = sendStateEventMock; + client._unstable_sendDelayedStateEvent = sendDelayedStateMock; + client.sendEvent = sendEventMock; - client.cancelPendingEvent = jest.fn(); - sendEventMock.mockImplementation(() => { - const e = new Error() as MatrixError; - e.data = {}; - e.event = eventSentinel; - throw e; + mockRoom = makeMockRoom([]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); }); - sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + afterEach(() => { + // stop the timers + sess!.leaveRoomSession(); + }); - expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel); - }); + it("creates a key when joining", () => { + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + const encryptionKeyChangedListener = jest.fn(); + sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); + sess?.reemitEncryptionKeys(); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); + expect(encryptionKeyChangedListener).toHaveBeenCalledWith( + expect.any(Uint8Array), + 0, + "@alice:example.org:AAAAAAA", + ); + }); - it("Rotates key if a new member joins", async () => { - jest.useFakeTimers(); - try { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + it("sends keys when joining", async () => { + jest.useFakeTimers(); + try { + const eventSentPromise = new Promise((resolve) => { + sendEventMock.mockImplementation(resolve); + }); - const keysSentPromise1 = new Promise((resolve) => { - sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); - }); + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - const firstKeysPayload = await keysSentPromise1; - expect(firstKeysPayload.keys).toHaveLength(1); - expect(firstKeysPayload.keys[0].index).toEqual(0); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); + await eventSentPromise; - sendEventMock.mockClear(); - jest.advanceTimersByTime(10000); + expect(sendEventMock).toHaveBeenCalledWith( + expect.stringMatching(".*"), + "io.element.call.encryption_keys", + { + call_id: "", + device_id: "AAAAAAA", + keys: [ + { + index: 0, + key: expect.stringMatching(".*"), + }, + ], + sent_ts: Date.now(), + }, + ); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); + } finally { + jest.useRealTimers(); + } + }); - const keysSentPromise2 = new Promise((resolve) => { - sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); - }); + it("does not send key if join called when already joined", () => { + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - const onMembershipsChanged = jest.fn(); - sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + expect(client.sendEvent).toHaveBeenCalledTimes(1); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); - const member2 = Object.assign({}, membershipTemplate, { - device_id: "BBBBBBB", - }); + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + expect(client.sendEvent).toHaveBeenCalledTimes(1); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); + }); - mockRoom.getLiveTimeline().getState = jest - .fn() - .mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId)); - sess.onMembershipUpdate(); + it("retries key sends", async () => { + jest.useFakeTimers(); + let firstEventSent = false; + + try { + const eventSentPromise = new Promise((resolve) => { + sendEventMock.mockImplementation(() => { + if (!firstEventSent) { + jest.advanceTimersByTime(10000); + + firstEventSent = true; + const e = new Error() as MatrixError; + e.data = {}; + throw e; + } else { + resolve(); + } + }); + }); - jest.advanceTimersByTime(10000); + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + jest.advanceTimersByTime(10000); - const secondKeysPayload = await keysSentPromise2; + await eventSentPromise; - expect(sendEventMock).toHaveBeenCalled(); - expect(secondKeysPayload.keys).toHaveLength(1); - expect(secondKeysPayload.keys[0].index).toEqual(1); - expect(secondKeysPayload.keys[0].key).not.toEqual(firstKeysPayload.keys[0].key); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2); - } finally { - jest.useRealTimers(); - } - }); + expect(sendEventMock).toHaveBeenCalledTimes(2); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2); + } finally { + jest.useRealTimers(); + } + }); - it("Does not re-send key if memberships stays same", async () => { - jest.useFakeTimers(); - try { - const keysSentPromise1 = new Promise((resolve) => { - sendEventMock.mockImplementation(resolve); - }); + it("cancels key send event that fail", async () => { + const eventSentinel = {} as unknown as MatrixEvent; - const member1 = membershipTemplate; - const member2 = Object.assign({}, membershipTemplate, { - device_id: "BBBBBBB", + client.cancelPendingEvent = jest.fn(); + sendEventMock.mockImplementation(() => { + const e = new Error() as MatrixError; + e.data = {}; + e.event = eventSentinel; + throw e; }); - const mockRoom = makeMockRoom([member1, member2]); - mockRoom.getLiveTimeline().getState = jest - .fn() - .mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId)); + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel); + }); - await keysSentPromise1; + it("rotates key if a new member joins", async () => { + jest.useFakeTimers(); + try { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - // make sure an encryption key was sent - expect(sendEventMock).toHaveBeenCalledWith( - expect.stringMatching(".*"), - "io.element.call.encryption_keys", - { - call_id: "", - device_id: "AAAAAAA", - keys: [ - { - index: 0, - key: expect.stringMatching(".*"), - }, - ], - sent_ts: Date.now(), - }, - ); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); + const keysSentPromise1 = new Promise((resolve) => { + sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); + }); - sendEventMock.mockClear(); + sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + const firstKeysPayload = await keysSentPromise1; + expect(firstKeysPayload.keys).toHaveLength(1); + expect(firstKeysPayload.keys[0].index).toEqual(0); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); - // these should be a no-op: - sess.onMembershipUpdate(); - expect(sendEventMock).toHaveBeenCalledTimes(0); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); - } finally { - jest.useRealTimers(); - } - }); + sendEventMock.mockClear(); + jest.advanceTimersByTime(10000); - it("Re-sends key if a member changes membership ID", async () => { - jest.useFakeTimers(); - try { - const keysSentPromise1 = new Promise((resolve) => { - sendEventMock.mockImplementation(resolve); - }); + const keysSentPromise2 = new Promise((resolve) => { + sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); + }); - const member1 = membershipTemplate; - const member2 = { - ...membershipTemplate, - device_id: "BBBBBBB", - }; + const onMembershipsChanged = jest.fn(); + sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); - const mockRoom = makeMockRoom([member1, member2]); - mockRoom.getLiveTimeline().getState = jest - .fn() - .mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId)); + const member2 = Object.assign({}, membershipTemplate, { + device_id: "BBBBBBB", + }); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + mockRoom.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId)); + sess.onMembershipUpdate(); - await keysSentPromise1; + jest.advanceTimersByTime(10000); - // make sure an encryption key was sent - expect(sendEventMock).toHaveBeenCalledWith( - expect.stringMatching(".*"), - "io.element.call.encryption_keys", - { - call_id: "", - device_id: "AAAAAAA", - keys: [ - { - index: 0, - key: expect.stringMatching(".*"), - }, - ], - sent_ts: Date.now(), - }, - ); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); + const secondKeysPayload = await keysSentPromise2; - sendEventMock.mockClear(); + expect(sendEventMock).toHaveBeenCalled(); + expect(secondKeysPayload.keys).toHaveLength(1); + expect(secondKeysPayload.keys[0].index).toEqual(1); + expect(secondKeysPayload.keys[0].key).not.toEqual(firstKeysPayload.keys[0].key); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2); + } finally { + jest.useRealTimers(); + } + }); - // this should be a no-op: - sess.onMembershipUpdate(); - expect(sendEventMock).toHaveBeenCalledTimes(0); + it("does not re-send key if memberships stays same", async () => { + jest.useFakeTimers(); + try { + const keysSentPromise1 = new Promise((resolve) => { + sendEventMock.mockImplementation(resolve); + }); - // advance time to avoid key throttling - jest.advanceTimersByTime(10000); + const member1 = membershipTemplate; + const member2 = Object.assign({}, membershipTemplate, { + device_id: "BBBBBBB", + }); - // update membership ID - member2.membershipID = "newID"; + const mockRoom = makeMockRoom([member1, member2]); + mockRoom.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId)); - const keysSentPromise2 = new Promise((resolve) => { - sendEventMock.mockImplementation(resolve); - }); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - // this should re-send the key - sess.onMembershipUpdate(); + await keysSentPromise1; - await keysSentPromise2; + // make sure an encryption key was sent + expect(sendEventMock).toHaveBeenCalledWith( + expect.stringMatching(".*"), + "io.element.call.encryption_keys", + { + call_id: "", + device_id: "AAAAAAA", + keys: [ + { + index: 0, + key: expect.stringMatching(".*"), + }, + ], + sent_ts: Date.now(), + }, + ); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); - expect(sendEventMock).toHaveBeenCalledWith( - expect.stringMatching(".*"), - "io.element.call.encryption_keys", - { - call_id: "", - device_id: "AAAAAAA", - keys: [ - { - index: 0, - key: expect.stringMatching(".*"), - }, - ], - sent_ts: Date.now(), - }, - ); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2); - } finally { - jest.useRealTimers(); - } - }); + sendEventMock.mockClear(); - it("Re-sends key if a member changes created_ts", async () => { - jest.useFakeTimers(); - jest.setSystemTime(1000); - try { - const keysSentPromise1 = new Promise((resolve) => { - sendEventMock.mockImplementation(resolve); - }); + // these should be a no-op: + sess.onMembershipUpdate(); + expect(sendEventMock).toHaveBeenCalledTimes(0); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); + } finally { + jest.useRealTimers(); + } + }); - const member1 = { ...membershipTemplate, created_ts: 1000 }; - const member2 = { - ...membershipTemplate, - created_ts: 1000, - device_id: "BBBBBBB", - }; + it("re-sends key if a member changes membership ID", async () => { + jest.useFakeTimers(); + try { + const keysSentPromise1 = new Promise((resolve) => { + sendEventMock.mockImplementation(resolve); + }); - const mockRoom = makeMockRoom([member1, member2]); - mockRoom.getLiveTimeline().getState = jest - .fn() - .mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId)); + const member1 = membershipTemplate; + const member2 = { + ...membershipTemplate, + device_id: "BBBBBBB", + }; - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + const mockRoom = makeMockRoom([member1, member2]); + mockRoom.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId)); - await keysSentPromise1; + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - // make sure an encryption key was sent - expect(sendEventMock).toHaveBeenCalledWith( - expect.stringMatching(".*"), - "io.element.call.encryption_keys", - { - call_id: "", - device_id: "AAAAAAA", - keys: [ - { - index: 0, - key: expect.stringMatching(".*"), - }, - ], - sent_ts: Date.now(), - }, - ); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); + await keysSentPromise1; - sendEventMock.mockClear(); + // make sure an encryption key was sent + expect(sendEventMock).toHaveBeenCalledWith( + expect.stringMatching(".*"), + "io.element.call.encryption_keys", + { + call_id: "", + device_id: "AAAAAAA", + keys: [ + { + index: 0, + key: expect.stringMatching(".*"), + }, + ], + sent_ts: Date.now(), + }, + ); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); - // this should be a no-op: - sess.onMembershipUpdate(); - expect(sendEventMock).toHaveBeenCalledTimes(0); + sendEventMock.mockClear(); - // advance time to avoid key throttling - jest.advanceTimersByTime(10000); + // this should be a no-op: + sess.onMembershipUpdate(); + expect(sendEventMock).toHaveBeenCalledTimes(0); - // update created_ts - member2.created_ts = 5000; + // advance time to avoid key throttling + jest.advanceTimersByTime(10000); - const keysSentPromise2 = new Promise((resolve) => { - sendEventMock.mockImplementation(resolve); - }); + // update membership ID + member2.membershipID = "newID"; - // this should re-send the key - sess.onMembershipUpdate(); + const keysSentPromise2 = new Promise((resolve) => { + sendEventMock.mockImplementation(resolve); + }); - await keysSentPromise2; + // this should re-send the key + sess.onMembershipUpdate(); - expect(sendEventMock).toHaveBeenCalledWith( - expect.stringMatching(".*"), - "io.element.call.encryption_keys", - { - call_id: "", - device_id: "AAAAAAA", - keys: [ - { - index: 0, - key: expect.stringMatching(".*"), - }, - ], - sent_ts: Date.now(), - }, - ); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2); - } finally { - jest.useRealTimers(); - } - }); + await keysSentPromise2; - it("Rotates key if a member leaves", async () => { - jest.useFakeTimers(); - try { - const member2 = Object.assign({}, membershipTemplate, { - device_id: "BBBBBBB", - }); - const mockRoom = makeMockRoom([membershipTemplate, member2]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sendEventMock).toHaveBeenCalledWith( + expect.stringMatching(".*"), + "io.element.call.encryption_keys", + { + call_id: "", + device_id: "AAAAAAA", + keys: [ + { + index: 0, + key: expect.stringMatching(".*"), + }, + ], + sent_ts: Date.now(), + }, + ); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2); + } finally { + jest.useRealTimers(); + } + }); - const onMyEncryptionKeyChanged = jest.fn(); - sess.on( - MatrixRTCSessionEvent.EncryptionKeyChanged, - (_key: Uint8Array, _idx: number, participantId: string) => { - if (participantId === `${client.getUserId()}:${client.getDeviceId()}`) { - onMyEncryptionKeyChanged(); - } - }, - ); + it("re-sends key if a member changes created_ts", async () => { + jest.useFakeTimers(); + jest.setSystemTime(1000); + try { + const keysSentPromise1 = new Promise((resolve) => { + sendEventMock.mockImplementation(resolve); + }); - const keysSentPromise1 = new Promise((resolve) => { - sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); - }); + const member1 = { ...membershipTemplate, created_ts: 1000 }; + const member2 = { + ...membershipTemplate, + created_ts: 1000, + device_id: "BBBBBBB", + }; - sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - const firstKeysPayload = await keysSentPromise1; - expect(firstKeysPayload.keys).toHaveLength(1); - expect(firstKeysPayload.keys[0].index).toEqual(0); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); + const mockRoom = makeMockRoom([member1, member2]); + mockRoom.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState([member1, member2], mockRoom.roomId)); - sendEventMock.mockClear(); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - const keysSentPromise2 = new Promise((resolve) => { - sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); - }); + await keysSentPromise1; - mockRoom.getLiveTimeline().getState = jest - .fn() - .mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId)); - sess.onMembershipUpdate(); + // make sure an encryption key was sent + expect(sendEventMock).toHaveBeenCalledWith( + expect.stringMatching(".*"), + "io.element.call.encryption_keys", + { + call_id: "", + device_id: "AAAAAAA", + keys: [ + { + index: 0, + key: expect.stringMatching(".*"), + }, + ], + sent_ts: Date.now(), + }, + ); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); - jest.advanceTimersByTime(10000); + sendEventMock.mockClear(); - const secondKeysPayload = await keysSentPromise2; + // this should be a no-op: + sess.onMembershipUpdate(); + expect(sendEventMock).toHaveBeenCalledTimes(0); - expect(secondKeysPayload.keys).toHaveLength(1); - expect(secondKeysPayload.keys[0].index).toEqual(1); - expect(onMyEncryptionKeyChanged).toHaveBeenCalledTimes(2); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2); - } finally { - jest.useRealTimers(); - } - }); + // advance time to avoid key throttling + jest.advanceTimersByTime(10000); - it("Wraps key index around to 0 when it reaches the maximum", async () => { - // this should give us keys with index [0...255, 0, 1] - const membersToTest = 258; - const members: CallMembershipData[] = []; - for (let i = 0; i < membersToTest; i++) { - members.push(Object.assign({}, membershipTemplate, { device_id: `DEVICE${i}` })); - } - jest.useFakeTimers(); - try { - // start with a single member - const mockRoom = makeMockRoom(members.slice(0, 1)); + // update created_ts + member2.created_ts = 5000; - for (let i = 0; i < membersToTest; i++) { - const keysSentPromise = new Promise((resolve) => { - sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); + const keysSentPromise2 = new Promise((resolve) => { + sendEventMock.mockImplementation(resolve); }); - if (i === 0) { - // if first time around then set up the session - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - } else { - // otherwise update the state - mockRoom.getLiveTimeline().getState = jest - .fn() - .mockReturnValue(makeMockRoomState(members.slice(0, i + 1), mockRoom.roomId)); - } - - sess!.onMembershipUpdate(); + // this should re-send the key + sess.onMembershipUpdate(); - // advance time to avoid key throttling - jest.advanceTimersByTime(10000); + await keysSentPromise2; - const keysPayload = await keysSentPromise; - expect(keysPayload.keys).toHaveLength(1); - expect(keysPayload.keys[0].index).toEqual(i % 256); + expect(sendEventMock).toHaveBeenCalledWith( + expect.stringMatching(".*"), + "io.element.call.encryption_keys", + { + call_id: "", + device_id: "AAAAAAA", + keys: [ + { + index: 0, + key: expect.stringMatching(".*"), + }, + ], + sent_ts: Date.now(), + }, + ); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2); + } finally { + jest.useRealTimers(); } - } finally { - jest.useRealTimers(); - } - }); + }); - it("Doesn't re-send key immediately", async () => { - const realSetTimeout = setTimeout; - jest.useFakeTimers(); - try { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + it("rotates key if a member leaves", async () => { + jest.useFakeTimers(); + try { + const member2 = Object.assign({}, membershipTemplate, { + device_id: "BBBBBBB", + }); + const mockRoom = makeMockRoom([membershipTemplate, member2]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + const onMyEncryptionKeyChanged = jest.fn(); + sess.on( + MatrixRTCSessionEvent.EncryptionKeyChanged, + (_key: Uint8Array, _idx: number, participantId: string) => { + if (participantId === `${client.getUserId()}:${client.getDeviceId()}`) { + onMyEncryptionKeyChanged(); + } + }, + ); - const keysSentPromise1 = new Promise((resolve) => { - sendEventMock.mockImplementation(resolve); - }); + const keysSentPromise1 = new Promise((resolve) => { + sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); + }); - sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - await keysSentPromise1; + sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + const firstKeysPayload = await keysSentPromise1; + expect(firstKeysPayload.keys).toHaveLength(1); + expect(firstKeysPayload.keys[0].index).toEqual(0); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); - sendEventMock.mockClear(); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); + sendEventMock.mockClear(); - const onMembershipsChanged = jest.fn(); - sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); + const keysSentPromise2 = new Promise((resolve) => { + sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); + }); - const member2 = Object.assign({}, membershipTemplate, { - device_id: "BBBBBBB", - }); + mockRoom.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId)); + sess.onMembershipUpdate(); - mockRoom.getLiveTimeline().getState = jest - .fn() - .mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId)); - sess.onMembershipUpdate(); + jest.advanceTimersByTime(10000); - await new Promise((resolve) => { - realSetTimeout(resolve); - }); + const secondKeysPayload = await keysSentPromise2; - expect(sendEventMock).not.toHaveBeenCalled(); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); - } finally { - jest.useRealTimers(); - } - }); - }); + expect(secondKeysPayload.keys).toHaveLength(1); + expect(secondKeysPayload.keys[0].index).toEqual(1); + expect(onMyEncryptionKeyChanged).toHaveBeenCalledTimes(2); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2); + } finally { + jest.useRealTimers(); + } + }); - it("Does not emit if no membership changes", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + it("wraps key index around to 0 when it reaches the maximum", async () => { + // this should give us keys with index [0...255, 0, 1] + const membersToTest = 258; + const members: CallMembershipData[] = []; + for (let i = 0; i < membersToTest; i++) { + members.push(Object.assign({}, membershipTemplate, { device_id: `DEVICE${i}` })); + } + jest.useFakeTimers(); + try { + // start with a single member + const mockRoom = makeMockRoom(members.slice(0, 1)); + + for (let i = 0; i < membersToTest; i++) { + const keysSentPromise = new Promise((resolve) => { + sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); + }); + + if (i === 0) { + // if first time around then set up the session + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + } else { + // otherwise update the state + mockRoom.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState(members.slice(0, i + 1), mockRoom.roomId)); + } - const onMembershipsChanged = jest.fn(); - sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); - sess.onMembershipUpdate(); + sess!.onMembershipUpdate(); - expect(onMembershipsChanged).not.toHaveBeenCalled(); - }); + // advance time to avoid key throttling + jest.advanceTimersByTime(10000); - it("Emits on membership changes", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + const keysPayload = await keysSentPromise; + expect(keysPayload.keys).toHaveLength(1); + expect(keysPayload.keys[0].index).toEqual(i % 256); + } + } finally { + jest.useRealTimers(); + } + }); - const onMembershipsChanged = jest.fn(); - sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); + it("doesn't re-send key immediately", async () => { + const realSetTimeout = setTimeout; + jest.useFakeTimers(); + try { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - mockRoom.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState([], mockRoom.roomId)); - sess.onMembershipUpdate(); + const keysSentPromise1 = new Promise((resolve) => { + sendEventMock.mockImplementation(resolve); + }); - expect(onMembershipsChanged).toHaveBeenCalled(); - }); + sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + await keysSentPromise1; - it("emits an event at the time a membership event expires", () => { - jest.useFakeTimers(); - try { - const membership = Object.assign({}, membershipTemplate); - const mockRoom = makeMockRoom([membership]); + sendEventMock.mockClear(); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - const membershipObject = sess.memberships[0]; + const onMembershipsChanged = jest.fn(); + sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); - const onMembershipsChanged = jest.fn(); - sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); + const member2 = Object.assign({}, membershipTemplate, { + device_id: "BBBBBBB", + }); - jest.advanceTimersByTime(61 * 1000 * 1000); + mockRoom.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId)); + sess.onMembershipUpdate(); - expect(onMembershipsChanged).toHaveBeenCalledWith([membershipObject], []); - expect(sess?.memberships.length).toEqual(0); - } finally { - jest.useRealTimers(); - } - }); + await new Promise((resolve) => { + realSetTimeout(resolve); + }); - it("prunes expired memberships on update", () => { - jest.useFakeTimers(); - try { - client.sendStateEvent = jest.fn(); + expect(sendEventMock).not.toHaveBeenCalled(); + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); + } finally { + jest.useRealTimers(); + } + }); + }); - const mockMemberships = [ - Object.assign({}, membershipTemplate, { - device_id: "OTHERDEVICE", - expires: 1000, - }), - ]; - const mockRoomNoExpired = makeMockRoom(mockMemberships); + describe("receiving", () => { + it("collects keys from encryption events", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: "bobsphone", + call_id: "", + keys: [ + { + index: 0, + key: "dGhpcyBpcyB0aGUga2V5", + }, + ], + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + getTs: jest.fn().mockReturnValue(Date.now()), + } as unknown as MatrixEvent); + + const encryptionKeyChangedListener = jest.fn(); + sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); + sess!.reemitEncryptionKeys(); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); + expect(encryptionKeyChangedListener).toHaveBeenCalledWith( + Buffer.from("this is the key", "utf-8"), + 0, + "@bob:example.org:bobsphone", + ); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoomNoExpired); + expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1); + }); - // sanity check - expect(sess.memberships).toHaveLength(1); - expect(sess.memberships[0].deviceId).toEqual("OTHERDEVICE"); + it("collects keys at non-zero indices", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: "bobsphone", + call_id: "", + keys: [ + { + index: 4, + key: "dGhpcyBpcyB0aGUga2V5", + }, + ], + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + getTs: jest.fn().mockReturnValue(Date.now()), + } as unknown as MatrixEvent); + + const encryptionKeyChangedListener = jest.fn(); + sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); + sess!.reemitEncryptionKeys(); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); + expect(encryptionKeyChangedListener).toHaveBeenCalledWith( + Buffer.from("this is the key", "utf-8"), + 4, + "@bob:example.org:bobsphone", + ); - jest.advanceTimersByTime(10000); + expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1); + }); - sess.joinRoomSession([mockFocus], mockFocus); + it("collects keys by merging", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: "bobsphone", + call_id: "", + keys: [ + { + index: 0, + key: "dGhpcyBpcyB0aGUga2V5", + }, + ], + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + getTs: jest.fn().mockReturnValue(Date.now()), + } as unknown as MatrixEvent); + + const encryptionKeyChangedListener = jest.fn(); + sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); + sess!.reemitEncryptionKeys(); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); + expect(encryptionKeyChangedListener).toHaveBeenCalledWith( + Buffer.from("this is the key", "utf-8"), + 0, + "@bob:example.org:bobsphone", + ); - expect(client.sendStateEvent).toHaveBeenCalledWith( - mockRoomNoExpired!.roomId, - EventType.GroupCallMemberPrefix, - { - memberships: [ - { - application: "m.call", - scope: "m.room", - call_id: "", - device_id: "AAAAAAA", - expires: 3600000, - expires_ts: Date.now() + 3600000, - foci_active: [mockFocus], - membershipID: expect.stringMatching(".*"), - }, - ], - }, - "@alice:example.org", - ); - } finally { - jest.useRealTimers(); - } - }); + expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1); - it("fills in created_ts for other memberships on update", () => { - client.sendStateEvent = jest.fn(); - jest.useFakeTimers(); - jest.setSystemTime(1000); - const mockRoom = makeMockRoom([ - Object.assign({}, membershipTemplate, { - device_id: "OTHERDEVICE", - }), - ]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - - sess.joinRoomSession([mockFocus], mockFocus); - - expect(client.sendStateEvent).toHaveBeenCalledWith( - mockRoom!.roomId, - EventType.GroupCallMemberPrefix, - { - memberships: [ - { - application: "m.call", - scope: "m.room", - call_id: "", - device_id: "OTHERDEVICE", - expires: 3600000, - created_ts: 1000, - foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }], - membershipID: expect.stringMatching(".*"), - }, - { - application: "m.call", - scope: "m.room", + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: "bobsphone", call_id: "", - device_id: "AAAAAAA", - expires: 3600000, - expires_ts: Date.now() + 3600000, - foci_active: [mockFocus], - membershipID: expect.stringMatching(".*"), - }, - ], - }, - "@alice:example.org", - ); - jest.useRealTimers(); - }); - - it("collects keys from encryption events", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); - - const encryptionKeyChangedListener = jest.fn(); - sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); - sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); - expect(encryptionKeyChangedListener).toHaveBeenCalledWith( - Buffer.from("this is the key", "utf-8"), - 0, - "@bob:example.org:bobsphone", - ); - - expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1); - }); + keys: [ + { + index: 4, + key: "dGhpcyBpcyB0aGUga2V5", + }, + ], + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + getTs: jest.fn().mockReturnValue(Date.now()), + } as unknown as MatrixEvent); + + encryptionKeyChangedListener.mockClear(); + sess!.reemitEncryptionKeys(); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2); + expect(encryptionKeyChangedListener).toHaveBeenCalledWith( + Buffer.from("this is the key", "utf-8"), + 0, + "@bob:example.org:bobsphone", + ); + expect(encryptionKeyChangedListener).toHaveBeenCalledWith( + Buffer.from("this is the key", "utf-8"), + 4, + "@bob:example.org:bobsphone", + ); - it("collects keys at non-zero indices", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 4, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); - - const encryptionKeyChangedListener = jest.fn(); - sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); - sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); - expect(encryptionKeyChangedListener).toHaveBeenCalledWith( - Buffer.from("this is the key", "utf-8"), - 4, - "@bob:example.org:bobsphone", - ); - - expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1); - }); + expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2); + }); - it("collects keys by merging", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); - - const encryptionKeyChangedListener = jest.fn(); - sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); - sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); - expect(encryptionKeyChangedListener).toHaveBeenCalledWith( - Buffer.from("this is the key", "utf-8"), - 0, - "@bob:example.org:bobsphone", - ); - - expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1); - - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 4, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); - - encryptionKeyChangedListener.mockClear(); - sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2); - expect(encryptionKeyChangedListener).toHaveBeenCalledWith( - Buffer.from("this is the key", "utf-8"), - 0, - "@bob:example.org:bobsphone", - ); - expect(encryptionKeyChangedListener).toHaveBeenCalledWith( - Buffer.from("this is the key", "utf-8"), - 4, - "@bob:example.org:bobsphone", - ); - - expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2); - }); + it("ignores older keys at same index", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: "bobsphone", + call_id: "", + keys: [ + { + index: 0, + key: encodeBase64(Buffer.from("newer key", "utf-8")), + }, + ], + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + getTs: jest.fn().mockReturnValue(2000), + } as unknown as MatrixEvent); + + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: "bobsphone", + call_id: "", + keys: [ + { + index: 0, + key: encodeBase64(Buffer.from("older key", "utf-8")), + }, + ], + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + getTs: jest.fn().mockReturnValue(1000), // earlier timestamp than the newer key + } as unknown as MatrixEvent); + + const encryptionKeyChangedListener = jest.fn(); + sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); + sess!.reemitEncryptionKeys(); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); + expect(encryptionKeyChangedListener).toHaveBeenCalledWith( + Buffer.from("newer key", "utf-8"), + 0, + "@bob:example.org:bobsphone", + ); - it("ignores older keys at same index", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: encodeBase64(Buffer.from("newer key", "utf-8")), - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(2000), - } as unknown as MatrixEvent); - - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: encodeBase64(Buffer.from("older key", "utf-8")), - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(1000), // earlier timestamp than the newer key - } as unknown as MatrixEvent); - - const encryptionKeyChangedListener = jest.fn(); - sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); - sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); - expect(encryptionKeyChangedListener).toHaveBeenCalledWith( - Buffer.from("newer key", "utf-8"), - 0, - "@bob:example.org:bobsphone", - ); - - expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2); - }); + expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2); + }); - it("key timestamps are treated as monotonic", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: encodeBase64(Buffer.from("first key", "utf-8")), - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(1000), - } as unknown as MatrixEvent); - - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: encodeBase64(Buffer.from("second key", "utf-8")), - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(1000), // same timestamp as the first key - } as unknown as MatrixEvent); - - const encryptionKeyChangedListener = jest.fn(); - sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); - sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); - expect(encryptionKeyChangedListener).toHaveBeenCalledWith( - Buffer.from("second key", "utf-8"), - 0, - "@bob:example.org:bobsphone", - ); - }); + it("key timestamps are treated as monotonic", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: "bobsphone", + call_id: "", + keys: [ + { + index: 0, + key: encodeBase64(Buffer.from("first key", "utf-8")), + }, + ], + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + getTs: jest.fn().mockReturnValue(1000), + } as unknown as MatrixEvent); + + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: "bobsphone", + call_id: "", + keys: [ + { + index: 0, + key: encodeBase64(Buffer.from("second key", "utf-8")), + }, + ], + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + getTs: jest.fn().mockReturnValue(1000), // same timestamp as the first key + } as unknown as MatrixEvent); + + const encryptionKeyChangedListener = jest.fn(); + sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); + sess!.reemitEncryptionKeys(); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); + expect(encryptionKeyChangedListener).toHaveBeenCalledWith( + Buffer.from("second key", "utf-8"), + 0, + "@bob:example.org:bobsphone", + ); + }); - it("ignores keys event for the local participant", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: client.getDeviceId(), - call_id: "", - keys: [ - { - index: 4, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], - }), - getSender: jest.fn().mockReturnValue(client.getUserId()), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); - - const encryptionKeyChangedListener = jest.fn(); - sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); - sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(0); - - expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(0); - }); + it("ignores keys event for the local participant", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: client.getDeviceId(), + call_id: "", + keys: [ + { + index: 4, + key: "dGhpcyBpcyB0aGUga2V5", + }, + ], + }), + getSender: jest.fn().mockReturnValue(client.getUserId()), + getTs: jest.fn().mockReturnValue(Date.now()), + } as unknown as MatrixEvent); - it("tracks total age statistics for collected keys", () => { - jest.useFakeTimers(); - try { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + const encryptionKeyChangedListener = jest.fn(); + sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); + sess!.reemitEncryptionKeys(); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(0); - // defaults to getTs() - jest.setSystemTime(1000); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(0), - } as unknown as MatrixEvent); - expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(1000); - - jest.setSystemTime(2000); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], - sent_ts: 0, - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); - expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(3000); + expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(0); + }); - jest.setSystemTime(3000); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], - sent_ts: 1000, - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); - expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(5000); - } finally { - jest.useRealTimers(); - } + it("tracks total age statistics for collected keys", () => { + jest.useFakeTimers(); + try { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + // defaults to getTs() + jest.setSystemTime(1000); + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: "bobsphone", + call_id: "", + keys: [ + { + index: 0, + key: "dGhpcyBpcyB0aGUga2V5", + }, + ], + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + getTs: jest.fn().mockReturnValue(0), + } as unknown as MatrixEvent); + expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(1000); + + jest.setSystemTime(2000); + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: "bobsphone", + call_id: "", + keys: [ + { + index: 0, + key: "dGhpcyBpcyB0aGUga2V5", + }, + ], + sent_ts: 0, + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + getTs: jest.fn().mockReturnValue(Date.now()), + } as unknown as MatrixEvent); + expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(3000); + + jest.setSystemTime(3000); + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: "bobsphone", + call_id: "", + keys: [ + { + index: 0, + key: "dGhpcyBpcyB0aGUga2V5", + }, + ], + sent_ts: 1000, + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + getTs: jest.fn().mockReturnValue(Date.now()), + } as unknown as MatrixEvent); + expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(5000); + } finally { + jest.useRealTimers(); + } + }); + }); }); });