/* Copyright 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { type MockedObject } from "jest-mock"; import * as utils from "../test-utils/test-utils"; import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon"; import { filterEmitCallsByEventType } from "../test-utils/emitter"; import { RoomState, RoomStateEvent } from "../../src/models/room-state"; import { type Beacon, BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon"; import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event"; import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { M_BEACON } from "../../src/@types/beacon"; import { type MatrixClient } from "../../src/client"; import { defer } from "../../src/utils"; import { Room } from "../../src/models/room"; import { KnownMembership } from "../../src/@types/membership"; import { DecryptionFailureCode } from "../../src/crypto-api"; import { DecryptionError } from "../../src/common-crypto/CryptoBackend"; describe("RoomState", function () { const roomId = "!foo:bar"; const userA = "@alice:bar"; const userB = "@bob:bar"; const userC = "@cleo:bar"; const userLazy = "@lazy:bar"; let state = new RoomState(roomId); beforeEach(function () { state = new RoomState(roomId); state.setStateEvents([ utils.mkMembership({ // userA joined event: true, mship: KnownMembership.Join, user: userA, room: roomId, }), utils.mkMembership({ // userB joined event: true, mship: KnownMembership.Join, user: userB, room: roomId, }), utils.mkEvent({ // Room name is "Room name goes here" type: "m.room.name", user: userA, room: roomId, event: true, content: { name: "Room name goes here", }, }), utils.mkEvent({ // Room creation type: "m.room.create", user: userA, room: roomId, event: true, content: {}, }), ]); }); describe("getMembers", function () { it("should return an empty list if there are no members", function () { state = new RoomState(roomId); expect(state.getMembers().length).toEqual(0); }); it("should return a member for each m.room.member event", function () { const members = state.getMembers(); expect(members.length).toEqual(2); // ordering unimportant expect([userA, userB].indexOf(members[0].userId)).not.toEqual(-1); expect([userA, userB].indexOf(members[1].userId)).not.toEqual(-1); }); }); describe("getMember", function () { it("should return null if there is no member", function () { expect(state.getMember("@no-one:here")).toEqual(null); }); it("should return a member if they exist", function () { expect(state.getMember(userB)).toBeTruthy(); }); it("should return a member which changes as state changes", function () { const member = state.getMember(userB); expect(member?.membership).toEqual(KnownMembership.Join); expect(member?.name).toEqual(userB); state.setStateEvents([ utils.mkMembership({ room: roomId, user: userB, mship: KnownMembership.Leave, event: true, name: "BobGone", }), ]); expect(member?.membership).toEqual(KnownMembership.Leave); expect(member?.name).toEqual("BobGone"); }); }); describe("getSentinelMember", function () { it("should return a member with the user id as name", function () { expect(state.getSentinelMember("@no-one:here")?.name).toEqual("@no-one:here"); }); it("should return a member which doesn't change when the state is updated", function () { const preLeaveUser = state.getSentinelMember(userA); state.setStateEvents([ utils.mkMembership({ room: roomId, user: userA, mship: KnownMembership.Leave, event: true, name: "AliceIsGone", }), ]); const postLeaveUser = state.getSentinelMember(userA); expect(preLeaveUser?.membership).toEqual(KnownMembership.Join); expect(preLeaveUser?.name).toEqual(userA); expect(postLeaveUser?.membership).toEqual(KnownMembership.Leave); expect(postLeaveUser?.name).toEqual("AliceIsGone"); }); }); describe("getStateEvents", function () { it("should return null if a state_key was specified and there was no match", function () { expect(state.getStateEvents("foo.bar.baz", "keyname")).toEqual(null); }); it("should return an empty list if a state_key was not specified and there" + " was no match", function () { expect(state.getStateEvents("foo.bar.baz")).toEqual([]); }); it("should return a list of matching events if no state_key was specified", function () { const events = state.getStateEvents("m.room.member"); expect(events.length).toEqual(2); // ordering unimportant expect([userA, userB].indexOf(events[0].getStateKey() as string)).not.toEqual(-1); expect([userA, userB].indexOf(events[1].getStateKey() as string)).not.toEqual(-1); }); it("should return a single MatrixEvent if a state_key was specified", function () { const event = state.getStateEvents("m.room.member", userA); expect(event?.getContent()).toMatchObject({ membership: KnownMembership.Join, }); }); }); describe("setStateEvents", function () { it("should emit 'RoomState.members' for each m.room.member event", function () { const memberEvents = [ utils.mkMembership({ user: "@cleo:bar", mship: KnownMembership.Invite, room: roomId, event: true, }), utils.mkMembership({ user: "@daisy:bar", mship: KnownMembership.Join, room: roomId, event: true, }), ]; let emitCount = 0; state.on(RoomStateEvent.Members, function (ev, st, mem) { expect(ev).toEqual(memberEvents[emitCount]); expect(st).toEqual(state); expect(mem).toEqual(state.getMember(ev.getSender()!)); emitCount += 1; }); state.setStateEvents(memberEvents); expect(emitCount).toEqual(2); }); it("should emit 'RoomState.newMember' for each new member added", function () { const memberEvents = [ utils.mkMembership({ user: "@cleo:bar", mship: KnownMembership.Invite, room: roomId, event: true, }), utils.mkMembership({ user: "@daisy:bar", mship: KnownMembership.Join, room: roomId, event: true, }), ]; let emitCount = 0; state.on(RoomStateEvent.NewMember, function (ev, st, mem) { expect(state.getMember(mem.userId)).toEqual(mem); expect(mem.userId).toEqual(memberEvents[emitCount].getSender()); expect(mem.membership).toBeFalsy(); // not defined yet emitCount += 1; }); state.setStateEvents(memberEvents); expect(emitCount).toEqual(2); }); it("should emit 'RoomState.events' for each state event", function () { const events = [ utils.mkMembership({ user: "@cleo:bar", mship: KnownMembership.Invite, room: roomId, event: true, }), utils.mkEvent({ user: userB, room: roomId, type: "m.room.topic", event: true, content: { topic: "boo!", }, }), utils.mkMessage({ // Not a state event user: userA, room: roomId, event: true, }), ]; let emitCount = 0; state.on(RoomStateEvent.Events, function (ev, st) { expect(ev).toEqual(events[emitCount]); expect(st).toEqual(state); emitCount += 1; }); state.setStateEvents(events); expect(emitCount).toEqual(2); }); it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", function () { const powerLevelEvent = utils.mkEvent({ type: "m.room.power_levels", room: roomId, user: userA, event: true, content: { users_default: 10, state_default: 50, events_default: 25, }, }); // spy on the room members jest.spyOn(state.members[userA], "setPowerLevelEvent"); jest.spyOn(state.members[userB], "setPowerLevelEvent"); state.setStateEvents([powerLevelEvent]); expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent); expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent); }); it("should call setPowerLevelEvent on a new RoomMember if power levels exist", function () { const memberEvent = utils.mkMembership({ mship: KnownMembership.Join, user: userC, room: roomId, event: true, }); const powerLevelEvent = utils.mkEvent({ type: "m.room.power_levels", room: roomId, user: userA, event: true, content: { users_default: 10, state_default: 50, events_default: 25, users: {}, }, }); state.setStateEvents([powerLevelEvent]); state.setStateEvents([memberEvent]); // TODO: We do this because we don't DI the RoomMember constructor // so we can't inject a mock :/ so we have to infer. expect(state.members[userC]).toBeTruthy(); expect(state.members[userC].powerLevel).toEqual(10); }); it("should call setMembershipEvent on the right RoomMember", function () { const memberEvent = utils.mkMembership({ user: userB, mship: KnownMembership.Leave, room: roomId, event: true, }); // spy on the room members jest.spyOn(state.members[userA], "setMembershipEvent"); jest.spyOn(state.members[userB], "setMembershipEvent"); state.setStateEvents([memberEvent]); expect(state.members[userA].setMembershipEvent).not.toHaveBeenCalled(); expect(state.members[userB].setMembershipEvent).toHaveBeenCalledWith(memberEvent, state); }); it("should emit `RoomStateEvent.Marker` for each marker event", function () { const events = [ utils.mkEvent({ event: true, type: UNSTABLE_MSC2716_MARKER.name, room: roomId, user: userA, skey: "", content: { "m.insertion_id": "$abc", }, }), ]; let emitCount = 0; state.on(RoomStateEvent.Marker, function (markerEvent, markerFoundOptions) { expect(markerEvent).toEqual(events[emitCount]); expect(markerFoundOptions).toEqual({ timelineWasEmpty: true }); emitCount += 1; }); state.setStateEvents(events, { timelineWasEmpty: true }); expect(emitCount).toEqual(1); }); }); describe("beacon events", () => { it("adds new beacon info events to state and emits", () => { const beaconEvent = makeBeaconInfoEvent(userA, roomId); const emitSpy = jest.spyOn(state, "emit"); state.setStateEvents([beaconEvent]); expect(state.beacons.size).toEqual(1); const beaconInstance = state.beacons.get(`${roomId}_${userA}`); expect(beaconInstance).toBeTruthy(); expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.New, beaconEvent, beaconInstance); }); it("does not add redacted beacon info events to state", () => { const mockClient = {} as unknown as MockedObject; const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId); const redactionEvent = new MatrixEvent({ type: "m.room.redaction" }); const room = new Room(roomId, mockClient, userA); redactedBeaconEvent.makeRedacted(redactionEvent, room); const emitSpy = jest.spyOn(state, "emit"); state.setStateEvents([redactedBeaconEvent]); // no beacon added expect(state.beacons.size).toEqual(0); expect(state.beacons.get(getBeaconInfoIdentifier(redactedBeaconEvent))).toBeFalsy(); // no new beacon emit expect(filterEmitCallsByEventType(BeaconEvent.New, emitSpy).length).toBeFalsy(); }); it("updates existing beacon info events in state", () => { const beaconId = "$beacon1"; const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId); state.setStateEvents([beaconEvent]); const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent)); expect(beaconInstance?.isLive).toEqual(true); state.setStateEvents([updatedBeaconEvent]); // same Beacon expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(beaconInstance); // updated liveness expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))?.isLive).toEqual(false); }); it("destroys and removes redacted beacon events", () => { const mockClient = {} as unknown as MockedObject; const beaconId = "$beacon1"; const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); const redactionEvent = new MatrixEvent({ type: "m.room.redaction", redacts: beaconEvent.getId() }); const room = new Room(roomId, mockClient, userA); redactedBeaconEvent.makeRedacted(redactionEvent, room); state.setStateEvents([beaconEvent]); const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent)); const destroySpy = jest.spyOn(beaconInstance as Beacon, "destroy"); expect(beaconInstance?.isLive).toEqual(true); state.setStateEvents([redactedBeaconEvent]); expect(destroySpy).toHaveBeenCalled(); expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(undefined); }); it("updates live beacon ids once after setting state events", () => { const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, "$beacon1"); const deadBeaconEvent = makeBeaconInfoEvent(userB, roomId, { isLive: false }, "$beacon2"); const emitSpy = jest.spyOn(state, "emit"); state.setStateEvents([liveBeaconEvent, deadBeaconEvent]); // called once expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(1); // live beacon is now not live const updatedLiveBeaconEvent = makeBeaconInfoEvent( userA, roomId, { isLive: false }, liveBeaconEvent.getId(), ); state.setStateEvents([updatedLiveBeaconEvent]); expect(state.hasLiveBeacons).toBe(false); expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(3); expect(emitSpy).toHaveBeenCalledWith(RoomStateEvent.BeaconLiveness, state, false); }); }); describe("setOutOfBandMembers", function () { it("should add a new member", function () { const oobMemberEvent = utils.mkMembership({ user: userLazy, mship: KnownMembership.Join, room: roomId, event: true, }); state.markOutOfBandMembersStarted(); state.setOutOfBandMembers([oobMemberEvent]); const member = state.getMember(userLazy); expect(member?.userId).toEqual(userLazy); expect(member?.isOutOfBand()).toEqual(true); }); it("should have no effect when not in correct status", function () { state.setOutOfBandMembers([ utils.mkMembership({ user: userLazy, mship: KnownMembership.Join, room: roomId, event: true, }), ]); expect(state.getMember(userLazy)).toBeFalsy(); }); it("should emit newMember when adding a member", function () { const userLazy = "@oob:hs"; const oobMemberEvent = utils.mkMembership({ user: userLazy, mship: KnownMembership.Join, room: roomId, event: true, }); let eventReceived = false; state.once(RoomStateEvent.NewMember, (_event, _state, member) => { expect(member.userId).toEqual(userLazy); eventReceived = true; }); state.markOutOfBandMembersStarted(); state.setOutOfBandMembers([oobMemberEvent]); expect(eventReceived).toEqual(true); }); it("should never overwrite existing members", function () { const oobMemberEvent = utils.mkMembership({ user: userA, mship: KnownMembership.Join, room: roomId, event: true, }); state.markOutOfBandMembersStarted(); state.setOutOfBandMembers([oobMemberEvent]); const memberA = state.getMember(userA); expect(memberA?.events?.member?.getId()).not.toEqual(oobMemberEvent.getId()); expect(memberA?.isOutOfBand()).toEqual(false); }); it("should emit members when updating a member", function () { const doesntExistYetUserId = "@doesntexistyet:hs"; const oobMemberEvent = utils.mkMembership({ user: doesntExistYetUserId, mship: KnownMembership.Join, room: roomId, event: true, }); let eventReceived = false; state.once(RoomStateEvent.Members, (_event, _state, member) => { expect(member.userId).toEqual(doesntExistYetUserId); eventReceived = true; }); state.markOutOfBandMembersStarted(); state.setOutOfBandMembers([oobMemberEvent]); expect(eventReceived).toEqual(true); }); }); describe("clone", function () { it("should contain same information as original", function () { // include OOB members in copy state.markOutOfBandMembersStarted(); state.setOutOfBandMembers([ utils.mkMembership({ user: userLazy, mship: KnownMembership.Join, room: roomId, event: true, }), ]); const copy = state.clone(); // check individual members [userA, userB, userLazy].forEach((userId) => { const member = state.getMember(userId); const memberCopy = copy.getMember(userId); expect(member?.name).toEqual(memberCopy?.name); expect(member?.isOutOfBand()).toEqual(memberCopy?.isOutOfBand()); }); // check member keys expect(Object.keys(state.members)).toEqual(Object.keys(copy.members)); // check join count expect(state.getJoinedMemberCount()).toEqual(copy.getJoinedMemberCount()); }); it("should mark old copy as not waiting for out of band anymore", function () { state.markOutOfBandMembersStarted(); const copy = state.clone(); copy.setOutOfBandMembers([ utils.mkMembership({ user: userA, mship: KnownMembership.Join, room: roomId, event: true, }), ]); // should have no effect as it should be marked in status finished just like copy state.setOutOfBandMembers([ utils.mkMembership({ user: userLazy, mship: KnownMembership.Join, room: roomId, event: true, }), ]); expect(state.getMember(userLazy)).toBeFalsy(); }); it("should return copy independent of original", function () { const copy = state.clone(); copy.setStateEvents([ utils.mkMembership({ user: userLazy, mship: KnownMembership.Join, room: roomId, event: true, }), ]); expect(state.getMember(userLazy)).toBeFalsy(); expect(state.getJoinedMemberCount()).toEqual(2); expect(copy.getJoinedMemberCount()).toEqual(3); }); }); describe("setTypingEvent", function () { it("should call setTypingEvent on each RoomMember", function () { const typingEvent = utils.mkEvent({ type: "m.typing", room: roomId, event: true, content: { user_ids: [userA], }, }); // spy on the room members jest.spyOn(state.members[userA], "setTypingEvent"); jest.spyOn(state.members[userB], "setTypingEvent"); state.setTypingEvent(typingEvent); expect(state.members[userA].setTypingEvent).toHaveBeenCalledWith(typingEvent); expect(state.members[userB].setTypingEvent).toHaveBeenCalledWith(typingEvent); }); }); describe("maySendStateEvent", function () { it("should say any member may send state with no power level event", function () { expect(state.maySendStateEvent("m.room.name", userA)).toEqual(true); }); it( "should say members with power >=50 may send state with power level event " + "but no state default", function () { const powerLevelEvent = new MatrixEvent({ type: "m.room.power_levels", room_id: roomId, sender: userA, state_key: "", content: { users_default: 10, // state_default: 50, "intentionally left blank" events_default: 25, users: { [userA]: 50, }, }, }); state.setStateEvents([powerLevelEvent]); expect(state.maySendStateEvent("m.room.name", userA)).toEqual(true); expect(state.maySendStateEvent("m.room.name", userB)).toEqual(false); }, ); it("should obey state_default", function () { const powerLevelEvent = new MatrixEvent({ type: "m.room.power_levels", room_id: roomId, sender: userA, state_key: "", content: { users_default: 10, state_default: 30, events_default: 25, users: { [userA]: 30, [userB]: 29, }, }, }); state.setStateEvents([powerLevelEvent]); expect(state.maySendStateEvent("m.room.name", userA)).toEqual(true); expect(state.maySendStateEvent("m.room.name", userB)).toEqual(false); }); it("should honour explicit event power levels in the power_levels event", function () { const powerLevelEvent = new MatrixEvent({ type: "m.room.power_levels", room_id: roomId, sender: userA, state_key: "", content: { events: { "m.room.other_thing": 76, }, users_default: 10, state_default: 50, events_default: 25, users: { [userA]: 80, [userB]: 50, }, }, }); state.setStateEvents([powerLevelEvent]); expect(state.maySendStateEvent("m.room.name", userA)).toEqual(true); expect(state.maySendStateEvent("m.room.name", userB)).toEqual(true); expect(state.maySendStateEvent("m.room.other_thing", userA)).toEqual(true); expect(state.maySendStateEvent("m.room.other_thing", userB)).toEqual(false); }); }); describe("getJoinedMemberCount", function () { beforeEach(() => { state = new RoomState(roomId); }); it("should update after adding joined member", function () { state.setStateEvents([ utils.mkMembership({ event: true, mship: KnownMembership.Join, user: userA, room: roomId }), ]); expect(state.getJoinedMemberCount()).toEqual(1); state.setStateEvents([ utils.mkMembership({ event: true, mship: KnownMembership.Join, user: userC, room: roomId }), ]); expect(state.getJoinedMemberCount()).toEqual(2); }); }); describe("getInvitedMemberCount", function () { beforeEach(() => { state = new RoomState(roomId); }); it("should update after adding invited member", function () { state.setStateEvents([ utils.mkMembership({ event: true, mship: KnownMembership.Invite, user: userA, room: roomId }), ]); expect(state.getInvitedMemberCount()).toEqual(1); state.setStateEvents([ utils.mkMembership({ event: true, mship: KnownMembership.Invite, user: userC, room: roomId }), ]); expect(state.getInvitedMemberCount()).toEqual(2); }); }); describe("setJoinedMemberCount", function () { beforeEach(() => { state = new RoomState(roomId); }); it("should, once used, override counting members from state", function () { state.setStateEvents([ utils.mkMembership({ event: true, mship: KnownMembership.Join, user: userA, room: roomId }), ]); expect(state.getJoinedMemberCount()).toEqual(1); state.setJoinedMemberCount(100); expect(state.getJoinedMemberCount()).toEqual(100); state.setStateEvents([ utils.mkMembership({ event: true, mship: KnownMembership.Join, user: userC, room: roomId }), ]); expect(state.getJoinedMemberCount()).toEqual(100); }); it("should, once used, override counting members from state, " + "also after clone", function () { state.setStateEvents([ utils.mkMembership({ event: true, mship: KnownMembership.Join, user: userA, room: roomId }), ]); state.setJoinedMemberCount(100); const copy = state.clone(); copy.setStateEvents([ utils.mkMembership({ event: true, mship: KnownMembership.Join, user: userC, room: roomId }), ]); expect(state.getJoinedMemberCount()).toEqual(100); }); }); describe("setInvitedMemberCount", function () { beforeEach(() => { state = new RoomState(roomId); }); it("should, once used, override counting members from state", function () { state.setStateEvents([ utils.mkMembership({ event: true, mship: KnownMembership.Invite, user: userB, room: roomId }), ]); expect(state.getInvitedMemberCount()).toEqual(1); state.setInvitedMemberCount(100); expect(state.getInvitedMemberCount()).toEqual(100); state.setStateEvents([ utils.mkMembership({ event: true, mship: KnownMembership.Invite, user: userC, room: roomId }), ]); expect(state.getInvitedMemberCount()).toEqual(100); }); it("should, once used, override counting members from state, " + "also after clone", function () { state.setStateEvents([ utils.mkMembership({ event: true, mship: KnownMembership.Invite, user: userB, room: roomId }), ]); state.setInvitedMemberCount(100); const copy = state.clone(); copy.setStateEvents([ utils.mkMembership({ event: true, mship: KnownMembership.Invite, user: userC, room: roomId }), ]); expect(state.getInvitedMemberCount()).toEqual(100); }); }); describe("maySendEvent", function () { it("should say any member may send events with no power level event", function () { expect(state.maySendEvent("m.room.message", userA)).toEqual(true); expect(state.maySendMessage(userA)).toEqual(true); }); it("should obey events_default", function () { const powerLevelEvent = new MatrixEvent({ type: "m.room.power_levels", room_id: roomId, sender: userA, state_key: "", content: { users_default: 10, state_default: 30, events_default: 25, users: { [userA]: 26, [userB]: 24, }, }, }); state.setStateEvents([powerLevelEvent]); expect(state.maySendEvent("m.room.message", userA)).toEqual(true); expect(state.maySendEvent("m.room.message", userB)).toEqual(false); expect(state.maySendMessage(userA)).toEqual(true); expect(state.maySendMessage(userB)).toEqual(false); }); it("should honour explicit event power levels in the power_levels event", function () { const powerLevelEvent = new MatrixEvent({ type: "m.room.power_levels", room_id: roomId, sender: userA, state_key: "", content: { events: { "m.room.other_thing": 33, }, users_default: 10, state_default: 50, events_default: 25, users: { [userA]: 40, [userB]: 30, }, }, }); state.setStateEvents([powerLevelEvent]); expect(state.maySendEvent("m.room.message", userA)).toEqual(true); expect(state.maySendEvent("m.room.message", userB)).toEqual(true); expect(state.maySendMessage(userA)).toEqual(true); expect(state.maySendMessage(userB)).toEqual(true); expect(state.maySendEvent("m.room.other_thing", userA)).toEqual(true); expect(state.maySendEvent("m.room.other_thing", userB)).toEqual(false); }); }); describe("processBeaconEvents", () => { const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, "$beacon1"); const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, "$beacon2"); const mockClient = { decryptEventIfNeeded: jest.fn() } as unknown as MockedObject; beforeEach(() => { mockClient.decryptEventIfNeeded.mockClear(); }); it("does nothing when state has no beacons", () => { const emitSpy = jest.spyOn(state, "emit"); state.processBeaconEvents([makeBeaconEvent(userA, { beaconInfoId: "$beacon1" })], mockClient); expect(emitSpy).not.toHaveBeenCalled(); expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled(); }); it("does nothing when there are no events", () => { state.setStateEvents([beacon1, beacon2]); const emitSpy = jest.spyOn(state, "emit").mockClear(); state.processBeaconEvents([], mockClient); expect(emitSpy).not.toHaveBeenCalled(); expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled(); }); describe("without encryption", () => { it("discards events for beacons that are not in state", () => { const location = makeBeaconEvent(userA, { beaconInfoId: "some-other-beacon", }); const otherRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessage, content: { ["m.relates_to"]: { event_id: "whatever", }, }, }); state.setStateEvents([beacon1, beacon2]); const emitSpy = jest.spyOn(state, "emit").mockClear(); state.processBeaconEvents([location, otherRelatedEvent], mockClient); expect(emitSpy).not.toHaveBeenCalled(); }); it("discards events that are not beacon type", () => { // related to beacon1 const otherRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessage, content: { ["m.relates_to"]: { rel_type: RelationType.Reference, event_id: beacon1.getId(), }, }, }); state.setStateEvents([beacon1, beacon2]); const emitSpy = jest.spyOn(state, "emit").mockClear(); state.processBeaconEvents([otherRelatedEvent], mockClient); expect(emitSpy).not.toHaveBeenCalled(); }); it("adds locations to beacons", async () => { const location1 = makeBeaconEvent(userA, { beaconInfoId: "$beacon1", timestamp: Date.now() + 1, }); const location2 = makeBeaconEvent(userA, { beaconInfoId: "$beacon1", timestamp: Date.now() + 2, }); const location3 = makeBeaconEvent(userB, { beaconInfoId: "some-other-beacon", }); state.setStateEvents([beacon1, beacon2]); expect(state.beacons.size).toEqual(2); const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; const addLocationsSpy = jest.spyOn(beaconInstance, "addLocations"); await state.processBeaconEvents([location1, location2, location3], mockClient); expect(addLocationsSpy).toHaveBeenCalledTimes(2); // only called with locations for beacon1 expect(addLocationsSpy).toHaveBeenCalledWith([location1]); expect(addLocationsSpy).toHaveBeenCalledWith([location2]); }); }); describe("with encryption", () => { const beacon1RelationContent = { ["m.relates_to"]: { rel_type: RelationType.Reference, event_id: beacon1.getId(), }, }; const relatedEncryptedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); const decryptingRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true); const failedDecryptionRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); jest.spyOn(failedDecryptionRelatedEvent, "isDecryptionFailure").mockReturnValue(true); it("discards events without relations", () => { const unrelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, }); state.setStateEvents([beacon1, beacon2]); const emitSpy = jest.spyOn(state, "emit").mockClear(); state.processBeaconEvents([unrelatedEvent], mockClient); expect(emitSpy).not.toHaveBeenCalled(); // discard unrelated events early expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled(); }); it("discards events for beacons that are not in state", () => { const location = makeBeaconEvent(userA, { beaconInfoId: "some-other-beacon", }); const otherRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: { ["m.relates_to"]: { rel_type: RelationType.Reference, event_id: "whatever", }, }, }); state.setStateEvents([beacon1, beacon2]); const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear(); state.processBeaconEvents([location, otherRelatedEvent], mockClient); expect(addLocationsSpy).not.toHaveBeenCalled(); // discard unrelated events early expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled(); }); it("decrypts related events if needed", async () => { const location = makeBeaconEvent(userA, { beaconInfoId: beacon1.getId(), }); state.setStateEvents([beacon1, beacon2]); await state.processBeaconEvents([location, relatedEncryptedEvent], mockClient); // discard unrelated events early expect(mockClient.decryptEventIfNeeded).toHaveBeenCalledTimes(2); }); it("awaits for decryption on events that are being decrypted", async () => { const decryptingRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true); state.setStateEvents([beacon1, beacon2]); await state.processBeaconEvents([decryptingRelatedEvent], mockClient); // listener was added expect(mockClient.decryptEventIfNeeded).toHaveBeenCalled(); }); it("listens for decryption on events that have decryption failure", async () => { const failedDecryptionRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); jest.spyOn(failedDecryptionRelatedEvent, "isDecryptionFailure").mockReturnValue(true); mockClient.decryptEventIfNeeded.mockRejectedValue( new DecryptionError(DecryptionFailureCode.UNKNOWN_ERROR, "msg"), ); // spy on event.once const eventOnceSpy = jest.spyOn(failedDecryptionRelatedEvent, "once"); state.setStateEvents([beacon1, beacon2]); await state.processBeaconEvents([failedDecryptionRelatedEvent], mockClient); // listener was added expect(eventOnceSpy).toHaveBeenCalled(); }); it("discard events that are not m.beacon type after decryption", async () => { const decryptingRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true); state.setStateEvents([beacon1, beacon2]); const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear(); await state.processBeaconEvents([decryptingRelatedEvent], mockClient); // this event is a message after decryption decryptingRelatedEvent.event.type = EventType.RoomMessage; decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted, decryptingRelatedEvent); expect(addLocationsSpy).not.toHaveBeenCalled(); }); it("adds locations to beacons after decryption", async () => { const decryptingRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); const locationEvent = makeBeaconEvent(userA, { beaconInfoId: "$beacon1", timestamp: Date.now() + 1, }); const deferred = defer(); mockClient.decryptEventIfNeeded.mockReturnValue(deferred.promise); state.setStateEvents([beacon1, beacon2]); const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear(); const prom = state.processBeaconEvents([decryptingRelatedEvent], mockClient); // update type after '''decryption''' decryptingRelatedEvent.event.type = M_BEACON.name; decryptingRelatedEvent.event.content = locationEvent.event.content; deferred.resolve(); await prom; expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]); }); }); }); describe("mayClientSendStateEvent", () => { it("should return false if the user isn't authenticated", () => { expect( state.mayClientSendStateEvent("m.room.message", { isGuest: jest.fn().mockReturnValue(false), credentials: {}, } as unknown as MatrixClient), ).toBeFalsy(); }); it("should return false if the user is a guest", () => { expect( state.mayClientSendStateEvent("m.room.message", { isGuest: jest.fn().mockReturnValue(true), credentials: { userId: userA }, } as unknown as MatrixClient), ).toBeFalsy(); }); }); });