c2ce7dbc5e
* display a warning when an unverified user's identity changes * use Compound and make comments into doc comments * refactor to use functional component * split into multiple hooks * apply minor changes from review * use Crypto API to determine if room is encrypted * apply changes from review * change initialisation status to a tri-state rather than a boolean * fix more race conditions, and apply changes from review * apply changes from review and switch to using counter for detecting races * Remove outdated comment Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * fix test --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
534 lines
22 KiB
TypeScript
534 lines
22 KiB
TypeScript
/*
|
|
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 `<b>` 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(<UserIdentityWarning room={room} key={ROOM_ID} />, {
|
|
wrapper: ({ ...rest }) => <MatrixClientContext.Provider value={client} {...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(),
|
|
);
|
|
});
|
|
});
|
|
});
|