/* Copyright 2024 New Vector Ltd. Copyright 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ // fake-indexeddb needs this and the tests crash without it // https://github.com/dumbmatter/fakeIndexedDB?tab=readme-ov-file#jsdom-often-used-with-jest import "core-js/stable/structured-clone"; import "fake-indexeddb/auto"; import React, { type ComponentProps } from "react"; import { fireEvent, render, type RenderResult, screen, waitFor, within, act } from "jest-matrix-react"; import fetchMock from "fetch-mock-jest"; import { type Mocked, mocked } from "jest-mock"; import { ClientEvent, type MatrixClient, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix"; import { type MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler"; import * as MatrixJs from "matrix-js-sdk/src/matrix"; import { completeAuthorizationCodeGrant } from "matrix-js-sdk/src/oidc/authorize"; import { logger } from "matrix-js-sdk/src/logger"; import { OidcError } from "matrix-js-sdk/src/oidc/error"; import { type BearerTokenResponse } from "matrix-js-sdk/src/oidc/validate"; import { sleep } from "matrix-js-sdk/src/utils"; import { CryptoEvent, type DeviceVerificationStatus, UserVerificationStatus, type CryptoApi, } from "matrix-js-sdk/src/crypto-api"; import MatrixChat from "../../../../src/components/structures/MatrixChat"; import * as StorageAccess from "../../../../src/utils/StorageAccess"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../src/dispatcher/actions"; import { UserTab } from "../../../../src/components/views/dialogs/UserTab"; import { clearAllModals, createStubMatrixRTC, filterConsole, flushPromises, getMockClientWithEventEmitter, mockClientMethodsServer, mockClientMethodsUser, MockClientWithEventEmitter, mockPlatformPeg, resetJsDomAfterEach, unmockClientPeg, } from "../../../test-utils"; import * as leaveRoomUtils from "../../../../src/utils/leave-behaviour"; import { OidcClientError } from "../../../../src/utils/oidc/error"; import LegacyCallHandler from "../../../../src/LegacyCallHandler"; import { CallStore } from "../../../../src/stores/CallStore"; import { type Call } from "../../../../src/models/Call"; import { PosthogAnalytics } from "../../../../src/PosthogAnalytics"; import PlatformPeg from "../../../../src/PlatformPeg"; import EventIndexPeg from "../../../../src/indexing/EventIndexPeg"; import * as Lifecycle from "../../../../src/Lifecycle"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../../src/BasePlatform"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../../src/settings/SettingLevel"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { ReleaseAnnouncementStore } from "../../../../src/stores/ReleaseAnnouncementStore"; import { DRAFT_LAST_CLEANUP_KEY } from "../../../../src/DraftCleaner"; import { UIFeature } from "../../../../src/settings/UIFeature"; import AutoDiscoveryUtils from "../../../../src/utils/AutoDiscoveryUtils"; import { type ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig"; import Modal from "../../../../src/Modal.tsx"; import { SetupEncryptionStore } from "../../../../src/stores/SetupEncryptionStore.ts"; import { ShareFormat } from "../../../../src/dispatcher/payloads/SharePayload.ts"; import { clearStorage } from "../../../../src/Lifecycle"; import RoomListStore from "../../../../src/stores/room-list/RoomListStore.ts"; import UserSettingsDialog from "../../../../src/components/views/dialogs/UserSettingsDialog.tsx"; import { SdkContextClass } from "../../../../src/contexts/SDKContext.ts"; jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({ completeAuthorizationCodeGrant: jest.fn(), })); // Stub out ThemeWatcher as the necessary bits for themes are done in element-web's index.html and thus are lacking here, // plus JSDOM's implementation of CSSStyleDeclaration has a bunch of differences to real browsers which cause issues. jest.mock("../../../../src/settings/watchers/ThemeWatcher"); /** The matrix versions our mock server claims to support */ const SERVER_SUPPORTED_MATRIX_VERSIONS = ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"]; describe("", () => { const userId = "@alice:server.org"; const deviceId = "qwertyui"; const accessToken = "abc123"; const refreshToken = "def456"; let bootstrapDeferred: PromiseWithResolvers; // reused in createClient mock below const getMockClientMethods = () => ({ ...mockClientMethodsUser(userId), ...mockClientMethodsServer(), getVersions: jest.fn().mockResolvedValue({ versions: SERVER_SUPPORTED_MATRIX_VERSIONS }), startClient: function () { // @ts-ignore this.emit(ClientEvent.Sync, SyncState.Prepared, null); }, stopClient: jest.fn(), setCanResetTimelineCallback: jest.fn(), isInitialSyncComplete: jest.fn(), getSyncState: jest.fn(), getSsoLoginUrl: jest.fn(), getSyncStateData: jest.fn().mockReturnValue(null), getThirdpartyProtocols: jest.fn().mockResolvedValue({}), getClientWellKnown: jest.fn().mockReturnValue({}), isVersionSupported: jest.fn().mockResolvedValue(false), initRustCrypto: jest.fn(), getRoom: jest.fn(), getMediaHandler: jest.fn().mockReturnValue({ setVideoInput: jest.fn(), setAudioInput: jest.fn(), setAudioSettings: jest.fn(), stopAllStreams: jest.fn(), } as unknown as MediaHandler), setAccountData: jest.fn(), store: { destroy: jest.fn(), startup: jest.fn(), }, login: jest.fn(), loginFlows: jest.fn().mockResolvedValue({ flows: [] }), isGuest: jest.fn().mockReturnValue(false), clearStores: jest.fn(), setGuest: jest.fn(), setNotifTimelineSet: jest.fn(), getAccountData: jest.fn(), doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false), getDevices: jest.fn().mockResolvedValue({ devices: [] }), getProfileInfo: jest.fn().mockResolvedValue({ displayname: "Ernie", }), getVisibleRooms: jest.fn().mockReturnValue([]), getRooms: jest.fn().mockReturnValue([]), getCrypto: jest.fn().mockReturnValue({ getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]), isCrossSigningReady: jest.fn().mockReturnValue(false), isDehydrationSupported: jest.fn().mockReturnValue(false), getUserDeviceInfo: jest.fn().mockReturnValue(new Map()), getUserVerificationStatus: jest.fn().mockResolvedValue(new UserVerificationStatus(false, false, false)), getVersion: jest.fn().mockReturnValue("1"), setDeviceIsolationMode: jest.fn(), userHasCrossSigningKeys: jest.fn(), getActiveSessionBackupVersion: jest.fn().mockResolvedValue(null), globalBlacklistUnverifiedDevices: false, // This needs to not finish immediately because we need to test the screen appears bootstrapCrossSigning: jest.fn().mockImplementation(() => bootstrapDeferred.promise), getKeyBackupInfo: jest.fn().mockResolvedValue(null), }), secretStorage: { isStored: jest.fn().mockReturnValue(null), }, matrixRTC: createStubMatrixRTC(), getDehydratedDevice: jest.fn(), whoami: jest.fn(), logout: jest.fn(), getDeviceId: jest.fn(), forget: () => Promise.resolve(), }); let mockClient: Mocked; const serverConfig = { hsUrl: "https://test.com", hsName: "Test Server", hsNameIsDifferent: false, isUrl: "https://is.com", isDefault: true, isNameResolvable: true, warning: "", }; let defaultProps: ComponentProps; const getComponent = (props: Partial> = {}) => { return render(); }; // make test results readable filterConsole( "Failed to parse localStorage object", "Sync store cannot be used on this browser", "Crypto store cannot be used on this browser", "Storage consistency checks failed", "LegacyCallHandler: missing => { // need to wait for different elements depending on which flow // without security setup we go to a loading page if (withoutSecuritySetup) { // wait for logged in view to load await screen.findByLabelText("User menu"); // otherwise we stay on login and load from there for longer } else { // we are logged in, but are still waiting for the /sync to complete await screen.findByText("Syncing…"); // initial sync await act(() => client.emit(ClientEvent.Sync, SyncState.Prepared, null)); } // let things settle await flushPromises(); // and some more for good measure // this proved to be a little flaky await flushPromises(); }; beforeEach(async () => { await clearStorage(); Lifecycle.setSessionLockNotStolen(); localStorage.clear(); jest.restoreAllMocks(); defaultProps = { config: { brand: "Test", help_url: "help_url", help_encryption_url: "help_encryption_url", element_call: {}, feedback: { existing_issues_url: "https://feedback.org/existing", new_issue_url: "https://feedback.org/new", }, validated_server_config: serverConfig, }, onNewScreen: jest.fn(), onTokenLoginCompleted: jest.fn(), realQueryParams: {}, }; mockClient = getMockClientWithEventEmitter(getMockClientMethods()); jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient); jest.spyOn(defaultDispatcher, "dispatch").mockClear(); jest.spyOn(defaultDispatcher, "fire").mockClear(); DMRoomMap.makeShared(mockClient); jest.spyOn(AutoDiscoveryUtils, "validateServerConfigWithStaticUrls").mockResolvedValue( {} as ValidatedServerConfig, ); bootstrapDeferred = Promise.withResolvers(); await clearAllModals(); }); afterEach(async () => { // @ts-ignore DMRoomMap.setShared(null); // emit a loggedOut event so that all of the Store singletons forget about their references to the mock client // (must be sync otherwise the next test will start before it happens) act(() => defaultDispatcher.dispatch({ action: Action.OnLoggedOut }, true)); localStorage.clear(); // This is a massive hack, but ... // // A lot of these tests end up completing while the login flow is still proceeding. So then, we start the next // test while stuff is still ongoing from the previous test, which messes up the current test (by changing // localStorage or opening modals, or whatever). // // There is no obvious event we could wait for which indicates that everything has completed, since each test // does something different. Instead... await act(() => sleep(200)); }); resetJsDomAfterEach(); it("should render spinner while app is loading", () => { const { container } = getComponent(); expect(container).toMatchSnapshot(); }); it("should fire to focus the message composer", async () => { getComponent(); defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: "!room:server.org", focusNext: "composer" }); await waitFor(() => { expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.FocusSendMessageComposer); }); }); it("should fire to focus the threads panel", async () => { getComponent(); defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: "!room:server.org", focusNext: "threadsPanel" }); await waitFor(() => { expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.FocusThreadsPanel); }); }); describe("when query params have a OIDC params", () => { const issuer = "https://auth.com/"; const homeserverUrl = "https://matrix.org"; const identityServerUrl = "https://is.org"; const clientId = "xyz789"; const code = "test-oidc-auth-code"; const state = "test-oidc-state"; const realQueryParams = { code, state: state, }; const deviceId = "test-device-id"; const accessToken = "test-access-token-from-oidc"; const tokenResponse: BearerTokenResponse = { access_token: accessToken, refresh_token: "def456", id_token: "ghi789", scope: "test", token_type: "Bearer", expires_at: 12345, }; let loginClient!: ReturnType; const expectOIDCError = async ( errorMessage = "Something went wrong during authentication. Go to the sign in page and try again.", ): Promise => { await flushPromises(); const dialog = await screen.findByRole("dialog"); await waitFor(() => expect(within(dialog).getByText(errorMessage)).toBeInTheDocument()); }; beforeEach(() => { mocked(completeAuthorizationCodeGrant) .mockClear() .mockResolvedValue({ oidcClientSettings: { clientId, issuer, }, tokenResponse, homeserverUrl, identityServerUrl, idTokenClaims: { aud: "123", iss: issuer, sub: "123", exp: 123, iat: 456, }, }); loginClient = getMockClientWithEventEmitter(getMockClientMethods()); // this is used to create a temporary client during login jest.spyOn(MatrixJs, "createClient").mockReturnValue(loginClient); jest.spyOn(logger, "error").mockClear(); jest.spyOn(logger, "log").mockClear(); loginClient.whoami.mockResolvedValue({ user_id: userId, device_id: deviceId, is_guest: false, }); }); it("should fail when query params do not include valid code and state", async () => { const queryParams = { code: 123, state: "abc", }; getComponent({ realQueryParams: queryParams }); await flushPromises(); expect(logger.error).toHaveBeenCalledWith( "Failed to login via OIDC", new Error(OidcClientError.InvalidQueryParameters), ); await expectOIDCError(); }); it("should make correct request to complete authorization", async () => { getComponent({ realQueryParams }); await flushPromises(); expect(completeAuthorizationCodeGrant).toHaveBeenCalledWith(code, state); }); it("should look up userId using access token", async () => { getComponent({ realQueryParams }); await flushPromises(); // check we used a client with the correct accesstoken expect(MatrixJs.createClient).toHaveBeenCalledWith({ baseUrl: homeserverUrl, accessToken, idBaseUrl: identityServerUrl, }); expect(loginClient.whoami).toHaveBeenCalled(); }); it("should log error and return to welcome page when userId lookup fails", async () => { loginClient.whoami.mockRejectedValue(new Error("oups")); getComponent({ realQueryParams }); await flushPromises(); expect(logger.error).toHaveBeenCalledWith( "Failed to login via OIDC", new Error("Failed to retrieve userId using accessToken"), ); await expectOIDCError(); }); it("should call onTokenLoginCompleted", async () => { const onTokenLoginCompleted = jest.fn(); getComponent({ realQueryParams, onTokenLoginCompleted }); await waitFor(() => expect(onTokenLoginCompleted).toHaveBeenCalled()); }); describe("when login fails", () => { beforeEach(() => { mocked(completeAuthorizationCodeGrant).mockRejectedValue(new Error(OidcError.CodeExchangeFailed)); }); it("should log and return to welcome page with correct error when login state is not found", async () => { mocked(completeAuthorizationCodeGrant).mockRejectedValue( new Error(OidcError.MissingOrInvalidStoredState), ); getComponent({ realQueryParams }); await flushPromises(); expect(logger.error).toHaveBeenCalledWith( "Failed to login via OIDC", new Error(OidcError.MissingOrInvalidStoredState), ); await expectOIDCError( "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.", ); }); it("should log and return to welcome page", async () => { getComponent({ realQueryParams }); await flushPromises(); expect(logger.error).toHaveBeenCalledWith( "Failed to login via OIDC", new Error(OidcError.CodeExchangeFailed), ); // warning dialog await expectOIDCError(); }); it("should not clear storage", async () => { getComponent({ realQueryParams }); await flushPromises(); expect(loginClient.clearStores).not.toHaveBeenCalled(); }); it("should not store clientId or issuer", async () => { const sessionStorageSetSpy = jest.spyOn(sessionStorage.__proto__, "setItem"); getComponent({ realQueryParams }); await flushPromises(); expect(sessionStorageSetSpy).not.toHaveBeenCalledWith("mx_oidc_client_id", clientId); expect(sessionStorageSetSpy).not.toHaveBeenCalledWith("mx_oidc_token_issuer", issuer); }); }); describe("when login succeeds", () => { beforeEach(() => { jest.spyOn(StorageAccess, "idbLoad").mockImplementation( async (_table: string, key: string | string[]) => (key === "mx_access_token" ? accessToken : null), ); }); afterEach(() => { SettingsStore.reset(); }); it("should persist login credentials", async () => { getComponent({ realQueryParams }); await waitFor(() => expect(localStorage.getItem("mx_device_id")).toEqual(deviceId)); expect(localStorage.getItem("mx_hs_url")).toEqual(homeserverUrl); expect(localStorage.getItem("mx_user_id")).toEqual(userId); expect(localStorage.getItem("mx_has_access_token")).toEqual("true"); }); it("should store clientId and issuer in session storage", async () => { getComponent({ realQueryParams }); await waitFor(() => expect(localStorage.getItem("mx_oidc_client_id")).toEqual(clientId)); await waitFor(() => expect(localStorage.getItem("mx_oidc_token_issuer")).toEqual(issuer)); }); it("should set logged in and start MatrixClient", async () => { getComponent({ realQueryParams }); defaultDispatcher.dispatch({ action: "will_start_client", }); // client successfully started await waitFor(() => expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "client_started" }), ); // check we get to logged in view await waitForSyncAndLoad(loginClient, true); }); it("should persist device language when available", async () => { await SettingsStore.setValue("language", null, SettingLevel.DEVICE, "en"); const languageBefore = SettingsStore.getValueAt(SettingLevel.DEVICE, "language", null, true, true); jest.spyOn(Lifecycle, "attemptDelegatedAuthLogin"); getComponent({ realQueryParams }); await flushPromises(); expect(Lifecycle.attemptDelegatedAuthLogin).toHaveBeenCalled(); const languageAfter = SettingsStore.getValueAt(SettingLevel.DEVICE, "language", null, true, true); expect(languageBefore).toEqual(languageAfter); }); it("should not persist device language when not available", async () => { await SettingsStore.setValue("language", null, SettingLevel.DEVICE, undefined); const languageBefore = SettingsStore.getValueAt(SettingLevel.DEVICE, "language", null, true, true); jest.spyOn(Lifecycle, "attemptDelegatedAuthLogin"); getComponent({ realQueryParams }); await flushPromises(); expect(Lifecycle.attemptDelegatedAuthLogin).toHaveBeenCalled(); const languageAfter = SettingsStore.getValueAt(SettingLevel.DEVICE, "language", null, true, true); expect(languageBefore).toEqual(languageAfter); }); }); }); describe("with an existing session", () => { const mockidb: Record> = { account: { mx_access_token: accessToken, mx_refresh_token: refreshToken, }, }; beforeEach(async () => { await populateStorageForSession(); jest.spyOn(StorageAccess, "idbLoad").mockImplementation(async (table, key) => { const safeKey = Array.isArray(key) ? key[0] : key; return mockidb[table]?.[safeKey]; }); }); const getComponentAndWaitForReady = async (): Promise => { const renderResult = getComponent(); // we think we are logged in, but are still waiting for the /sync to complete await screen.findByText("Logout"); // initial sync mockClient.emit(ClientEvent.Sync, SyncState.Prepared, null); // wait for logged in view to load await screen.findByLabelText("User menu"); // let things settle await flushPromises(); // and some more for good measure // this proved to be a little flaky await flushPromises(); return renderResult; }; it("should render welcome page after login", async () => { getComponent(); // wait for logged in view to load await screen.findByLabelText("User menu"); await screen.findByRole("heading", { level: 1, name: "Welcome Ernie" }); }); describe("clean up drafts", () => { const roomId = "!room:server.org"; const unknownRoomId = "!room2:server.org"; const room = new Room(roomId, mockClient, userId); const timestamp = 2345678901234; beforeEach(() => { localStorage.setItem(`mx_cider_state_${unknownRoomId}`, "fake_content"); localStorage.setItem(`mx_cider_state_${roomId}`, "fake_content"); mockClient.getRoom.mockImplementation((id) => [room].find((room) => room.roomId === id) || null); }); it("should clean up drafts", async () => { Date.now = jest.fn(() => timestamp); localStorage.setItem(`mx_cider_state_${roomId}`, "fake_content"); localStorage.setItem(`mx_cider_state_${unknownRoomId}`, "fake_content"); await getComponentAndWaitForReady(); mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing); // let things settle await flushPromises(); expect(localStorage.getItem(`mx_cider_state_${roomId}`)).not.toBeNull(); expect(localStorage.getItem(`mx_cider_state_${unknownRoomId}`)).toBeNull(); }); it("should clean up wysiwyg drafts", async () => { Date.now = jest.fn(() => timestamp); localStorage.setItem(`mx_wysiwyg_state_${roomId}`, "fake_content"); localStorage.setItem(`mx_wysiwyg_state_${unknownRoomId}`, "fake_content"); await getComponentAndWaitForReady(); mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing); // let things settle await flushPromises(); expect(localStorage.getItem(`mx_wysiwyg_state_${roomId}`)).not.toBeNull(); expect(localStorage.getItem(`mx_wysiwyg_state_${unknownRoomId}`)).toBeNull(); }); it("should not clean up drafts before expiry", async () => { // Set the last cleanup to the recent past localStorage.setItem(`mx_cider_state_${unknownRoomId}`, "fake_content"); localStorage.setItem(DRAFT_LAST_CLEANUP_KEY, String(timestamp - 100)); await getComponentAndWaitForReady(); mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing); expect(localStorage.getItem(`mx_cider_state_${unknownRoomId}`)).not.toBeNull(); }); }); describe("onAction()", () => { afterEach(() => { jest.restoreAllMocks(); }); it("ViewUserDeviceSettings should open user device settings", async () => { await getComponentAndWaitForReady(); const createDialog = jest.spyOn(Modal, "createDialog").mockReturnValue({} as any); await act(async () => { defaultDispatcher.dispatch({ action: Action.ViewUserDeviceSettings, }); await waitFor(() => expect(createDialog).toHaveBeenCalledWith( UserSettingsDialog, { initialTabId: UserTab.SessionManager, sdkContext: expect.any(SdkContextClass) }, /*className=*/ undefined, /*isPriority=*/ false, /*isStatic=*/ true, ), ); }); }); describe("room actions", () => { const roomId = "!room:server.org"; const spaceId = "!spaceRoom:server.org"; const room = new Room(roomId, mockClient, userId); const spaceRoom = new Room(spaceId, mockClient, userId); beforeEach(() => { mockClient.getRoom.mockImplementation( (id) => [room, spaceRoom].find((room) => room.roomId === id) || null, ); jest.spyOn(spaceRoom, "isSpaceRoom").mockReturnValue(true); jest.spyOn(ReleaseAnnouncementStore.instance, "getReleaseAnnouncement").mockReturnValue(null); (room as any).client = mockClient; (spaceRoom as any).client = mockClient; }); describe("forget_room", () => { it("should dispatch after_forget_room action on successful forget", async () => { await clearAllModals(); await getComponentAndWaitForReady(); // Mock out the old room list store jest.spyOn(RoomListStore.instance, "manualRoomUpdate").mockImplementation(async () => {}); // Register a mock function to the dispatcher const fn = jest.fn(); defaultDispatcher.register(fn); // Forge the room defaultDispatcher.dispatch({ action: "forget_room", room_id: roomId, }); // On success, we expect the following action to have been dispatched. await waitFor(() => { expect(fn).toHaveBeenCalledWith({ action: Action.AfterForgetRoom, room: room, }); }); }); }); describe("leave_room", () => { beforeEach(async () => { await clearAllModals(); await getComponentAndWaitForReady(); // this is thoroughly unit tested elsewhere jest.spyOn(leaveRoomUtils, "leaveRoomBehaviour").mockClear().mockResolvedValue(undefined); }); const dispatchAction = () => defaultDispatcher.dispatch({ action: "leave_room", room_id: roomId, }); const publicJoinRule = new MatrixEvent({ type: "m.room.join_rules", content: { join_rule: "public", }, }); const inviteJoinRule = new MatrixEvent({ type: "m.room.join_rules", content: { join_rule: "invite", }, }); describe("for a room", () => { beforeEach(() => { jest.spyOn(room.currentState, "getJoinedMemberCount").mockReturnValue(2); jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(publicJoinRule); }); it("should launch a confirmation modal", async () => { dispatchAction(); const dialog = await screen.findByRole("dialog"); expect(dialog).toMatchSnapshot(); }); it("should warn when room has only one joined member", async () => { jest.spyOn(room.currentState, "getJoinedMemberCount").mockReturnValue(1); dispatchAction(); await screen.findByRole("dialog"); expect( screen.getByText( "You are the only person here. If you leave, no one will be able to join in the future, including you.", ), ).toBeInTheDocument(); }); it("should warn when room is not public", async () => { jest.spyOn(room.currentState, "getStateEvents").mockReturnValue(inviteJoinRule); dispatchAction(); await screen.findByRole("dialog"); expect( screen.getByText( "This room is not public. You will not be able to rejoin without an invite.", ), ).toBeInTheDocument(); }); it("should warn when user is the last admin", async () => { jest.spyOn(room, "getJoinedMembers").mockReturnValue([ { powerLevel: 100 } as unknown as MatrixJs.RoomMember, { powerLevel: 0 } as unknown as MatrixJs.RoomMember, ]); jest.spyOn(room, "getMember").mockReturnValue({ powerLevel: 100, } as unknown as MatrixJs.RoomMember); dispatchAction(); await screen.findByRole("dialog"); expect( screen.getByText( "You're the only administrator in this room. If you leave, nobody will be able to change room settings or take other important actions.", ), ).toBeInTheDocument(); }); it("should do nothing on cancel", async () => { dispatchAction(); const dialog = await screen.findByRole("dialog"); fireEvent.click(within(dialog).getByText("Cancel")); await flushPromises(); expect(leaveRoomUtils.leaveRoomBehaviour).not.toHaveBeenCalled(); expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: Action.AfterLeaveRoom, room_id: roomId, }); }); it("should leave room and dispatch after leave action", async () => { dispatchAction(); const dialog = await screen.findByRole("dialog"); fireEvent.click(within(dialog).getByText("Leave")); await flushPromises(); expect(leaveRoomUtils.leaveRoomBehaviour).toHaveBeenCalled(); expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.AfterLeaveRoom, room_id: roomId, }); }); }); describe("for a space", () => { const dispatchAction = () => defaultDispatcher.dispatch({ action: "leave_room", room_id: spaceId, }); beforeEach(() => { jest.spyOn(spaceRoom.currentState, "getStateEvents").mockReturnValue(publicJoinRule); }); it("should launch a confirmation modal", async () => { dispatchAction(); const dialog = await screen.findByRole("dialog"); expect(dialog).toMatchSnapshot(); }); it("should warn when space is not public", async () => { jest.spyOn(spaceRoom.currentState, "getStateEvents").mockReturnValue(inviteJoinRule); dispatchAction(); await screen.findByRole("dialog"); expect( screen.getByText( "This space is not public. You will not be able to rejoin without an invite.", ), ).toBeInTheDocument(); }); }); }); it("should open forward dialog when text message shared", async () => { await getComponentAndWaitForReady(); defaultDispatcher.dispatch({ action: Action.Share, format: ShareFormat.Text, msg: "Hello world" }); await waitFor(() => { expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.OpenForwardDialog, event: expect.any(MatrixEvent), permalinkCreator: null, }); }); const forwardCall = mocked(defaultDispatcher.dispatch).mock.calls.find( ([call]) => call.action === Action.OpenForwardDialog, ); const payload = forwardCall?.[0]; expect(payload!.event.getContent()).toEqual({ msgtype: MatrixJs.MsgType.Text, body: "Hello world", }); }); it("should open forward dialog when html message shared", async () => { await getComponentAndWaitForReady(); defaultDispatcher.dispatch({ action: Action.Share, format: ShareFormat.Html, msg: "Hello world" }); await waitFor(() => { expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.OpenForwardDialog, event: expect.any(MatrixEvent), permalinkCreator: null, }); }); const forwardCall = mocked(defaultDispatcher.dispatch).mock.calls.find( ([call]) => call.action === Action.OpenForwardDialog, ); const payload = forwardCall?.[0]; expect(payload!.event.getContent()).toEqual({ msgtype: MatrixJs.MsgType.Text, format: "org.matrix.custom.html", body: expect.stringContaining("Hello world"), formatted_body: expect.stringContaining("Hello world"), }); }); it("should open forward dialog when markdown message shared", async () => { await getComponentAndWaitForReady(); defaultDispatcher.dispatch({ action: Action.Share, format: ShareFormat.Markdown, msg: "Hello *world*", }); await waitFor(() => { expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.OpenForwardDialog, event: expect.any(MatrixEvent), permalinkCreator: null, }); }); const forwardCall = mocked(defaultDispatcher.dispatch).mock.calls.find( ([call]) => call.action === Action.OpenForwardDialog, ); const payload = forwardCall?.[0]; expect(payload!.event.getContent()).toEqual({ msgtype: MatrixJs.MsgType.Text, format: "org.matrix.custom.html", body: "Hello *world*", formatted_body: "Hello world", }); }); it("should strip malicious tags from shared html message", async () => { await getComponentAndWaitForReady(); defaultDispatcher.dispatch({ action: Action.Share, format: ShareFormat.Html, msg: `evil