/* Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. 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 { act, fireEvent, render, RenderResult, screen, waitFor, waitForElementToBeRemoved, within, } from "jest-matrix-react"; import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; import { logger } from "matrix-js-sdk/src/logger"; import { CryptoApi, DeviceVerificationStatus, VerificationRequest } from "matrix-js-sdk/src/crypto-api"; import { defer, sleep } from "matrix-js-sdk/src/utils"; import { ClientEvent, Device, IMyDevice, LOCAL_NOTIFICATION_SETTINGS_PREFIX, MatrixEvent, PUSHER_DEVICE_ID, PUSHER_ENABLED, IAuthData, GET_LOGIN_TOKEN_CAPABILITY, MatrixError, MatrixClient, } from "matrix-js-sdk/src/matrix"; import { mocked, MockedObject } from "jest-mock"; import fetchMock from "fetch-mock-jest"; import { clearAllModals, flushPromises, getMockClientWithEventEmitter, mkPusher, mockClientMethodsServer, mockClientMethodsUser, mockPlatformPeg, } from "../../../../../../test-utils"; import SessionManagerTab from "../../../../../../../src/components/views/settings/tabs/user/SessionManagerTab"; import Modal from "../../../../../../../src/Modal"; import LogoutDialog from "../../../../../../../src/components/views/dialogs/LogoutDialog"; import { DeviceSecurityVariation, ExtendedDevice, } from "../../../../../../../src/components/views/settings/devices/types"; import { INACTIVE_DEVICE_AGE_MS } from "../../../../../../../src/components/views/settings/devices/filter"; import SettingsStore from "../../../../../../../src/settings/SettingsStore"; import { getClientInformationEventType } from "../../../../../../../src/utils/device/clientInformation"; import { SDKContext, SdkContextClass } from "../../../../../../../src/contexts/SDKContext"; import { OidcClientStore } from "../../../../../../../src/stores/oidc/OidcClientStore"; import { mockOpenIdConfiguration } from "../../../../../../test-utils/oidc"; import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext"; mockPlatformPeg(); // to restore later const realWindowLocation = window.location; function deviceToDeviceObj(userId: string, device: IMyDevice, opts: Partial = {}): Device { const deviceOpts: Pick & Partial = { deviceId: device.device_id, userId, algorithms: [], displayName: device.display_name, keys: new Map(), ...opts, }; return new Device(deviceOpts); } describe("", () => { const aliceId = "@alice:server.org"; const deviceId = "alices_device"; const alicesDevice = { device_id: deviceId, display_name: "Alices device", }; const alicesDeviceObj = deviceToDeviceObj(aliceId, alicesDevice); const alicesMobileDevice = { device_id: "alices_mobile_device", last_seen_ts: Date.now(), }; const alicesMobileDeviceObj = deviceToDeviceObj(aliceId, alicesMobileDevice); const alicesOlderMobileDevice = { device_id: "alices_older_mobile_device", last_seen_ts: Date.now() - 600000, }; const alicesInactiveDevice = { device_id: "alices_older_inactive_mobile_device", last_seen_ts: Date.now() - (INACTIVE_DEVICE_AGE_MS + 1000), }; const alicesDehydratedDevice = { device_id: "alices_dehydrated_device", last_seen_ts: Date.now(), }; const alicesDehydratedDeviceObj = deviceToDeviceObj(aliceId, alicesDehydratedDevice, { dehydrated: true }); const alicesOtherDehydratedDevice = { device_id: "alices_other_dehydrated_device", last_seen_ts: Date.now(), }; const alicesOtherDehydratedDeviceObj = deviceToDeviceObj(aliceId, alicesOtherDehydratedDevice, { dehydrated: true, }); const mockVerificationRequest = { cancel: jest.fn(), on: jest.fn(), } as unknown as VerificationRequest; const mockCrypto = mocked({ getDeviceVerificationStatus: jest.fn(), getUserDeviceInfo: jest.fn(), requestDeviceVerification: jest.fn().mockResolvedValue(mockVerificationRequest), supportsSecretsForQrLogin: jest.fn().mockReturnValue(false), isCrossSigningReady: jest.fn().mockReturnValue(true), } as unknown as CryptoApi); let mockClient!: MockedObject; let sdkContext: SdkContextClass; const defaultProps = {}; const getComponent = (props = {}): React.ReactElement => ( ); const toggleDeviceDetails = ( getByTestId: ReturnType["getByTestId"], deviceId: ExtendedDevice["device_id"], isOpen?: boolean, ): void => { // open device detail const tile = getByTestId(`device-tile-${deviceId}`); const label = isOpen ? "Hide details" : "Show details"; const toggle = within(tile).getByLabelText(label); fireEvent.click(toggle); }; const toggleDeviceSelection = ( getByTestId: ReturnType["getByTestId"], deviceId: ExtendedDevice["device_id"], ): void => { const checkbox = getByTestId(`device-tile-checkbox-${deviceId}`); fireEvent.click(checkbox); }; const getDeviceTile = ( getByTestId: ReturnType["getByTestId"], deviceId: ExtendedDevice["device_id"], ): HTMLElement => { return getByTestId(`device-tile-${deviceId}`); }; const setFilter = async (container: HTMLElement, option: DeviceSecurityVariation | string) => { const dropdown = within(container).getByLabelText("Filter devices"); fireEvent.click(dropdown); screen.getByRole("listbox"); fireEvent.click(screen.getByTestId(`filter-option-${option}`) as Element); }; const isDeviceSelected = ( getByTestId: ReturnType["getByTestId"], deviceId: ExtendedDevice["device_id"], ): boolean => !!(getByTestId(`device-tile-checkbox-${deviceId}`) as HTMLInputElement).checked; const isSelectAllChecked = (getByTestId: ReturnType["getByTestId"]): boolean => !!(getByTestId("device-select-all-checkbox") as HTMLInputElement).checked; const confirmSignout = async ( getByTestId: ReturnType["getByTestId"], confirm = true, ): Promise => { // modal has sleeps in rendering process :( await screen.findByRole("dialog"); const buttonId = confirm ? "dialog-primary-button" : "dialog-cancel-button"; fireEvent.click(getByTestId(buttonId)); // flush the confirmation promise await flushPromises(); }; beforeEach(async () => { mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsUser(aliceId), ...mockClientMethodsServer(), getCrypto: jest.fn().mockReturnValue(mockCrypto), getDevices: jest.fn(), getStoredDevice: jest.fn(), getDeviceId: jest.fn().mockReturnValue(deviceId), deleteMultipleDevices: jest.fn(), generateClientSecret: jest.fn(), setDeviceDetails: jest.fn(), getAccountData: jest.fn(), deleteAccountData: jest.fn(), doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(true), getPushers: jest.fn(), setPusher: jest.fn(), setLocalNotificationSettings: jest.fn(), getAuthIssuer: jest.fn().mockReturnValue(new Promise(() => {})), }); jest.clearAllMocks(); jest.spyOn(logger, "error").mockRestore(); mockClient.getStoredDevice.mockImplementation((_userId, id) => { const device = [alicesDevice, alicesMobileDevice].find((device) => device.device_id === id); return device ? new DeviceInfo(device.device_id) : null; }); mockCrypto.getDeviceVerificationStatus.mockReset().mockResolvedValue(new DeviceVerificationStatus({})); mockClient.getDevices.mockReset().mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] }); mockClient.getPushers.mockReset().mockResolvedValue({ pushers: [ mkPusher({ [PUSHER_DEVICE_ID.name]: alicesMobileDevice.device_id, [PUSHER_ENABLED.name]: true, }), ], }); // @ts-ignore mock mockClient.store = { accountData: new Map() }; mockClient.getAccountData.mockReset().mockImplementation((eventType) => { if (eventType.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) { return new MatrixEvent({ type: eventType, content: { is_silenced: false, }, }); } }); sdkContext = new SdkContextClass(); sdkContext.client = mockClient; // @ts-ignore allow delete of non-optional prop delete window.location; // @ts-ignore ugly mocking window.location = { href: "https://localhost/", origin: "https://localhost/", }; // sometimes a verification modal is in modal state when these tests run // make sure the coast is clear await clearAllModals(); }); afterAll(() => { window.location = realWindowLocation; }); it("renders spinner while devices load", () => { const { container } = render(getComponent()); expect(container.getElementsByClassName("mx_Spinner").length).toBeTruthy(); }); it("removes spinner when device fetch fails", async () => { // eat the expected error log jest.spyOn(logger, "error").mockImplementation(() => {}); mockClient.getDevices.mockRejectedValue({ httpStatus: 404 }); const { container } = render(getComponent()); await act(async () => { await flushPromises(); }); expect(container.getElementsByClassName("mx_Spinner").length).toBeFalsy(); }); it("sets device verification status correctly", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }); mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); mockCrypto.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { // alices device is trusted if (deviceId === alicesDevice.device_id) { return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); } // alices mobile device is not if (deviceId === alicesMobileDevice.device_id) { return new DeviceVerificationStatus({}); } // alicesOlderMobileDevice does not support encryption return null; }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); expect(mockCrypto.getDeviceVerificationStatus).toHaveBeenCalledTimes(3); expect( getByTestId(`device-tile-${alicesDevice.device_id}`).querySelector('[aria-label="Verified"]'), ).toBeTruthy(); expect( getByTestId(`device-tile-${alicesMobileDevice.device_id}`).querySelector('[aria-label="Unverified"]'), ).toBeTruthy(); // sessions that dont support encryption use unverified badge expect( getByTestId(`device-tile-${alicesOlderMobileDevice.device_id}`).querySelector('[aria-label="Unverified"]'), ).toBeTruthy(); }); it("extends device with client information when available", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice], }); mockClient.getAccountData.mockImplementation((eventType: string) => { const content = { name: "Element Web", version: "1.2.3", url: "test.com", }; return new MatrixEvent({ type: eventType, content, }); }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); // twice for each device expect(mockClient.getAccountData).toHaveBeenCalledTimes(4); toggleDeviceDetails(getByTestId, alicesDevice.device_id); // application metadata section rendered expect(getByTestId("device-detail-metadata-application")).toBeTruthy(); }); it("renders devices without available client information without error", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice], }); const { getByTestId, queryByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesDevice.device_id); // application metadata section not rendered expect(queryByTestId("device-detail-metadata-application")).toBeFalsy(); }); it("does not render other sessions section when user has only one device", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice] }); const { queryByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); expect(queryByTestId("other-sessions-section")).toBeFalsy(); }); it("renders other sessions section when user has more than one device", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesOlderMobileDevice, alicesMobileDevice], }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); expect(getByTestId("other-sessions-section")).toBeTruthy(); }); it("goes to filtered list from security recommendations", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice], }); const { getByTestId, container } = render(getComponent()); await act(async () => { await flushPromises(); }); fireEvent.click(getByTestId("unverified-devices-cta")); // our session manager waits a tick for rerender await flushPromises(); // unverified filter is set expect(container.querySelector(".mx_FilteredDeviceListHeader")).toMatchSnapshot(); }); describe("current session section", () => { it("disables current session context menu while devices are loading", () => { const { getByTestId } = render(getComponent()); expect(getByTestId("current-session-menu").getAttribute("aria-disabled")).toBeTruthy(); }); it("disables current session context menu when there is no current device", async () => { mockClient.getDevices.mockResolvedValue({ devices: [] }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); expect(getByTestId("current-session-menu").getAttribute("aria-disabled")).toBeTruthy(); }); it("renders current session section with an unverified session", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice], }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); expect(getByTestId("current-session-section")).toMatchSnapshot(); }); it("opens encryption setup dialog when verifiying current session", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice], }); const { getByTestId } = render(getComponent()); const modalSpy = jest.spyOn(Modal, "createDialog"); await act(async () => { await flushPromises(); }); // click verify button from current session section fireEvent.click(getByTestId(`verification-status-button-${alicesDevice.device_id}`)); expect(modalSpy).toHaveBeenCalled(); }); it("renders current session section with a verified session", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice], }); mockClient.getStoredDevice.mockImplementation(() => new DeviceInfo(alicesDevice.device_id)); mockCrypto.getDeviceVerificationStatus.mockResolvedValue( new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }), ); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); expect(getByTestId("current-session-section")).toMatchSnapshot(); }); it("expands current session details", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice], }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); fireEvent.click(getByTestId("current-session-toggle-details")); expect(getByTestId(`device-detail-${alicesDevice.device_id}`)).toBeTruthy(); // only one security card rendered expect(getByTestId("current-session-section").querySelectorAll(".mx_DeviceSecurityCard").length).toEqual(1); }); }); describe("device detail expansion", () => { it("renders no devices expanded by default", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesOlderMobileDevice, alicesMobileDevice], }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); const otherSessionsSection = getByTestId("other-sessions-section"); // no expanded device details expect(otherSessionsSection.getElementsByClassName("mx_DeviceDetails").length).toBeFalsy(); }); it("toggles device expansion on click", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesOlderMobileDevice, alicesMobileDevice], }); const { getByTestId, queryByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesOlderMobileDevice.device_id); // device details are expanded expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy(); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); // both device details are expanded expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy(); expect(getByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeTruthy(); // toggle closed toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id, true); // alicesMobileDevice was toggled off expect(queryByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeFalsy(); // alicesOlderMobileDevice stayed open expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy(); }); }); describe("Device verification", () => { it("does not render device verification cta when current session is not verified", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesOlderMobileDevice, alicesMobileDevice], }); const { getByTestId, queryByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesOlderMobileDevice.device_id); // verify device button is not rendered expect(queryByTestId(`verification-status-button-${alicesOlderMobileDevice.device_id}`)).toBeFalsy(); }); it("renders device verification cta on other sessions when current session is verified", async () => { const modalSpy = jest.spyOn(Modal, "createDialog"); // make the current device verified mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice], }); mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); mockCrypto.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { if (deviceId === alicesDevice.device_id) { return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); } return new DeviceVerificationStatus({}); }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); // click verify button from current session section fireEvent.click(getByTestId(`verification-status-button-${alicesMobileDevice.device_id}`)); expect(mockCrypto.requestDeviceVerification).toHaveBeenCalledWith(aliceId, alicesMobileDevice.device_id); expect(modalSpy).toHaveBeenCalled(); }); it("does not allow device verification on session that do not support encryption", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice], }); mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); mockCrypto.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { // current session verified = able to verify other sessions if (deviceId === alicesDevice.device_id) { return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); } // but alicesMobileDevice doesn't support encryption return null; }); const { getByTestId, queryByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); // no verify button expect(queryByTestId(`verification-status-button-${alicesMobileDevice.device_id}`)).toBeFalsy(); expect( getByTestId(`device-detail-${alicesMobileDevice.device_id}`).getElementsByClassName( "mx_DeviceSecurityCard", ), ).toMatchSnapshot(); }); it("refreshes devices after verifying other device", async () => { const modalSpy = jest.spyOn(Modal, "createDialog"); // make the current device verified mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice], }); mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); mockCrypto.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { if (deviceId === alicesDevice.device_id) { return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); } return new DeviceVerificationStatus({}); }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); // reset mock counter before triggering verification mockClient.getDevices.mockClear(); // click verify button from current session section fireEvent.click(getByTestId(`verification-status-button-${alicesMobileDevice.device_id}`)); const { onFinished: modalOnFinished } = modalSpy.mock.calls[0][1] as any; // simulate modal completing process await modalOnFinished(); // cancelled in case it was a failure exit from modal expect(mockVerificationRequest.cancel).toHaveBeenCalled(); // devices refreshed expect(mockClient.getDevices).toHaveBeenCalled(); }); }); describe("device dehydration", () => { it("Hides a verified dehydrated device", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesDehydratedDevice], }); mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); const devicesMap = new Map([ [alicesDeviceObj.deviceId, alicesDeviceObj], [alicesMobileDeviceObj.deviceId, alicesMobileDeviceObj], [alicesDehydratedDeviceObj.deviceId, alicesDehydratedDeviceObj], ]); const userDeviceMap = new Map>([[aliceId, devicesMap]]); mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); mockCrypto.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { // alices device is trusted if (deviceId === alicesDevice.device_id) { return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); } // the dehydrated device is trusted if (deviceId === alicesDehydratedDevice.device_id) { return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); } // alices mobile device is not if (deviceId === alicesMobileDevice.device_id) { return new DeviceVerificationStatus({}); } return null; }); const { queryByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); expect(queryByTestId(`device-tile-${alicesDevice.device_id}`)).toBeTruthy(); expect(queryByTestId(`device-tile-${alicesMobileDevice.device_id}`)).toBeTruthy(); // the dehydrated device should be hidden expect(queryByTestId(`device-tile-${alicesDehydratedDevice.device_id}`)).toBeFalsy(); }); it("Shows an unverified dehydrated device", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesDehydratedDevice], }); mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); const devicesMap = new Map([ [alicesDeviceObj.deviceId, alicesDeviceObj], [alicesMobileDeviceObj.deviceId, alicesMobileDeviceObj], [alicesDehydratedDeviceObj.deviceId, alicesDehydratedDeviceObj], ]); const userDeviceMap = new Map>([[aliceId, devicesMap]]); mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); mockCrypto.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { // alices device is trusted if (deviceId === alicesDevice.device_id) { return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); } // the dehydrated device is not if (deviceId === alicesDehydratedDevice.device_id) { return new DeviceVerificationStatus({ crossSigningVerified: false, localVerified: false }); } // alices mobile device is not if (deviceId === alicesMobileDevice.device_id) { return new DeviceVerificationStatus({}); } return null; }); const { queryByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); expect(queryByTestId(`device-tile-${alicesDevice.device_id}`)).toBeTruthy(); expect(queryByTestId(`device-tile-${alicesMobileDevice.device_id}`)).toBeTruthy(); // the dehydrated device should be shown since it is unverified expect(queryByTestId(`device-tile-${alicesDehydratedDevice.device_id}`)).toBeTruthy(); }); it("Shows the dehydrated devices if there are multiple", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesDehydratedDevice, alicesOtherDehydratedDevice], }); mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); const devicesMap = new Map([ [alicesDeviceObj.deviceId, alicesDeviceObj], [alicesMobileDeviceObj.deviceId, alicesMobileDeviceObj], [alicesDehydratedDeviceObj.deviceId, alicesDehydratedDeviceObj], [alicesOtherDehydratedDeviceObj.deviceId, alicesOtherDehydratedDeviceObj], ]); const userDeviceMap = new Map>([[aliceId, devicesMap]]); mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); mockCrypto.getDeviceVerificationStatus.mockImplementation(async (_userId, deviceId) => { // alices device is trusted if (deviceId === alicesDevice.device_id) { return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); } // one dehydrated device is trusted if (deviceId === alicesDehydratedDevice.device_id) { return new DeviceVerificationStatus({ crossSigningVerified: true, localVerified: true }); } // the other is not if (deviceId === alicesOtherDehydratedDevice.device_id) { return new DeviceVerificationStatus({ crossSigningVerified: false, localVerified: false }); } // alices mobile device is not if (deviceId === alicesMobileDevice.device_id) { return new DeviceVerificationStatus({}); } return null; }); const { queryByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); expect(queryByTestId(`device-tile-${alicesDevice.device_id}`)).toBeTruthy(); expect(queryByTestId(`device-tile-${alicesMobileDevice.device_id}`)).toBeTruthy(); // both the dehydrated devices should be shown, since there are multiple expect(queryByTestId(`device-tile-${alicesDehydratedDevice.device_id}`)).toBeTruthy(); expect(queryByTestId(`device-tile-${alicesOtherDehydratedDevice.device_id}`)).toBeTruthy(); }); }); describe("Sign out", () => { it("Signs out of current device", async () => { const modalSpy = jest.spyOn(Modal, "createDialog"); mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice], }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesDevice.device_id); const signOutButton = getByTestId("device-detail-sign-out-cta"); expect(signOutButton).toMatchSnapshot(); fireEvent.click(signOutButton); // logout dialog opened expect(modalSpy).toHaveBeenCalledWith(LogoutDialog, {}, undefined, false, true); }); it("Signs out of current device from kebab menu", async () => { const modalSpy = jest.spyOn(Modal, "createDialog"); mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice], }); const { getByTestId, getByLabelText } = render(getComponent()); await act(async () => { await flushPromises(); }); fireEvent.click(getByTestId("current-session-menu")); fireEvent.click(getByLabelText("Sign out")); // logout dialog opened expect(modalSpy).toHaveBeenCalledWith(LogoutDialog, {}, undefined, false, true); }); it("does not render sign out other devices option when only one device", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice], }); const { getByTestId, queryByLabelText } = render(getComponent()); await act(async () => { await flushPromises(); }); fireEvent.click(getByTestId("current-session-menu")); expect(queryByLabelText("Sign out of all other sessions")).toBeFalsy(); }); it("signs out of all other devices from current session context menu", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }); const { getByTestId, getByLabelText } = render(getComponent()); await act(async () => { await flushPromises(); }); fireEvent.click(getByTestId("current-session-menu")); fireEvent.click(getByLabelText("Sign out of all other sessions (2)")); await confirmSignout(getByTestId); // other devices deleted, excluding current device expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( [alicesMobileDevice.device_id, alicesOlderMobileDevice.device_id], undefined, ); }); it("removes account data events for devices after sign out", async () => { const mobileDeviceClientInfo = new MatrixEvent({ type: getClientInformationEventType(alicesMobileDevice.device_id), content: { name: "test", }, }); // @ts-ignore setup mock mockClient.store = { // @ts-ignore setup mock accountData: new Map([[mobileDeviceClientInfo.getType(), mobileDeviceClientInfo]]), }; mockClient.getDevices .mockResolvedValueOnce({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }) .mockResolvedValueOnce({ // refreshed devices after sign out devices: [alicesDevice], }); const { getByTestId, getByLabelText } = render(getComponent()); await act(async () => { await flushPromises(); }); expect(mockClient.deleteAccountData).not.toHaveBeenCalled(); fireEvent.click(getByTestId("current-session-menu")); fireEvent.click(getByLabelText("Sign out of all other sessions (2)")); await confirmSignout(getByTestId); // only called once for signed out device with account data event expect(mockClient.deleteAccountData).toHaveBeenCalledTimes(1); expect(mockClient.deleteAccountData).toHaveBeenCalledWith(mobileDeviceClientInfo.getType()); }); describe("other devices", () => { const interactiveAuthError = new MatrixError( { flows: [{ stages: ["m.login.password"] }], }, 401, ); beforeEach(() => { mockClient.deleteMultipleDevices.mockReset(); }); it("deletes a device when interactive auth is not required", async () => { mockClient.deleteMultipleDevices.mockResolvedValue({}); mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }); const { getByTestId, findByTestId } = render(getComponent()); await waitForElementToBeRemoved(() => screen.queryAllByRole("progressbar")); await toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); const signOutButton = await within( await findByTestId(`device-detail-${alicesMobileDevice.device_id}`), ).findByTestId("device-detail-sign-out-cta"); // pretend it was really deleted on refresh mockClient.getDevices.mockResolvedValueOnce({ devices: [alicesDevice, alicesOlderMobileDevice], }); // sign out button is disabled with spinner const prom = waitFor(() => expect(signOutButton).toHaveAttribute("aria-disabled", "true")); fireEvent.click(signOutButton); await confirmSignout(getByTestId); await prom; // delete called expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( [alicesMobileDevice.device_id], undefined, ); await flushPromises(); // devices refreshed expect(mockClient.getDevices).toHaveBeenCalled(); }); it("does not delete a device when interactive auth is not required", async () => { const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); const deviceDetails = getByTestId(`device-detail-${alicesMobileDevice.device_id}`); const signOutButton = deviceDetails.querySelector( '[data-testid="device-detail-sign-out-cta"]', ) as Element; fireEvent.click(signOutButton); await confirmSignout(getByTestId, false); // doesnt enter loading state expect( (deviceDetails.querySelector('[data-testid="device-detail-sign-out-cta"]') as Element).getAttribute( "aria-disabled", ), ).toEqual(null); // delete not called expect(mockClient.deleteMultipleDevices).not.toHaveBeenCalled(); }); it("deletes a device when interactive auth is required", async () => { mockClient.deleteMultipleDevices // require auth .mockRejectedValueOnce(interactiveAuthError) // then succeed .mockResolvedValueOnce({}); mockClient.getDevices .mockResolvedValueOnce({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }) // pretend it was really deleted on refresh .mockResolvedValueOnce({ devices: [alicesDevice, alicesOlderMobileDevice], }); const { getByTestId, getByLabelText } = render(getComponent()); await act(flushPromises); // reset mock count after initial load mockClient.getDevices.mockClear(); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); const deviceDetails = getByTestId(`device-detail-${alicesMobileDevice.device_id}`); const signOutButton = deviceDetails.querySelector( '[data-testid="device-detail-sign-out-cta"]', ) as Element; fireEvent.click(signOutButton); await confirmSignout(getByTestId); await flushPromises(); // modal rendering has some weird sleeps await screen.findByRole("dialog"); expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( [alicesMobileDevice.device_id], undefined, ); const modal = document.getElementsByClassName("mx_Dialog"); expect(modal.length).toBeTruthy(); // fill password and submit for interactive auth act(() => { fireEvent.change(getByLabelText("Password"), { target: { value: "topsecret" }, }); fireEvent.submit(getByLabelText("Password")); }); await flushPromises(); // called again with auth expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([alicesMobileDevice.device_id], { identifier: { type: "m.id.user", user: aliceId, }, password: "", type: "m.login.password", }); // devices refreshed expect(mockClient.getDevices).toHaveBeenCalled(); }); it("clears loading state when device deletion is cancelled during interactive auth", async () => { mockClient.deleteMultipleDevices // require auth .mockRejectedValueOnce(interactiveAuthError) // then succeed .mockResolvedValueOnce({}); mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }); const { getByTestId, getByLabelText } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); const deviceDetails = getByTestId(`device-detail-${alicesMobileDevice.device_id}`); const signOutButton = deviceDetails.querySelector( '[data-testid="device-detail-sign-out-cta"]', ) as Element; fireEvent.click(signOutButton); await confirmSignout(getByTestId); // button is loading expect( (deviceDetails.querySelector('[data-testid="device-detail-sign-out-cta"]') as Element).getAttribute( "aria-disabled", ), ).toEqual("true"); await flushPromises(); // Modal rendering has some weird sleeps. // Resetting ourselves twice in the main loop gives modal the chance to settle. await sleep(0); await sleep(0); expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( [alicesMobileDevice.device_id], undefined, ); const modal = document.getElementsByClassName("mx_Dialog"); expect(modal.length).toBeTruthy(); // cancel iau by closing modal act(() => { fireEvent.click(getByLabelText("Close dialog")); }); await flushPromises(); // not called again expect(mockClient.deleteMultipleDevices).toHaveBeenCalledTimes(1); // devices not refreshed (not called since initial fetch) expect(mockClient.getDevices).toHaveBeenCalledTimes(1); // loading state cleared expect( (deviceDetails.querySelector('[data-testid="device-detail-sign-out-cta"]') as Element).getAttribute( "aria-disabled", ), ).toEqual(null); }); it("deletes multiple devices", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice, alicesInactiveDevice], }); // get a handle for resolving the delete call // because promise flushing after the confirm modal is resolving this too // and we want to test the loading state here const resolveDeleteRequest = defer(); mockClient.deleteMultipleDevices.mockImplementation(() => { return resolveDeleteRequest.promise; }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id); toggleDeviceSelection(getByTestId, alicesOlderMobileDevice.device_id); fireEvent.click(getByTestId("sign-out-selection-cta")); await confirmSignout(getByTestId); // buttons disabled in list header expect(getByTestId("sign-out-selection-cta").getAttribute("aria-disabled")).toBeTruthy(); expect(getByTestId("cancel-selection-cta").getAttribute("aria-disabled")).toBeTruthy(); // spinner rendered in list header expect(getByTestId("sign-out-selection-cta").querySelector(".mx_Spinner")).toBeTruthy(); // spinners on signing out devices expect( getDeviceTile(getByTestId, alicesMobileDevice.device_id).querySelector(".mx_Spinner"), ).toBeTruthy(); expect( getDeviceTile(getByTestId, alicesOlderMobileDevice.device_id).querySelector(".mx_Spinner"), ).toBeTruthy(); // no spinner for device that is not signing out expect( getDeviceTile(getByTestId, alicesInactiveDevice.device_id).querySelector(".mx_Spinner"), ).toBeFalsy(); // delete called with both ids expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( [alicesMobileDevice.device_id, alicesOlderMobileDevice.device_id], undefined, ); resolveDeleteRequest.resolve({}); }); it("signs out of all other devices from other sessions context menu", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }); const { getByTestId, getByLabelText } = render(getComponent()); await act(async () => { await flushPromises(); }); fireEvent.click(getByTestId("other-sessions-menu")); fireEvent.click(getByLabelText("Sign out of 2 sessions")); await confirmSignout(getByTestId); // other devices deleted, excluding current device expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( [alicesMobileDevice.device_id, alicesOlderMobileDevice.device_id], undefined, ); }); }); describe("for an OIDC-aware server", () => { beforeEach(() => { // just do an ugly mock here to avoid mocking initialisation const mockOidcClientStore = { accountManagementEndpoint: "https://issuer.org/account", } as unknown as OidcClientStore; jest.spyOn(sdkContext, "oidcClientStore", "get").mockReturnValue(mockOidcClientStore); }); // signing out the current device works as usual it("Signs out of current device", async () => { const modalSpy = jest.spyOn(Modal, "createDialog"); mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice], }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesDevice.device_id); const signOutButton = getByTestId("device-detail-sign-out-cta"); expect(signOutButton).toMatchSnapshot(); fireEvent.click(signOutButton); // logout dialog opened expect(modalSpy).toHaveBeenCalledWith(LogoutDialog, {}, undefined, false, true); }); it("does not allow signing out of all other devices from current session context menu", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); fireEvent.click(getByTestId("current-session-menu")); expect(screen.queryByLabelText("Sign out of all other sessions (2)")).not.toBeInTheDocument(); }); describe("other devices", () => { it("opens delegated auth provider to sign out a single device", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); // reset call count mockClient.getDevices.mockClear(); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); const deviceDetails = getByTestId(`device-detail-${alicesMobileDevice.device_id}`); const signOutButton = deviceDetails.querySelector( '[data-testid="device-detail-sign-out-cta"]', ) as Element; fireEvent.click(signOutButton); await screen.findByRole("dialog"); expect( screen.getByText( "You will be redirected to your server's authentication provider to complete sign out.", ), ).toBeInTheDocument(); // correct link to auth provider expect(screen.getByText("Continue")).toHaveAttribute( "href", `https://issuer.org/account?action=session_end&device_id=${alicesMobileDevice.device_id}`, ); // go to the link fireEvent.click(screen.getByText("Continue")); await flushPromises(); // come back from the link and close the modal fireEvent.click(screen.getByText("Close")); await flushPromises(); // devices were refreshed expect(mockClient.getDevices).toHaveBeenCalled(); }); it("does not allow removing multiple devices at once", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice, alicesInactiveDevice], }); render(getComponent()); await act(async () => { await flushPromises(); }); // sessions don't have checkboxes expect( screen.queryByTestId(`device-tile-checkbox-${alicesMobileDevice.device_id}`), ).not.toBeInTheDocument(); // no select all expect(screen.queryByLabelText("Select all")).not.toBeInTheDocument(); }); it("does not allow signing out of all other devices from other sessions context menu", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }); render(getComponent()); await act(async () => { await flushPromises(); }); // no context menu because 'sign out all' is the only option // and it is not allowed when server is oidc-aware expect(screen.queryByTestId("other-sessions-menu")).not.toBeInTheDocument(); }); }); }); }); describe("Rename sessions", () => { const updateDeviceName = async ( getByTestId: RenderResult["getByTestId"], device: IMyDevice, newDeviceName: string, ) => { toggleDeviceDetails(getByTestId, device.device_id); // start editing fireEvent.click(getByTestId("device-heading-rename-cta")); const input = getByTestId("device-rename-input"); fireEvent.change(input, { target: { value: newDeviceName } }); fireEvent.click(getByTestId("device-rename-submit-cta")); await flushPromises(); await flushPromises(); }; it("renames current session", async () => { const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); const newDeviceName = "new device name"; await updateDeviceName(getByTestId, alicesDevice, newDeviceName); expect(mockClient.setDeviceDetails).toHaveBeenCalledWith(alicesDevice.device_id, { display_name: newDeviceName, }); // devices refreshed expect(mockClient.getDevices).toHaveBeenCalledTimes(2); }); it("renames other session", async () => { const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); const newDeviceName = "new device name"; await updateDeviceName(getByTestId, alicesMobileDevice, newDeviceName); expect(mockClient.setDeviceDetails).toHaveBeenCalledWith(alicesMobileDevice.device_id, { display_name: newDeviceName, }); // devices refreshed expect(mockClient.getDevices).toHaveBeenCalledTimes(2); }); it("does not rename session or refresh devices is session name is unchanged", async () => { const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); await updateDeviceName(getByTestId, alicesDevice, alicesDevice.display_name); expect(mockClient.setDeviceDetails).not.toHaveBeenCalled(); // only called once on initial load expect(mockClient.getDevices).toHaveBeenCalledTimes(1); }); it("saves an empty session display name successfully", async () => { const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); await updateDeviceName(getByTestId, alicesDevice, ""); expect(mockClient.setDeviceDetails).toHaveBeenCalledWith(alicesDevice.device_id, { display_name: "", }); }); it("displays an error when session display name fails to save", async () => { const logSpy = jest.spyOn(logger, "error"); const error = new Error("oups"); mockClient.setDeviceDetails.mockRejectedValue(error); const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); const newDeviceName = "new device name"; await updateDeviceName(getByTestId, alicesDevice, newDeviceName); await flushPromises(); expect(logSpy).toHaveBeenCalledWith("Error setting device name", error); // error displayed expect(getByTestId("device-rename-error")).toBeTruthy(); }); }); describe("Multiple selection", () => { beforeEach(() => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }); }); it("toggles session selection", async () => { const { getByTestId, getByText } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id); toggleDeviceSelection(getByTestId, alicesOlderMobileDevice.device_id); // header displayed correctly expect(getByText("2 sessions selected")).toBeTruthy(); expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy(); expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy(); toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id); // unselected expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeFalsy(); // still selected expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy(); }); it("cancel button clears selection", async () => { const { getByTestId, getByText } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id); toggleDeviceSelection(getByTestId, alicesOlderMobileDevice.device_id); // header displayed correctly expect(getByText("2 sessions selected")).toBeTruthy(); fireEvent.click(getByTestId("cancel-selection-cta")); // unselected expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeFalsy(); expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeFalsy(); }); it("changing the filter clears selection", async () => { const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id); expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy(); fireEvent.click(getByTestId("unverified-devices-cta")); // our session manager waits a tick for rerender await flushPromises(); // unselected expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeFalsy(); }); describe("toggling select all", () => { it("selects all sessions when there is not existing selection", async () => { const { getByTestId, getByText } = render(getComponent()); await act(async () => { await flushPromises(); }); fireEvent.click(getByTestId("device-select-all-checkbox")); // header displayed correctly expect(getByText("2 sessions selected")).toBeTruthy(); expect(isSelectAllChecked(getByTestId)).toBeTruthy(); // devices selected expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy(); expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy(); }); it("selects all sessions when some sessions are already selected", async () => { const { getByTestId, getByText } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id); fireEvent.click(getByTestId("device-select-all-checkbox")); // header displayed correctly expect(getByText("2 sessions selected")).toBeTruthy(); expect(isSelectAllChecked(getByTestId)).toBeTruthy(); // devices selected expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy(); expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy(); }); it("deselects all sessions when all sessions are selected", async () => { const { getByTestId, getByText } = render(getComponent()); await act(async () => { await flushPromises(); }); fireEvent.click(getByTestId("device-select-all-checkbox")); // header displayed correctly expect(getByText("2 sessions selected")).toBeTruthy(); expect(isSelectAllChecked(getByTestId)).toBeTruthy(); // devices selected expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy(); expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy(); }); it("selects only sessions that are part of the active filter", async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesInactiveDevice], }); const { getByTestId, container } = render(getComponent()); await act(flushPromises); // filter for inactive sessions await setFilter(container, DeviceSecurityVariation.Inactive); // select all inactive sessions fireEvent.click(getByTestId("device-select-all-checkbox")); expect(isSelectAllChecked(getByTestId)).toBeTruthy(); // sign out of all selected sessions fireEvent.click(getByTestId("sign-out-selection-cta")); await confirmSignout(getByTestId); // only called with session from active filter expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( [alicesInactiveDevice.device_id], undefined, ); }); }); }); it("lets you change the pusher state", async () => { const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); // device details are expanded expect(getByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeTruthy(); expect(getByTestId("device-detail-push-notification")).toBeTruthy(); const checkbox = getByTestId("device-detail-push-notification-checkbox"); expect(checkbox).toBeTruthy(); fireEvent.click(checkbox); expect(mockClient.setPusher).toHaveBeenCalled(); }); it("lets you change the local notification settings state", async () => { const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesDevice.device_id); // device details are expanded expect(getByTestId(`device-detail-${alicesDevice.device_id}`)).toBeTruthy(); expect(getByTestId("device-detail-push-notification")).toBeTruthy(); const checkbox = getByTestId("device-detail-push-notification-checkbox"); expect(checkbox).toBeTruthy(); fireEvent.click(checkbox); expect(mockClient.setLocalNotificationSettings).toHaveBeenCalledWith(alicesDevice.device_id, { is_silenced: true, }); }); it("updates the UI when another session changes the local notifications", async () => { const { getByTestId } = render(getComponent()); await act(async () => { await flushPromises(); }); toggleDeviceDetails(getByTestId, alicesDevice.device_id); // device details are expanded expect(getByTestId(`device-detail-${alicesDevice.device_id}`)).toBeTruthy(); expect(getByTestId("device-detail-push-notification")).toBeTruthy(); const checkbox = getByTestId("device-detail-push-notification-checkbox"); expect(checkbox).toBeTruthy(); expect(checkbox.getAttribute("aria-checked")).toEqual("true"); const evt = new MatrixEvent({ type: LOCAL_NOTIFICATION_SETTINGS_PREFIX.name + "." + alicesDevice.device_id, content: { is_silenced: true, }, }); await act(async () => { mockClient.emit(ClientEvent.AccountData, evt); }); expect(checkbox.getAttribute("aria-checked")).toEqual("false"); }); describe("MSC3906 QR code login", () => { const settingsValueSpy = jest.spyOn(SettingsStore, "getValue"); beforeEach(() => { settingsValueSpy.mockClear().mockReturnValue(false); // enable server support for qr login mockClient.getVersions.mockResolvedValue({ versions: [], unstable_features: { "org.matrix.msc3886": true, }, }); mockClient.getCapabilities.mockResolvedValue({ [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true, }, }); }); it("renders qr code login section", async () => { const { getByText } = render(getComponent()); // wait for versions call to settle await flushPromises(); expect(getByText("Link new device")).toBeTruthy(); expect(getByText("Show QR code")).toBeTruthy(); }); it("enters qr code login section when show QR code button clicked", async () => { const { getByText, findByTestId } = render(getComponent()); // wait for versions call to settle await flushPromises(); fireEvent.click(getByText("Show QR code")); await expect(findByTestId("login-with-qr")).resolves.toBeTruthy(); }); }); describe("MSC4108 QR code login", () => { const settingsValueSpy = jest.spyOn(SettingsStore, "getValue"); const issuer = "https://issuer.org"; const openIdConfiguration = mockOpenIdConfiguration(issuer); beforeEach(() => { settingsValueSpy.mockClear().mockReturnValue(true); // enable server support for qr login mockClient.getVersions.mockResolvedValue({ versions: [], unstable_features: { "org.matrix.msc4108": true, }, }); mockClient.getCapabilities.mockResolvedValue({ [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true, }, }); mockClient.getAuthIssuer.mockResolvedValue({ issuer }); mockCrypto.exportSecretsBundle = jest.fn(); fetchMock.mock(`${issuer}/.well-known/openid-configuration`, { ...openIdConfiguration, grant_types_supported: [ ...openIdConfiguration.grant_types_supported, "urn:ietf:params:oauth:grant-type:device_code", ], }); fetchMock.mock(openIdConfiguration.jwks_uri!, { status: 200, headers: { "Content-Type": "application/json", }, keys: [], }); }); it("renders qr code login section", async () => { const { getByText } = render(getComponent()); // wait for versions call to settle await flushPromises(); expect(getByText("Link new device")).toBeTruthy(); expect(getByText("Show QR code")).toBeTruthy(); }); it("enters qr code login section when show QR code button clicked", async () => { const { getByText, findByTestId } = render(getComponent()); // wait for versions call to settle await flushPromises(); fireEvent.click(getByText("Show QR code")); await waitForElementToBeRemoved(() => screen.queryAllByRole("progressbar")); await expect(findByTestId("login-with-qr")).resolves.toBeTruthy(); }); }); });