Add new verification section to user profile (#29200)

* Create new verification section

* Remove old code and use new VerificationSection

* Add styling and translation

* Fix tests

* Remove dead code

* Fix broken test

* Remove imports

* Remove console.log

* Update snapshots

* Fix broken tests

* Fix lint

* Make badge expand with content

* Remove unused code
This commit is contained in:
R Midhun Suresh
2025-02-10 16:52:58 +05:30
committed by GitHub
parent bb8b4d7991
commit 52b42c0b1c
14 changed files with 489 additions and 1095 deletions

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render, screen, cleanup, act, within, waitForElementToBeRemoved } from "jest-matrix-react";
import { fireEvent, render, screen, cleanup, act, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { type Mocked, mocked } from "jest-mock";
import {
@@ -28,12 +28,10 @@ import {
VerificationPhase as Phase,
VerificationRequestEvent,
type CryptoApi,
type DeviceVerificationStatus,
} from "matrix-js-sdk/src/crypto-api";
import UserInfo, {
BanToggleButton,
DeviceItem,
disambiguateDevices,
getPowerLevels,
isMuted,
@@ -48,9 +46,7 @@ import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPan
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import MultiInviter from "../../../../../src/utils/MultiInviter";
import * as mockVerification from "../../../../../src/verification";
import Modal from "../../../../../src/Modal";
import { E2EStatus } from "../../../../../src/utils/ShieldUtils";
import { DirectoryMember, startDmOnFirstMessage } from "../../../../../src/utils/direct-messages";
import { clearAllModals, flushPromises } from "../../../../test-utils";
import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog";
@@ -445,20 +441,6 @@ describe("<UserInfo />", () => {
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
});
it("renders a device list which can be expanded", async () => {
renderComponent();
await flushPromises();
// check the button exists with the expected text
const devicesButton = screen.getByRole("button", { name: "1 session" });
// click it
await userEvent.click(devicesButton);
// there should now be a button with the device id which should contain the device name
expect(screen.getByRole("button", { name: "my device" })).toBeInTheDocument();
});
it("renders <BasicUserInfo />", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
@@ -468,190 +450,9 @@ describe("<UserInfo />", () => {
room: mockRoom,
});
await flushPromises();
await expect(screen.findByRole("button", { name: "Verify" })).resolves.toBeInTheDocument();
expect(container).toMatchSnapshot();
});
describe("device dehydration", () => {
it("hides a verified dehydrated device (unverified user)", async () => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const device2 = new Device({
deviceId: "d2",
userId: defaultUserId,
displayName: "dehydrated device",
algorithms: [],
keys: new Map(),
dehydrated: true,
});
const devicesMap = new Map<string, Device>([
[device1.deviceId, device1],
[device2.deviceId, device2],
]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
renderComponent({ room: mockRoom });
await flushPromises();
// check the button exists with the expected text (the dehydrated device shouldn't be counted)
const devicesButton = screen.getByRole("button", { name: "1 session" });
// click it
await act(() => {
return userEvent.click(devicesButton);
});
// there should now be a button with the non-dehydrated device ID
expect(screen.getByRole("button", { name: "my device" })).toBeInTheDocument();
// but not for the dehydrated device ID
expect(screen.queryByRole("button", { name: "dehydrated device" })).not.toBeInTheDocument();
// there should be a line saying that the user has "Offline device" enabled
expect(screen.getByText("Offline device enabled")).toBeInTheDocument();
});
it("hides a verified dehydrated device (verified user)", async () => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const device2 = new Device({
deviceId: "d2",
userId: defaultUserId,
displayName: "dehydrated device",
algorithms: [],
keys: new Map(),
dehydrated: true,
});
const devicesMap = new Map<string, Device>([
[device1.deviceId, device1],
[device2.deviceId, device2],
]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, true));
mockCrypto.getDeviceVerificationStatus.mockResolvedValue({
isVerified: () => true,
} as DeviceVerificationStatus);
renderComponent({ room: mockRoom });
await flushPromises();
// check the button exists with the expected text (the dehydrated device shouldn't be counted)
const devicesButton = screen.getByRole("button", { name: "1 verified session" });
// click it
await act(() => {
return userEvent.click(devicesButton);
});
// there should now be a button with the non-dehydrated device ID
expect(screen.getByTitle("d1")).toBeInTheDocument();
// but not for the dehydrated device ID
expect(screen.queryByTitle("d2")).not.toBeInTheDocument();
// there should be a line saying that the user has "Offline device" enabled
expect(screen.getByText("Offline device enabled")).toBeInTheDocument();
});
it("shows an unverified dehydrated device", async () => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const device2 = new Device({
deviceId: "d2",
userId: defaultUserId,
displayName: "dehydrated device",
algorithms: [],
keys: new Map(),
dehydrated: true,
});
const devicesMap = new Map<string, Device>([
[device1.deviceId, device1],
[device2.deviceId, device2],
]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, true));
renderComponent({ room: mockRoom });
await flushPromises();
// the dehydrated device should be shown as an unverified device, which means
// there should now be a button with the device id ...
const deviceButton = screen.getByRole("button", { name: "dehydrated device" });
// ... which should contain the device name
expect(within(deviceButton).getByText("dehydrated device")).toBeInTheDocument();
});
it("shows dehydrated devices if there is more than one", async () => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "dehydrated device 1",
algorithms: [],
keys: new Map(),
dehydrated: true,
});
const device2 = new Device({
deviceId: "d2",
userId: defaultUserId,
displayName: "dehydrated device 2",
algorithms: [],
keys: new Map(),
dehydrated: true,
});
const devicesMap = new Map<string, Device>([
[device1.deviceId, device1],
[device2.deviceId, device2],
]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
renderComponent({ room: mockRoom });
await flushPromises();
// check the button exists with the expected text (the dehydrated device shouldn't be counted)
const devicesButton = screen.getByRole("button", { name: "2 sessions" });
// click it
await act(() => {
return userEvent.click(devicesButton);
});
// the dehydrated devices should be shown as an unverified device, which means
// there should now be a button with the first dehydrated device...
const device1Button = screen.getByRole("button", { name: "dehydrated device 1" });
expect(device1Button).toBeVisible();
// ... which should contain the device name
expect(within(device1Button).getByText("dehydrated device 1")).toBeInTheDocument();
// and a button with the second dehydrated device...
const device2Button = screen.getByRole("button", { name: "dehydrated device 2" });
expect(device2Button).toBeVisible();
// ... which should contain the device name
expect(within(device2Button).getByText("dehydrated device 2")).toBeInTheDocument();
});
});
it("should render a deactivate button for users of the same server if we are a server admin", async () => {
mockClient.isSynapseAdministrator.mockResolvedValue(true);
mockClient.getDomain.mockReturnValue("example.com");
@@ -668,34 +469,6 @@ describe("<UserInfo />", () => {
expect(container).toMatchSnapshot();
});
});
describe("with an encrypted room", () => {
beforeEach(() => {
jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true);
});
it("renders unverified user info", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
renderComponent({ room: mockRoom });
await flushPromises();
const userHeading = screen.getByRole("heading", { name: /@user:example.com/ });
// there should be a "normal" E2E padlock
expect(userHeading.getElementsByClassName("mx_E2EIcon_normal")).toHaveLength(1);
});
it("renders verified user info", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, false, false));
renderComponent({ room: mockRoom });
await flushPromises();
const userHeading = screen.getByRole("heading", { name: /@user:example.com/ });
// there should be a "verified" E2E padlock
expect(userHeading.getElementsByClassName("mx_E2EIcon_verified")).toHaveLength(1);
});
});
});
describe("<UserInfoHeader />", () => {
@@ -707,179 +480,51 @@ describe("<UserInfoHeader />", () => {
};
const renderComponent = (props = {}) => {
const device1 = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const devicesMap = new Map<string, Device>([[device1.deviceId, device1]]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(true);
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<UserInfoHeader {...defaultProps} {...props} />, {
return render(<UserInfoHeader {...defaultProps} {...props} devices={[device1]} />, {
wrapper: Wrapper,
});
};
it("does not render an e2e icon in the header if e2eStatus prop is undefined", () => {
renderComponent();
const header = screen.getByRole("heading", { name: defaultUserId });
expect(header.getElementsByClassName("mx_E2EIcon")).toHaveLength(0);
});
it("renders an e2e icon in the header if e2eStatus prop is defined", () => {
renderComponent({ e2eStatus: E2EStatus.Normal });
const header = screen.getByRole("heading");
expect(header.getElementsByClassName("mx_E2EIcon")).toHaveLength(1);
});
it("renders custom user identifiers in the header", () => {
renderComponent();
expect(screen.getByText("customUserIdentifier")).toBeInTheDocument();
});
});
describe("<DeviceItem />", () => {
const device = { deviceId: "deviceId", displayName: "deviceName" } as Device;
const defaultProps = {
userId: defaultUserId,
device,
isUserVerified: false,
};
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<DeviceItem {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
const setMockDeviceTrust = (isVerified = false, isCrossSigningVerified = false) => {
mockCrypto.getDeviceVerificationStatus.mockResolvedValue({
isVerified: () => isVerified,
crossSigningVerified: isCrossSigningVerified,
} as DeviceVerificationStatus);
};
const mockVerifyDevice = jest.spyOn(mockVerification, "verifyDevice");
beforeEach(() => {
setMockDeviceTrust();
});
afterEach(() => {
mockCrypto.getDeviceVerificationStatus.mockReset();
mockVerifyDevice.mockClear();
});
afterAll(() => {
mockVerifyDevice.mockRestore();
});
it("with unverified user and device, displays button without a label", async () => {
renderComponent();
await flushPromises();
expect(screen.getByRole("button", { name: device.displayName! })).toBeInTheDocument();
expect(screen.queryByText(/trusted/i)).not.toBeInTheDocument();
});
it("with verified user only, displays button with a 'Not trusted' label", async () => {
renderComponent({ isUserVerified: true });
await flushPromises();
const button = screen.getByRole("button", { name: device.displayName });
expect(button).toHaveTextContent(`${device.displayName}Not trusted`);
});
it("with verified device only, displays no button without a label", async () => {
setMockDeviceTrust(true);
renderComponent();
await flushPromises();
expect(screen.getByText(device.displayName!)).toBeInTheDocument();
expect(screen.queryByText(/trusted/)).not.toBeInTheDocument();
});
it("when userId is the same as userId from client, uses isCrossSigningVerified to determine if button is shown", async () => {
const deferred = defer<DeviceVerificationStatus>();
mockCrypto.getDeviceVerificationStatus.mockReturnValue(deferred.promise);
mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId);
mockClient.getUserId.mockReturnValueOnce(defaultUserId);
renderComponent();
await flushPromises();
// set trust to be false for isVerified, true for isCrossSigningVerified
deferred.resolve({
isVerified: () => false,
crossSigningVerified: true,
} as DeviceVerificationStatus);
await expect(screen.findByText(device.displayName!)).resolves.toBeInTheDocument();
// expect to see no button in this case
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("with verified user and device, displays no button and a 'Trusted' label", async () => {
setMockDeviceTrust(true);
renderComponent({ isUserVerified: true });
await flushPromises();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.getByText(device.displayName!)).toBeInTheDocument();
expect(screen.getByText("Trusted")).toBeInTheDocument();
});
it("does not call verifyDevice if client.getUser returns null", async () => {
mockClient.getUser.mockReturnValueOnce(null);
renderComponent();
await flushPromises();
const button = screen.getByRole("button", { name: device.displayName! });
expect(button).toBeInTheDocument();
await userEvent.click(button);
expect(mockVerifyDevice).not.toHaveBeenCalled();
});
it("calls verifyDevice if client.getUser returns an object", async () => {
mockClient.getUser.mockReturnValueOnce(defaultUser);
// set mock return of isGuest to short circuit verifyDevice call to avoid
// even more mocking
mockClient.isGuest.mockReturnValueOnce(true);
renderComponent();
await flushPromises();
const button = screen.getByRole("button", { name: device.displayName! });
expect(button).toBeInTheDocument();
await userEvent.click(button);
expect(mockVerifyDevice).toHaveBeenCalledTimes(1);
expect(mockVerifyDevice).toHaveBeenCalledWith(mockClient, defaultUser, device);
});
it("with display name", async () => {
it("renders verified badge when user is verified", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, false));
const { container } = renderComponent();
await flushPromises();
await waitFor(() => expect(screen.getByText("Verified")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
it("without display name", async () => {
const device = { deviceId: "deviceId" } as Device;
const { container } = renderComponent({ device, userId: defaultUserId });
await flushPromises();
it("renders verify button", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
mockCrypto.userHasCrossSigningKeys.mockResolvedValue(true);
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("Verify User")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
it("ambiguous display name", async () => {
const device = { deviceId: "deviceId", ambiguous: true, displayName: "my display name" };
const { container } = renderComponent({ device, userId: defaultUserId });
await flushPromises();
it("renders verification unavailable message", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
mockCrypto.userHasCrossSigningKeys.mockResolvedValue(false);
const { container } = renderComponent();
await waitFor(() => expect(screen.getByText("(User verification unavailable)")).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
});