diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index c2fb700f36..451e893b11 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -116,6 +116,8 @@ import VoipUserMapper from "../../VoipUserMapper"; import { isCallEvent } from "./LegacyCallEventGrouper"; import { WidgetType } from "../../widgets/WidgetType"; import WidgetUtils from "../../utils/WidgetUtils"; +import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../utils/room/shouldEncryptRoomWithSingle3rdPartyInvite"; +import { WaitingForThirdPartyRoomView } from "./WaitingForThirdPartyRoomView"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; @@ -231,6 +233,7 @@ export interface IRoomState { } interface LocalRoomViewProps { + localRoom: LocalRoom; resizeNotifier: ResizeNotifier; permalinkCreator: RoomPermalinkCreator; roomView: RefObject; @@ -246,7 +249,7 @@ interface LocalRoomViewProps { function LocalRoomView(props: LocalRoomViewProps): ReactElement { const context = useContext(RoomContext); const room = context.room as LocalRoom; - const encryptionEvent = context.room.currentState.getStateEvents(EventType.RoomEncryption)[0]; + const encryptionEvent = props.localRoom.currentState.getStateEvents(EventType.RoomEncryption)[0]; let encryptionTile: ReactNode; if (encryptionEvent) { @@ -261,8 +264,8 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { }); }; - let statusBar: ReactElement; - let composer: ReactElement; + let statusBar: ReactElement | null = null; + let composer: ReactElement | null = null; if (room.isError) { const buttons = ( @@ -281,7 +284,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { } else { composer = ( @@ -293,7 +296,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { { private roomView = createRef(); private searchResultsPanel = createRef(); - private messagePanel: TimelinePanel; + private messagePanel?: TimelinePanel; private roomViewBody = createRef(); public static contextType = SDKContext; @@ -382,15 +385,19 @@ export class RoomView extends React.Component { public constructor(props: IRoomProps, context: React.ContextType) { super(props, context); + if (!context.client) { + throw new Error("Unable to create RoomView without MatrixClient"); + } + const llMembers = context.client.hasLazyLoadMembersEnabled(); this.state = { - roomId: null, + roomId: undefined, roomLoading: true, peekLoading: false, shouldPeek: true, membersLoaded: !llMembers, numUnreadMessages: 0, - callState: null, + callState: undefined, activeCall: null, canPeek: false, canSelfRedact: false, @@ -1920,10 +1927,11 @@ export class RoomView extends React.Component { ); } - private renderLocalRoomView(): ReactElement { + private renderLocalRoomView(localRoom: LocalRoom): ReactElement { return ( { ); } + private renderWaitingForThirdPartyRoomView(inviteEvent: MatrixEvent): ReactElement { + return ( + + + + ); + } + public render(): React.ReactNode { if (this.state.room instanceof LocalRoom) { if (this.state.room.state === LocalRoomState.CREATING) { return this.renderLocalRoomCreateLoader(); } - return this.renderLocalRoomView(); + return this.renderLocalRoomView(this.state.room); + } + + if (this.state.room) { + const { shouldEncrypt, inviteEvent } = shouldEncryptRoomWithSingle3rdPartyInvite(this.state.room); + + if (shouldEncrypt) { + return this.renderWaitingForThirdPartyRoomView(inviteEvent); + } } if (!this.state.room) { diff --git a/src/components/structures/WaitingForThirdPartyRoomView.tsx b/src/components/structures/WaitingForThirdPartyRoomView.tsx new file mode 100644 index 0000000000..b747fd007f --- /dev/null +++ b/src/components/structures/WaitingForThirdPartyRoomView.tsx @@ -0,0 +1,82 @@ +/* +Copyright 2023 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 React from "react"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { RefObject } from "react"; + +import { useRoomContext } from "../../contexts/RoomContext"; +import ResizeNotifier from "../../utils/ResizeNotifier"; +import { E2EStatus } from "../../utils/ShieldUtils"; +import ErrorBoundary from "../views/elements/ErrorBoundary"; +import RoomHeader from "../views/rooms/RoomHeader"; +import ScrollPanel from "./ScrollPanel"; +import EventTileBubble from "../views/messages/EventTileBubble"; +import NewRoomIntro from "../views/rooms/NewRoomIntro"; +import { UnwrappedEventTile } from "../views/rooms/EventTile"; +import { _t } from "../../languageHandler"; + +interface Props { + roomView: RefObject; + resizeNotifier: ResizeNotifier; + inviteEvent: MatrixEvent; +} + +/** + * Component that displays a waiting room for an encrypted DM with a third party invite. + * If encryption by default is enabled, DMs with a third party invite should be encrypted as well. + * To avoid UTDs, users are shown a waiting room until the others have joined. + */ +export const WaitingForThirdPartyRoomView: React.FC = ({ roomView, resizeNotifier, inviteEvent }) => { + const context = useRoomContext(); + + return ( +
+ + +
+
+ + + + + +
+
+
+
+ ); +}; diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index f8ab6f4c18..070999b4fd 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -39,6 +39,7 @@ import { shouldShowComponent } from "../../../customisations/helpers/UIComponent import { UIComponent } from "../../../settings/UIFeature"; import { privateShouldBeEncrypted } from "../../../utils/rooms"; import { LocalRoom } from "../../../models/LocalRoom"; +import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../../utils/room/shouldEncryptRoomWithSingle3rdPartyInvite"; function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean { const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId); @@ -46,21 +47,40 @@ function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): return isPublic || !privateShouldBeEncrypted() || isEncrypted; } +const determineIntroMessage = (room: Room, encryptedSingle3rdPartyInvite: boolean): string => { + if (room instanceof LocalRoom) { + return _td("Send your first message to invite to chat"); + } + + if (encryptedSingle3rdPartyInvite) { + return _td("Once everyone has joined, you’ll be able to chat"); + } + + return _td("This is the beginning of your direct message history with ."); +}; + const NewRoomIntro: React.FC = () => { const cli = useContext(MatrixClientContext); const { room, roomId } = useContext(RoomContext); + if (!room || !roomId) { + throw new Error("Unable to create a NewRoomIntro without room and roomId"); + } + const isLocalRoom = room instanceof LocalRoom; const dmPartner = isLocalRoom ? room.targets[0]?.userId : DMRoomMap.shared().getUserIdForRoomId(roomId); let body: JSX.Element; if (dmPartner) { - let introMessage = _td("This is the beginning of your direct message history with ."); + const { shouldEncrypt: encryptedSingle3rdPartyInvite } = shouldEncryptRoomWithSingle3rdPartyInvite(room); + const introMessage = determineIntroMessage(room, encryptedSingle3rdPartyInvite); let caption: string | undefined; - if (isLocalRoom) { - introMessage = _td("Send your first message to invite to chat"); - } else if (room.getJoinedMemberCount() + room.getInvitedMemberCount() === 2) { + if ( + !(room instanceof LocalRoom) && + !encryptedSingle3rdPartyInvite && + room.getJoinedMemberCount() + room.getInvitedMemberCount() === 2 + ) { caption = _t("Only the two of you are in this conversation, unless either of you invites anyone to join."); } @@ -98,7 +118,7 @@ const NewRoomIntro: React.FC = () => { } else { const inRoom = room && room.getMyMembership() === "join"; const topic = room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic; - const canAddTopic = inRoom && room.currentState.maySendStateEvent(EventType.RoomTopic, cli.getUserId()); + const canAddTopic = inRoom && room.currentState.maySendStateEvent(EventType.RoomTopic, cli.getSafeUserId()); const onTopicClick = (): void => { defaultDispatcher.dispatch( @@ -110,7 +130,7 @@ const NewRoomIntro: React.FC = () => { ); // focus the topic field to help the user find it as it'll gain an outline setImmediate(() => { - window.document.getElementById("profileTopic").focus(); + window.document.getElementById("profileTopic")?.focus(); }); }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d5aa83660c..7a4294281c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1951,8 +1951,9 @@ "Code block": "Code block", "Quote": "Quote", "Insert link": "Insert link", - "This is the beginning of your direct message history with .": "This is the beginning of your direct message history with .", "Send your first message to invite to chat": "Send your first message to invite to chat", + "Once everyone has joined, you’ll be able to chat": "Once everyone has joined, you’ll be able to chat", + "This is the beginning of your direct message history with .": "This is the beginning of your direct message history with .", "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Only the two of you are in this conversation, unless either of you invites anyone to join.", "Topic: %(topic)s (edit)": "Topic: %(topic)s (edit)", "Topic: %(topic)s ": "Topic: %(topic)s ", @@ -3487,6 +3488,8 @@ "Original event source": "Original event source", "Event ID: %(eventId)s": "Event ID: %(eventId)s", "Thread root ID: %(threadRootId)s": "Thread root ID: %(threadRootId)s", + "Waiting for users to join Element": "Waiting for users to join Element", + "Once invited users have joined Element, you will be able to chat and the room will be end-to-end encrypted": "Once invited users have joined Element, you will be able to chat and the room will be end-to-end encrypted", "Unable to verify this device": "Unable to verify this device", "Verify this device": "Verify this device", "Device verified": "Device verified", diff --git a/src/utils/direct-messages.ts b/src/utils/direct-messages.ts index aede86f93b..9174defdfa 100644 --- a/src/utils/direct-messages.ts +++ b/src/utils/direct-messages.ts @@ -15,7 +15,6 @@ limitations under the License. */ import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; -import { Room } from "matrix-js-sdk/src/models/room"; import { logger } from "matrix-js-sdk/src/logger"; import { canEncryptToAllUsers } from "../createRoom"; @@ -30,7 +29,7 @@ import { createDmLocalRoom } from "./dm/createDmLocalRoom"; import { startDm } from "./dm/startDm"; import { resolveThreePids } from "./threepids"; -export async function startDmOnFirstMessage(client: MatrixClient, targets: Member[]): Promise { +export async function startDmOnFirstMessage(client: MatrixClient, targets: Member[]): Promise { let resolvedTargets = targets; try { @@ -49,7 +48,13 @@ export async function startDmOnFirstMessage(client: MatrixClient, targets: Membe joining: false, metricsTrigger: "MessageUser", }); - return existingRoom; + return existingRoom.roomId; + } + + if (targets.length === 1 && targets[0] instanceof ThreepidMember && privateShouldBeEncrypted()) { + // Single 3rd-party invite and well-known promotes encryption: + // Directly create a room and invite the other. + return await startDm(client, targets); } const room = await createDmLocalRoom(client, resolvedTargets); @@ -59,7 +64,7 @@ export async function startDmOnFirstMessage(client: MatrixClient, targets: Membe joining: false, targets: resolvedTargets, }); - return room; + return room.roomId; } /** @@ -81,6 +86,8 @@ export async function createRoomFromLocalRoom(client: MatrixClient, localRoom: L return startDm(client, localRoom.targets, false).then( (roomId) => { + if (!roomId) throw new Error(`startDm for local room ${localRoom.roomId} didn't return a room Id`); + localRoom.actualRoomId = roomId; return waitForRoomReadyAndApplyAfterCreateCallbacks(client, localRoom); }, @@ -186,6 +193,9 @@ export interface IDMUserTileProps { */ export async function determineCreateRoomEncryptionOption(client: MatrixClient, targets: Member[]): Promise { if (privateShouldBeEncrypted()) { + // Enable encryption for a single 3rd party invite. + if (targets.length === 1 && targets[0] instanceof ThreepidMember) return true; + // Check whether all users have uploaded device keys before. // If so, enable encryption in the new room. const has3PidMembers = targets.some((t) => t instanceof ThreepidMember); diff --git a/src/utils/dm/startDm.ts b/src/utils/dm/startDm.ts index 8d54292c71..782785bf62 100644 --- a/src/utils/dm/startDm.ts +++ b/src/utils/dm/startDm.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { IInvite3PID, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { Optional } from "matrix-events-sdk"; import { Action } from "../../dispatcher/actions"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; @@ -35,7 +36,7 @@ export async function startDm(client: MatrixClient, targets: Member[], showSpinn const targetIds = targets.map((t) => t.userId); // Check if there is already a DM with these people and reuse it if possible. - let existingRoom: Room | undefined; + let existingRoom: Optional; if (targetIds.length === 1) { existingRoom = findDMForUser(client, targetIds[0]); } else { diff --git a/src/utils/room/shouldEncryptRoomWithSingle3rdPartyInvite.ts b/src/utils/room/shouldEncryptRoomWithSingle3rdPartyInvite.ts new file mode 100644 index 0000000000..a3a1abe212 --- /dev/null +++ b/src/utils/room/shouldEncryptRoomWithSingle3rdPartyInvite.ts @@ -0,0 +1,48 @@ +/* +Copyright 2023 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 { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import DMRoomMap from "../DMRoomMap"; +import { privateShouldBeEncrypted } from "../rooms"; + +/** + * Tests whether a DM room with exactly one third-party invite should be encrypted. + * If it should be encrypted, the third-party invitation event is also returned. + */ +export const shouldEncryptRoomWithSingle3rdPartyInvite = ( + room: Room, +): { shouldEncrypt: true; inviteEvent: MatrixEvent } | { shouldEncrypt: false; inviteEvent?: undefined } => { + // encryption not promoted via .well-known + if (!privateShouldBeEncrypted()) return { shouldEncrypt: false }; + + // not a DM room + if (!DMRoomMap.shared().getRoomIds().has(room.roomId)) return { shouldEncrypt: false }; + + // more than one room member / invite + if (room.getInvitedAndJoinedMemberCount() !== 1) return { shouldEncrypt: false }; + + const thirdPartyInvites = room.currentState.getStateEvents("m.room.third_party_invite") || []; + + if (thirdPartyInvites.length === 1) { + return { + shouldEncrypt: true, + inviteEvent: thirdPartyInvites[0], + }; + } + + return { shouldEncrypt: false }; +}; diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index 28b82b02cd..6b1196ce1e 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -23,7 +23,7 @@ import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; -import { fireEvent, render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { stubClient, @@ -34,6 +34,8 @@ import { mkEvent, setupAsyncStoreWithClient, filterConsole, + mkRoomMemberJoinEvent, + mkThirdPartyInviteEvent, } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { Action } from "../../../src/dispatcher/actions"; @@ -68,7 +70,7 @@ describe("RoomView", () => { // mute some noise filterConsole("RVS update", "does not have an m.room.create event", "Current version: 1", "Version capability"); - beforeEach(async () => { + beforeEach(() => { mockPlatformPeg({ reload: () => {} }); stubClient(); cli = mocked(MatrixClientPeg.get()); @@ -91,7 +93,7 @@ describe("RoomView", () => { jest.spyOn(VoipUserMapper.sharedInstance(), "getVirtualRoomForRoom").mockResolvedValue(undefined); }); - afterEach(async () => { + afterEach(() => { unmockPlatformPeg(); jest.restoreAllMocks(); }); @@ -369,6 +371,32 @@ describe("RoomView", () => { }); }); + describe("when rendering a DM room with a single third-party invite", () => { + beforeEach(async () => { + room.currentState.setStateEvents([ + mkRoomMemberJoinEvent(cli.getSafeUserId(), room.roomId), + mkThirdPartyInviteEvent(cli.getSafeUserId(), "user@example.com", room.roomId), + ]); + jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(cli.getSafeUserId()); + jest.spyOn(DMRoomMap.shared(), "getRoomIds").mockReturnValue(new Set([room.roomId])); + mocked(cli).isRoomEncrypted.mockReturnValue(true); + await renderRoomView(); + }); + + it("should render the »waiting for third-party« view", () => { + expect(screen.getByText("Waiting for users to join Element")).toBeInTheDocument(); + expect( + screen.getByText( + "Once invited users have joined Element, you will be able to chat and the room will be end-to-end encrypted", + ), + ).toBeInTheDocument(); + + // no message composer + expect(screen.queryByText("Send an encrypted message…")).not.toBeInTheDocument(); + expect(screen.queryByText("Send a message…")).not.toBeInTheDocument(); + }); + }); + describe("when there is a RoomView", () => { const widget1Id = "widget1"; const widget2Id = "widget2"; diff --git a/test/components/views/rooms/NewRoomIntro-test.tsx b/test/components/views/rooms/NewRoomIntro-test.tsx index 8989a4f639..5694aeb501 100644 --- a/test/components/views/rooms/NewRoomIntro-test.tsx +++ b/test/components/views/rooms/NewRoomIntro-test.tsx @@ -20,13 +20,12 @@ import { render, screen } from "@testing-library/react"; import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { LocalRoom } from "../../../../src/models/LocalRoom"; -import { createTestClient } from "../../../test-utils"; +import { filterConsole, mkRoomMemberJoinEvent, mkThirdPartyInviteEvent, stubClient } from "../../../test-utils"; import RoomContext from "../../../../src/contexts/RoomContext"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import NewRoomIntro from "../../../../src/components/views/rooms/NewRoomIntro"; import { IRoomState } from "../../../../src/components/structures/RoomView"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; -import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { DirectoryMember } from "../../../../src/utils/direct-messages"; const renderNewRoomIntro = (client: MatrixClient, room: Room | LocalRoom) => { @@ -44,9 +43,10 @@ describe("NewRoomIntro", () => { const roomId = "!room:example.com"; const userId = "@user:example.com"; - beforeEach(() => { - client = createTestClient(); - jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client); + filterConsole("Room !room:example.com does not have an m.room.create event"); + + beforeAll(() => { + client = stubClient(); DMRoomMap.makeShared(); }); @@ -64,6 +64,24 @@ describe("NewRoomIntro", () => { }); }); + it("should render as expected for a DM room with a single third-party invite", () => { + const room = new Room(roomId, client, client.getSafeUserId()); + room.currentState.setStateEvents([ + mkRoomMemberJoinEvent(client.getSafeUserId(), room.roomId), + mkThirdPartyInviteEvent(client.getSafeUserId(), "user@example.com", room.roomId), + ]); + jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(userId); + jest.spyOn(DMRoomMap.shared(), "getRoomIds").mockReturnValue(new Set([room.roomId])); + renderNewRoomIntro(client, room); + + expect(screen.getByText("Once everyone has joined, you’ll be able to chat")).toBeInTheDocument(); + expect( + screen.queryByText( + "Only the two of you are in this conversation, unless either of you invites anyone to join.", + ), + ).not.toBeInTheDocument(); + }); + describe("for a DM LocalRoom", () => { beforeEach(() => { jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(userId); diff --git a/test/utils/direct-messages-test.ts b/test/utils/direct-messages-test.ts index 80d9f9becc..54222aae38 100644 --- a/test/utils/direct-messages-test.ts +++ b/test/utils/direct-messages-test.ts @@ -116,11 +116,11 @@ describe("direct-messages", () => { it("should create a local room and dispatch a view room event", async () => { mocked(createDmLocalRoom).mockResolvedValue(localRoom); const members = [member1]; - const room = await dmModule.startDmOnFirstMessage(mockClient, members); - expect(room).toBe(localRoom); + const roomId = await dmModule.startDmOnFirstMessage(mockClient, members); + expect(roomId).toBe(localRoom.roomId); expect(dis.dispatch).toHaveBeenCalledWith({ action: Action.ViewRoom, - room_id: room.roomId, + room_id: roomId, joining: false, targets: [member1], }); @@ -135,8 +135,8 @@ describe("direct-messages", () => { mocked(createDmLocalRoom).mockResolvedValue(localRoom); const members = [member1]; - const room = await dmModule.startDmOnFirstMessage(mockClient, members); - expect(room).toBe(localRoom); + const roomId = await dmModule.startDmOnFirstMessage(mockClient, members); + expect(roomId).toBe(localRoom.roomId); // ensure that startDmOnFirstMessage tries to resolve 3rd-party IDs expect(resolveThreePids).toHaveBeenCalledWith(members, mockClient); @@ -152,8 +152,8 @@ describe("direct-messages", () => { }); it("should return the room and dispatch a view room event", async () => { - const room = await dmModule.startDmOnFirstMessage(mockClient, [member1]); - expect(room).toBe(room1); + const roomId = await dmModule.startDmOnFirstMessage(mockClient, [member1]); + expect(roomId).toBe(room1.roomId); expect(dis.dispatch).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room1.roomId, diff --git a/test/utils/dm/createDmLocalRoom-test.ts b/test/utils/dm/createDmLocalRoom-test.ts index f014529114..dc0bcab555 100644 --- a/test/utils/dm/createDmLocalRoom-test.ts +++ b/test/utils/dm/createDmLocalRoom-test.ts @@ -70,10 +70,10 @@ describe("createDmLocalRoom", () => { mocked(privateShouldBeEncrypted).mockReturnValue(true); }); - it("should create an unencrypted room for 3PID targets", async () => { + it("should create an encrytped room for 3PID targets", async () => { const room = await createDmLocalRoom(mockClient, [member2]); expect(mockClient.store.storeRoom).toHaveBeenCalledWith(room); - assertLocalRoom(room, [member2], false); + assertLocalRoom(room, [member2], true); }); describe("for MXID targets with encryption available", () => { diff --git a/test/utils/room/shouldEncryptRoomWithSingle3rdPartyInvite-test.ts b/test/utils/room/shouldEncryptRoomWithSingle3rdPartyInvite-test.ts new file mode 100644 index 0000000000..1b74621254 --- /dev/null +++ b/test/utils/room/shouldEncryptRoomWithSingle3rdPartyInvite-test.ts @@ -0,0 +1,110 @@ +/* +Copyright 2023 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 { mocked } from "jest-mock"; +import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import DMRoomMap from "../../../src/utils/DMRoomMap"; +import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../../src/utils/room/shouldEncryptRoomWithSingle3rdPartyInvite"; +import { privateShouldBeEncrypted } from "../../../src/utils/rooms"; +import { mkRoomMemberJoinEvent, mkThirdPartyInviteEvent, stubClient } from "../../test-utils"; + +jest.mock("../../../src/utils/rooms", () => ({ + privateShouldBeEncrypted: jest.fn(), +})); + +describe("shouldEncryptRoomWithSingle3rdPartyInvite", () => { + let client: MatrixClient; + let thirdPartyInviteEvent: MatrixEvent; + let roomWithOneThirdPartyInvite: Room; + + beforeAll(() => { + client = stubClient(); + DMRoomMap.makeShared(); + }); + + beforeEach(() => { + roomWithOneThirdPartyInvite = new Room("!room1:example.com", client, client.getSafeUserId()); + thirdPartyInviteEvent = mkThirdPartyInviteEvent( + client.getSafeUserId(), + "user@example.com", + roomWithOneThirdPartyInvite.roomId, + ); + + roomWithOneThirdPartyInvite.currentState.setStateEvents([ + mkRoomMemberJoinEvent(client.getSafeUserId(), roomWithOneThirdPartyInvite.roomId), + thirdPartyInviteEvent, + ]); + jest.spyOn(DMRoomMap.shared(), "getRoomIds").mockReturnValue(new Set([roomWithOneThirdPartyInvite.roomId])); + }); + + describe("when well-known promotes encryption", () => { + beforeEach(() => { + mocked(privateShouldBeEncrypted).mockReturnValue(true); + }); + + it("should return true + invite event for a DM room with one third-party invite", () => { + expect(shouldEncryptRoomWithSingle3rdPartyInvite(roomWithOneThirdPartyInvite)).toEqual({ + shouldEncrypt: true, + inviteEvent: thirdPartyInviteEvent, + }); + }); + + it("should return false for a non-DM room with one third-party invite", () => { + mocked(DMRoomMap.shared().getRoomIds).mockReturnValue(new Set()); + + expect(shouldEncryptRoomWithSingle3rdPartyInvite(roomWithOneThirdPartyInvite)).toEqual({ + shouldEncrypt: false, + }); + }); + + it("should return false for a DM room with two members", () => { + roomWithOneThirdPartyInvite.currentState.setStateEvents([ + mkRoomMemberJoinEvent("@user2:example.com", roomWithOneThirdPartyInvite.roomId), + ]); + + expect(shouldEncryptRoomWithSingle3rdPartyInvite(roomWithOneThirdPartyInvite)).toEqual({ + shouldEncrypt: false, + }); + }); + + it("should return false for a DM room with two third-party invites", () => { + roomWithOneThirdPartyInvite.currentState.setStateEvents([ + mkThirdPartyInviteEvent( + client.getSafeUserId(), + "user2@example.com", + roomWithOneThirdPartyInvite.roomId, + ), + ]); + + expect(shouldEncryptRoomWithSingle3rdPartyInvite(roomWithOneThirdPartyInvite)).toEqual({ + shouldEncrypt: false, + }); + }); + }); + + describe("when well-known does not promote encryption", () => { + beforeEach(() => { + mocked(privateShouldBeEncrypted).mockReturnValue(false); + }); + + it("should return false for a DM room with one third-party invite", () => { + expect(shouldEncryptRoomWithSingle3rdPartyInvite(roomWithOneThirdPartyInvite)).toEqual({ + shouldEncrypt: false, + }); + }); + }); +});