/* Copyright 2024 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React from "react"; import { sleep } from "matrix-js-sdk/src/utils"; import { EventType, MatrixClient, MatrixEvent, Room, RoomState, RoomStateEvent, RoomMember, } from "matrix-js-sdk/src/matrix"; import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import { act, render, screen, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { stubClient } from "../../../../test-utils"; import { UserIdentityWarning } from "../../../../../src/components/views/rooms/UserIdentityWarning"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; const ROOM_ID = "!room:id"; function mockRoom(): Room { const room = { getEncryptionTargetMembers: jest.fn(async () => []), getMember: jest.fn((userId) => {}), roomId: ROOM_ID, shouldEncryptForInvitedMembers: jest.fn(() => true), } as unknown as Room; return room; } function mockRoomMember(userId: string, name?: string): RoomMember { return { userId, name: name ?? userId, rawDisplayName: name ?? userId, roomId: ROOM_ID, getMxcAvatarUrl: jest.fn(), } as unknown as RoomMember; } function dummyRoomState(): RoomState { return new RoomState(ROOM_ID); } /** * Get the warning element, given the warning text (excluding the "Learn more" * link). This is needed because the warning text contains a `` tag, so the * normal `getByText` doesn't work. */ function getWarningByText(text: string): Element { return screen.getByText((content?: string, element?: Element | null): boolean => { return ( !!element && element.classList.contains("mx_UserIdentityWarning_main") && element.textContent === text + " Learn more" ); }); } function renderComponent(client: MatrixClient, room: Room) { return render(, { wrapper: ({ ...rest }) => , }); } describe("UserIdentityWarning", () => { let client: MatrixClient; let room: Room; beforeEach(async () => { client = stubClient(); room = mockRoom(); jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); }); afterEach(() => { jest.restoreAllMocks(); }); // This tests the basic functionality of the component. If we have a room // member whose identity needs accepting, we should display a warning. When // the "OK" button gets pressed, it should call `pinCurrentUserIdentity`. it("displays a warning when a user's identity needs approval", async () => { jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ mockRoomMember("@alice:example.org", "Alice"), ]); const crypto = client.getCrypto()!; jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, false, false, true), ); crypto.pinCurrentUserIdentity = jest.fn(); renderComponent(client, room); await waitFor(() => expect( getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), ).toBeInTheDocument(), ); await userEvent.click(screen.getByRole("button")!); await waitFor(() => expect(crypto.pinCurrentUserIdentity).toHaveBeenCalledWith("@alice:example.org")); }); // We don't display warnings in non-encrypted rooms, but if encryption is // enabled, then we should display a warning if there are any users whose // identity need accepting. it("displays pending warnings when encryption is enabled", async () => { jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ mockRoomMember("@alice:example.org", "Alice"), ]); // Start the room off unencrypted. We shouldn't display anything. const crypto = client.getCrypto()!; jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(false); jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, false, false, true), ); renderComponent(client, room); await sleep(10); // give it some time to finish initialising expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(); // Encryption gets enabled in the room. We should now warn that Alice's // identity changed. jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(true); client.emit( RoomStateEvent.Events, new MatrixEvent({ event_id: "$event_id", type: EventType.RoomEncryption, state_key: "", content: { algorithm: "m.megolm.v1.aes-sha2", }, room_id: ROOM_ID, sender: "@alice:example.org", }), dummyRoomState(), null, ); await waitFor(() => expect( getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), ).toBeInTheDocument(), ); }); // When a user's identity needs approval, or has been approved, the display // should update appropriately. it("updates the display when identity changes", async () => { jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ mockRoomMember("@alice:example.org", "Alice"), ]); jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); const crypto = client.getCrypto()!; jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, false, false, false), ); renderComponent(client, room); await sleep(10); // give it some time to finish initialising expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(); // The user changes their identity, so we should show the warning. act(() => { client.emit( CryptoEvent.UserTrustStatusChanged, "@alice:example.org", new UserVerificationStatus(false, false, false, true), ); }); await waitFor(() => expect( getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), ).toBeInTheDocument(), ); // Simulate the user's new identity having been approved, so we no // longer show the warning. act(() => { client.emit( CryptoEvent.UserTrustStatusChanged, "@alice:example.org", new UserVerificationStatus(false, false, false, false), ); }); await waitFor(() => expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(), ); }); // We only display warnings about users in the room. When someone // joins/leaves, we should update the warning appropriately. describe("updates the display when a member joins/leaves", () => { it("when invited users can see encrypted messages", async () => { // Nobody in the room yet jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(true); const crypto = client.getCrypto()!; jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, false, false, true), ); renderComponent(client, room); await sleep(10); // give it some time to finish initialising // Alice joins. Her identity needs approval, so we should show a warning. client.emit( RoomStateEvent.Events, new MatrixEvent({ event_id: "$event_id", type: EventType.RoomMember, state_key: "@alice:example.org", content: { membership: "join", }, room_id: ROOM_ID, sender: "@alice:example.org", }), dummyRoomState(), null, ); await waitFor(() => expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(), ); // Bob is invited. His identity needs approval, so we should show a // warning for him after Alice's warning is resolved by her leaving. client.emit( RoomStateEvent.Events, new MatrixEvent({ event_id: "$event_id", type: EventType.RoomMember, state_key: "@bob:example.org", content: { membership: "invite", }, room_id: ROOM_ID, sender: "@carol:example.org", }), dummyRoomState(), null, ); // Alice leaves, so we no longer show her warning, but we will show // a warning for Bob. act(() => { client.emit( RoomStateEvent.Events, new MatrixEvent({ event_id: "$event_id", type: EventType.RoomMember, state_key: "@alice:example.org", content: { membership: "leave", }, room_id: ROOM_ID, sender: "@alice:example.org", }), dummyRoomState(), null, ); }); await waitFor(() => expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(), ); await waitFor(() => expect(getWarningByText("@bob:example.org's identity appears to have changed.")).toBeInTheDocument(), ); }); it("when invited users cannot see encrypted messages", async () => { // Nobody in the room yet jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); const crypto = client.getCrypto()!; jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, false, false, true), ); renderComponent(client, room); await sleep(10); // give it some time to finish initialising // Alice joins. Her identity needs approval, so we should show a warning. client.emit( RoomStateEvent.Events, new MatrixEvent({ event_id: "$event_id", type: EventType.RoomMember, state_key: "@alice:example.org", content: { membership: "join", }, room_id: ROOM_ID, sender: "@alice:example.org", }), dummyRoomState(), null, ); await waitFor(() => expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(), ); // Bob is invited. His identity needs approval, but we don't encrypt // to him, so we won't show a warning. (When Alice leaves, the // display won't be updated to show a warningfor Bob.) client.emit( RoomStateEvent.Events, new MatrixEvent({ event_id: "$event_id", type: EventType.RoomMember, state_key: "@bob:example.org", content: { membership: "invite", }, room_id: ROOM_ID, sender: "@carol:example.org", }), dummyRoomState(), null, ); // Alice leaves, so we no longer show her warning, and we don't show // a warning for Bob. act(() => { client.emit( RoomStateEvent.Events, new MatrixEvent({ event_id: "$event_id", type: EventType.RoomMember, state_key: "@alice:example.org", content: { membership: "leave", }, room_id: ROOM_ID, sender: "@alice:example.org", }), dummyRoomState(), null, ); }); await waitFor(() => expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(), ); await waitFor(() => expect(() => getWarningByText("@bob:example.org's identity appears to have changed.")).toThrow(), ); }); it("when member leaves immediately after component is loaded", async () => { jest.spyOn(room, "getEncryptionTargetMembers").mockImplementation(async () => { setTimeout(() => { // Alice immediately leaves after we get the room // membership, so we shouldn't show the warning any more client.emit( RoomStateEvent.Events, new MatrixEvent({ event_id: "$event_id", type: EventType.RoomMember, state_key: "@alice:example.org", content: { membership: "leave", }, room_id: ROOM_ID, sender: "@alice:example.org", }), dummyRoomState(), null, ); }); return [mockRoomMember("@alice:example.org")]; }); jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); const crypto = client.getCrypto()!; jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, false, false, true), ); renderComponent(client, room); await sleep(10); expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(); }); it("when member leaves immediately after joining", async () => { // Nobody in the room yet jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); const crypto = client.getCrypto()!; jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, false, false, true), ); renderComponent(client, room); await sleep(10); // give it some time to finish initialising // Alice joins. Her identity needs approval, so we should show a warning. client.emit( RoomStateEvent.Events, new MatrixEvent({ event_id: "$event_id", type: EventType.RoomMember, state_key: "@alice:example.org", content: { membership: "join", }, room_id: ROOM_ID, sender: "@alice:example.org", }), dummyRoomState(), null, ); // ... but she immediately leaves, so we shouldn't show the warning any more client.emit( RoomStateEvent.Events, new MatrixEvent({ event_id: "$event_id", type: EventType.RoomMember, state_key: "@alice:example.org", content: { membership: "leave", }, room_id: ROOM_ID, sender: "@alice:example.org", }), dummyRoomState(), null, ); await sleep(10); // give it some time to finish expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(); }); }); // When we have multiple users whose identity needs approval, one user's // identity no longer needs approval (e.g. their identity was approved), // then we show the next one. it("displays the next user when the current user's identity is approved", async () => { jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ mockRoomMember("@alice:example.org", "Alice"), mockRoomMember("@bob:example.org"), ]); const crypto = client.getCrypto()!; jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( new UserVerificationStatus(false, false, false, true), ); renderComponent(client, room); // We should warn about Alice's identity first. await waitFor(() => expect( getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), ).toBeInTheDocument(), ); // Simulate Alice's new identity having been approved, so now we warn // about Bob's identity. act(() => { client.emit( CryptoEvent.UserTrustStatusChanged, "@alice:example.org", new UserVerificationStatus(false, false, false, false), ); }); await waitFor(() => expect(getWarningByText("@bob:example.org's identity appears to have changed.")).toBeInTheDocument(), ); }); // If we get an update for a user's verification status while we're fetching // that user's verification status, we should display based on the updated // value. describe("handles races between fetching verification status and receiving updates", () => { // First case: check that if the update says that the user identity // needs approval, but the fetch says it doesn't, we show the warning. it("update says identity needs approval", async () => { jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ mockRoomMember("@alice:example.org", "Alice"), ]); jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); const crypto = client.getCrypto()!; jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => { act(() => { client.emit( CryptoEvent.UserTrustStatusChanged, "@alice:example.org", new UserVerificationStatus(false, false, false, true), ); }); return Promise.resolve(new UserVerificationStatus(false, false, false, false)); }); renderComponent(client, room); await sleep(10); // give it some time to finish initialising await waitFor(() => expect( getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), ).toBeInTheDocument(), ); }); // Second case: check that if the update says that the user identity // doesn't needs approval, but the fetch says it does, we don't show the // warning. it("update says identity doesn't need approval", async () => { jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ mockRoomMember("@alice:example.org", "Alice"), ]); jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); const crypto = client.getCrypto()!; jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => { act(() => { client.emit( CryptoEvent.UserTrustStatusChanged, "@alice:example.org", new UserVerificationStatus(false, false, false, false), ); }); return Promise.resolve(new UserVerificationStatus(false, false, false, true)); }); renderComponent(client, room); await sleep(10); // give it some time to finish initialising await waitFor(() => expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), ).toThrow(), ); }); }); });