2023-05-25 14:35:22 +12:00
/*
2024-09-09 14:57:16 +01:00
Copyright 2024 New Vector Ltd.
2023-05-25 14:35:22 +12:00
Copyright 2023 The Matrix.org Foundation C.I.C.
2025-01-06 11:18:54 +00:00
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-09 14:57:16 +01:00
Please see LICENSE files in the repository root for full details.
2023-05-25 14:35:22 +12:00
*/
2024-10-03 09:55:06 +01:00
import "fake-indexeddb/auto" ;
2025-02-05 13:25:06 +00:00
import React , { type ComponentProps } from "react" ;
import { fireEvent , render , type RenderResult , screen , waitFor , within , act } from "jest-matrix-react" ;
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" ;
2023-06-23 08:57:16 +12:00
import * as MatrixJs from "matrix-js-sdk/src/matrix" ;
2023-07-11 16:09:18 +12:00
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" ;
2025-02-05 13:25:06 +00:00
import { type BearerTokenResponse } from "matrix-js-sdk/src/oidc/validate" ;
2025-05-08 11:03:43 +01:00
import { sleep } from "matrix-js-sdk/src/utils" ;
2025-03-20 15:10:08 +00:00
import {
CryptoEvent ,
type DeviceVerificationStatus ,
UserVerificationStatus ,
type CryptoApi ,
} from "matrix-js-sdk/src/crypto-api" ;
2023-05-25 14:35:22 +12:00
2024-10-15 14:57:26 +01:00
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" ;
2023-06-23 08:57:16 +12:00
import {
clearAllModals ,
2023-10-30 16:14:27 +01:00
createStubMatrixRTC ,
2023-06-23 08:57:16 +12:00
filterConsole ,
flushPromises ,
getMockClientWithEventEmitter ,
2024-02-13 09:11:50 +00:00
mockClientMethodsServer ,
2023-06-23 08:57:16 +12:00
mockClientMethodsUser ,
2023-08-24 09:28:43 +01:00
MockClientWithEventEmitter ,
2023-07-28 14:25:18 +12:00
mockPlatformPeg ,
2023-08-24 09:28:43 +01:00
resetJsDomAfterEach ,
2023-10-03 16:19:54 +01:00
unmockClientPeg ,
2024-10-15 14:57:26 +01:00
} 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" ;
2025-02-05 13:25:06 +00:00
import { type Call } from "../../../../src/models/Call" ;
2024-10-15 14:57:26 +01:00
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" ;
2024-10-21 14:50:06 +01:00
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg" ;
2024-10-15 14:57:26 +01:00
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" ;
2025-02-05 13:25:06 +00:00
import { type ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig" ;
2024-11-04 11:34:00 +00:00
import Modal from "../../../../src/Modal.tsx" ;
2025-03-20 15:10:08 +00:00
import { SetupEncryptionStore } from "../../../../src/stores/SetupEncryptionStore.ts" ;
2025-05-28 19:48:32 +01:00
import { ShareFormat } from "../../../../src/dispatcher/payloads/SharePayload.ts" ;
2025-05-13 10:27:08 +01:00
import { clearStorage } from "../../../../src/Lifecycle" ;
2025-05-15 15:34:05 +05:30
import RoomListStore from "../../../../src/stores/room-list/RoomListStore.ts" ;
2025-07-28 17:32:53 +01:00
import UserSettingsDialog from "../../../../src/components/views/dialogs/UserSettingsDialog.tsx" ;
import { SdkContextClass } from "../../../../src/contexts/SDKContext.ts" ;
2023-05-25 14:35:22 +12:00
2023-07-11 16:09:18 +12:00
jest . mock ( "matrix-js-sdk/src/oidc/authorize" , ( ) = > ( {
completeAuthorizationCodeGrant : jest.fn ( ) ,
} ) ) ;
2024-09-30 17:34:39 +01:00
// 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.
2024-10-15 14:57:26 +01:00
jest . mock ( "../../../../src/settings/watchers/ThemeWatcher" ) ;
2026-02-16 12:23:32 +01:00
jest . mock ( "../../../../src/theme" ) ;
2024-09-30 17:34:39 +01:00
2024-01-05 13:24:00 +00:00
/** The matrix versions our mock server claims to support */
const SERVER_SUPPORTED_MATRIX_VERSIONS = [ "v1.1" , "v1.5" , "v1.6" , "v1.8" , "v1.9" ] ;
2023-05-25 14:35:22 +12:00
describe ( "<MatrixChat />" , ( ) = > {
const userId = "@alice:server.org" ;
const deviceId = "qwertyui" ;
const accessToken = "abc123" ;
2024-10-03 09:55:06 +01:00
const refreshToken = "def456" ;
2025-05-08 11:03:43 +01:00
let bootstrapDeferred : PromiseWithResolvers < void > ;
2023-06-23 08:57:16 +12:00
// reused in createClient mock below
const getMockClientMethods = ( ) = > ( {
2023-05-25 14:35:22 +12:00
. . . mockClientMethodsUser ( userId ) ,
2024-02-13 09:11:50 +00:00
. . . mockClientMethodsServer ( ) ,
2024-01-05 13:24:00 +00:00
getVersions : jest.fn ( ) . mockResolvedValue ( { versions : SERVER_SUPPORTED_MATRIX_VERSIONS } ) ,
2025-12-02 10:42:15 +00:00
startClient : async function ( ) {
// This `sleep` is a horrible hack, for which I am sorry.
//
// MatrixChat uses its `view` state as the tracker for the current state of its state machine. However,
// React state does not update immediately, with the result that if the client starts too quickly after the
// "OnLoggedIn" action, the state machine will be confused and the view will not be correctly updated.
//
// In practice it takes a little time for the client to start up (it has to read a load of stuff from
// indexedDB, so in some ways this is just a more realistic simulation of the real world 😇
await sleep ( 1 ) ;
2024-10-03 09:55:06 +01:00
// @ts-ignore
this . emit ( ClientEvent . Sync , SyncState . Prepared , null ) ;
} ,
2023-05-25 14:35:22 +12:00
stopClient : jest.fn ( ) ,
setCanResetTimelineCallback : jest.fn ( ) ,
isInitialSyncComplete : jest.fn ( ) ,
getSyncState : jest.fn ( ) ,
2023-10-03 16:19:54 +01:00
getSsoLoginUrl : jest.fn ( ) ,
2023-05-25 14:35:22 +12:00
getSyncStateData : jest.fn ( ) . mockReturnValue ( null ) ,
getThirdpartyProtocols : jest.fn ( ) . mockResolvedValue ( { } ) ,
getClientWellKnown : jest.fn ( ) . mockReturnValue ( { } ) ,
isVersionSupported : jest.fn ( ) . mockResolvedValue ( false ) ,
2024-02-02 13:20:13 +01:00
initRustCrypto : jest.fn ( ) ,
2023-05-25 14:35:22 +12:00
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 ( ) ,
2023-07-11 16:09:18 +12:00
startup : jest.fn ( ) ,
2023-05-25 14:35:22 +12:00
} ,
2023-06-23 08:57:16 +12:00
login : jest.fn ( ) ,
2025-04-09 19:03:09 +00:00
loginFlows : jest.fn ( ) . mockResolvedValue ( { flows : [ ] } ) ,
2023-06-23 08:57:16 +12:00
isGuest : jest.fn ( ) . mockReturnValue ( false ) ,
clearStores : jest.fn ( ) ,
setGuest : jest.fn ( ) ,
setNotifTimelineSet : jest.fn ( ) ,
getAccountData : jest.fn ( ) ,
2025-06-10 11:47:33 +01:00
doesServerSupportUnstableFeature : jest.fn ( ) . mockResolvedValue ( false ) ,
2023-06-23 08:57:16 +12:00
getDevices : jest.fn ( ) . mockResolvedValue ( { devices : [ ] } ) ,
2024-10-03 09:55:06 +01:00
getProfileInfo : jest.fn ( ) . mockResolvedValue ( {
displayname : "Ernie" ,
} ) ,
2023-06-23 08:57:16 +12:00
getVisibleRooms : jest.fn ( ) . mockReturnValue ( [ ] ) ,
getRooms : jest.fn ( ) . mockReturnValue ( [ ] ) ,
2024-10-03 09:55:06 +01:00
getCrypto : jest.fn ( ) . mockReturnValue ( {
getVerificationRequestsToDeviceInProgress : jest.fn ( ) . mockReturnValue ( [ ] ) ,
isCrossSigningReady : jest.fn ( ) . mockReturnValue ( false ) ,
2025-01-31 13:29:59 -05:00
isDehydrationSupported : jest.fn ( ) . mockReturnValue ( false ) ,
2024-10-03 09:55:06 +01:00
getUserDeviceInfo : jest.fn ( ) . mockReturnValue ( new Map ( ) ) ,
getUserVerificationStatus : jest.fn ( ) . mockResolvedValue ( new UserVerificationStatus ( false , false , false ) ) ,
getVersion : jest.fn ( ) . mockReturnValue ( "1" ) ,
setDeviceIsolationMode : jest.fn ( ) ,
2024-10-15 09:50:26 +02:00
userHasCrossSigningKeys : jest.fn ( ) ,
2024-10-18 11:45:45 +02:00
getActiveSessionBackupVersion : jest.fn ( ) . mockResolvedValue ( null ) ,
2024-10-21 13:53:39 +02:00
globalBlacklistUnverifiedDevices : false ,
// This needs to not finish immediately because we need to test the screen appears
bootstrapCrossSigning : jest.fn ( ) . mockImplementation ( ( ) = > bootstrapDeferred . promise ) ,
2024-11-25 10:30:42 +01:00
getKeyBackupInfo : jest.fn ( ) . mockResolvedValue ( null ) ,
2024-10-03 09:55:06 +01:00
} ) ,
2023-06-23 08:57:16 +12:00
secretStorage : {
isStored : jest.fn ( ) . mockReturnValue ( null ) ,
} ,
2023-10-30 16:14:27 +01:00
matrixRTC : createStubMatrixRTC ( ) ,
2023-06-23 08:57:16 +12:00
getDehydratedDevice : jest.fn ( ) ,
2023-07-11 16:09:18 +12:00
whoami : jest.fn ( ) ,
2023-07-28 14:25:18 +12:00
logout : jest.fn ( ) ,
getDeviceId : jest.fn ( ) ,
2025-05-15 15:34:05 +05:30
forget : ( ) = > Promise . resolve ( ) ,
2023-05-25 14:35:22 +12:00
} ) ;
2023-08-17 20:06:45 +01:00
let mockClient : Mocked < MatrixClient > ;
2023-05-25 14:35:22 +12:00
const serverConfig = {
hsUrl : "https://test.com" ,
hsName : "Test Server" ,
hsNameIsDifferent : false ,
isUrl : "https://is.com" ,
isDefault : true ,
isNameResolvable : true ,
warning : "" ,
} ;
2024-10-03 09:55:06 +01:00
let defaultProps : ComponentProps < typeof MatrixChat > ;
2024-11-20 18:09:51 +00:00
const getComponent = ( props : Partial < ComponentProps < typeof MatrixChat > > = { } ) = > {
2025-04-09 19:03:09 +00:00
return render ( < MatrixChat { ...defaultProps } { ...props } / > ) ;
2024-11-20 18:09:51 +00:00
} ;
2023-06-28 11:45:11 +12:00
// make test results readable
2023-08-24 09:28:43 +01:00
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 <audio" ,
) ;
/** populate storage with details of a persisted session */
async function populateStorageForSession() {
localStorage . setItem ( "mx_hs_url" , serverConfig . hsUrl ) ;
localStorage . setItem ( "mx_is_url" , serverConfig . isUrl ) ;
// TODO: nowadays the access token lives (encrypted) in indexedDB, and localstorage is only used as a fallback.
localStorage . setItem ( "mx_access_token" , accessToken ) ;
localStorage . setItem ( "mx_user_id" , userId ) ;
localStorage . setItem ( "mx_device_id" , deviceId ) ;
}
2023-05-25 14:35:22 +12:00
2023-06-23 08:57:16 +12:00
beforeEach ( async ( ) = > {
2025-05-13 10:27:08 +01:00
await clearStorage ( ) ;
Lifecycle . setSessionLockNotStolen ( ) ;
2025-04-09 19:03:09 +00:00
localStorage . clear ( ) ;
jest . restoreAllMocks ( ) ;
2024-10-03 09:55:06 +01:00
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 ( ) ,
2026-04-15 10:35:02 +01:00
urlParams : { } ,
2024-10-03 09:55:06 +01:00
} ;
2023-06-23 08:57:16 +12:00
mockClient = getMockClientWithEventEmitter ( getMockClientMethods ( ) ) ;
2024-10-03 09:55:06 +01:00
jest . spyOn ( MatrixJs , "createClient" ) . mockReturnValue ( mockClient ) ;
2023-07-28 14:25:18 +12:00
2023-05-25 14:35:22 +12:00
jest . spyOn ( defaultDispatcher , "dispatch" ) . mockClear ( ) ;
2024-04-10 14:13:08 +01:00
jest . spyOn ( defaultDispatcher , "fire" ) . mockClear ( ) ;
DMRoomMap . makeShared ( mockClient ) ;
2023-06-28 11:45:11 +12:00
2024-10-03 09:55:06 +01:00
jest . spyOn ( AutoDiscoveryUtils , "validateServerConfigWithStaticUrls" ) . mockResolvedValue (
{ } as ValidatedServerConfig ,
) ;
2025-05-08 11:03:43 +01:00
bootstrapDeferred = Promise . withResolvers ( ) ;
2024-10-03 09:55:06 +01:00
2023-06-28 11:45:11 +12:00
await clearAllModals ( ) ;
2023-05-25 14:35:22 +12:00
} ) ;
2024-10-03 09:55:06 +01:00
afterEach ( async ( ) = > {
2024-04-10 14:13:08 +01:00
// @ts-ignore
DMRoomMap . setShared ( null ) ;
2023-08-17 20:06:45 +01:00
// emit a loggedOut event so that all of the Store singletons forget about their references to the mock client
2024-10-03 09:55:06 +01:00
// (must be sync otherwise the next test will start before it happens)
2024-11-20 13:29:23 +00:00
act ( ( ) = > defaultDispatcher . dispatch ( { action : Action.OnLoggedOut } , true ) ) ;
2024-10-03 09:55:06 +01:00
localStorage . clear ( ) ;
2025-07-30 21:50:19 +01:00
// 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 ) ) ;
2023-07-28 14:25:18 +12:00
} ) ;
2024-10-03 09:55:06 +01:00
resetJsDomAfterEach ( ) ;
2023-05-25 14:35:22 +12:00
it ( "should render spinner while app is loading" , ( ) = > {
const { container } = getComponent ( ) ;
expect ( container ) . toMatchSnapshot ( ) ;
} ) ;
2024-04-10 14:13:08 +01:00
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 ) ;
} ) ;
} ) ;
2025-10-06 10:23:06 +01:00
it ( "should notify resizenotifier when left panel hidden" , async ( ) = > {
getComponent ( ) ;
jest . spyOn ( SdkContextClass . instance . resizeNotifier , "notifyLeftHandleResized" ) ;
defaultDispatcher . dispatch ( { action : "hide_left_panel" } ) ;
await waitFor ( ( ) = >
expect ( mocked ( SdkContextClass . instance . resizeNotifier . notifyLeftHandleResized ) ) . toHaveBeenCalled ( ) ,
) ;
} ) ;
it ( "should notify resizenotifier when left panel shown" , async ( ) = > {
getComponent ( ) ;
jest . spyOn ( SdkContextClass . instance . resizeNotifier , "notifyLeftHandleResized" ) ;
defaultDispatcher . dispatch ( { action : "show_left_panel" } ) ;
await waitFor ( ( ) = >
expect ( mocked ( SdkContextClass . instance . resizeNotifier . notifyLeftHandleResized ) ) . toHaveBeenCalled ( ) ,
) ;
} ) ;
2023-11-24 14:51:28 -01:00
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" ;
2026-04-15 10:35:02 +01:00
const urlParams = {
2026-04-16 12:07:39 +01:00
oidc_fragment : {
2026-04-15 10:35:02 +01:00
code ,
state : state ,
} ,
2023-11-24 14:51:28 -01:00
} ;
const deviceId = "test-device-id" ;
const accessToken = "test-access-token-from-oidc" ;
const tokenResponse : BearerTokenResponse = {
access_token : accessToken ,
refresh_token : "def456" ,
2024-05-07 12:27:37 +01:00
id_token : "ghi789" ,
2023-11-24 14:51:28 -01:00
scope : "test" ,
token_type : "Bearer" ,
expires_at : 12345 ,
} ;
let loginClient ! : ReturnType < typeof getMockClientWithEventEmitter > ;
const expectOIDCError = async (
errorMessage = "Something went wrong during authentication. Go to the sign in page and try again." ,
) : Promise < void > = > {
await flushPromises ( ) ;
const dialog = await screen . findByRole ( "dialog" ) ;
2025-04-14 16:22:46 +02:00
await waitFor ( ( ) = > expect ( within ( dialog ) . getByText ( errorMessage ) ) . toBeInTheDocument ( ) ) ;
2023-11-24 14:51:28 -01:00
} ;
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 ( ) = > {
2026-04-15 10:35:02 +01:00
const urlParams = {
2026-04-16 12:07:39 +01:00
oidc_query : {
2026-04-15 10:35:02 +01:00
code : "" ,
state : "abc" ,
} ,
2023-11-24 14:51:28 -01:00
} ;
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
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 ( ) = > {
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
await flushPromises ( ) ;
2026-04-15 10:35:02 +01:00
expect ( completeAuthorizationCodeGrant ) . toHaveBeenCalledWith ( code , state , "fragment" ) ;
2023-11-24 14:51:28 -01:00
} ) ;
it ( "should look up userId using access token" , async ( ) = > {
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
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" ) ) ;
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
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 ( ) ;
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams , onTokenLoginCompleted } ) ;
2023-11-24 14:51:28 -01:00
2024-10-03 09:55:06 +01:00
await waitFor ( ( ) = > expect ( onTokenLoginCompleted ) . toHaveBeenCalled ( ) ) ;
2023-11-24 14:51:28 -01:00
} ) ;
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 ) ,
) ;
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
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 ( ) = > {
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
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 ( ) = > {
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
await flushPromises ( ) ;
expect ( loginClient . clearStores ) . not . toHaveBeenCalled ( ) ;
} ) ;
it ( "should not store clientId or issuer" , async ( ) = > {
const sessionStorageSetSpy = jest . spyOn ( sessionStorage . __proto__ , "setItem" ) ;
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
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 ( ( ) = > {
2024-05-02 16:19:55 -06:00
jest . spyOn ( StorageAccess , "idbLoad" ) . mockImplementation (
2023-11-24 14:51:28 -01:00
async ( _table : string , key : string | string [ ] ) = > ( key === "mx_access_token" ? accessToken : null ) ,
) ;
} ) ;
2025-04-15 09:01:35 +01:00
afterEach ( ( ) = > {
SettingsStore . reset ( ) ;
} ) ;
2023-11-24 14:51:28 -01:00
it ( "should persist login credentials" , async ( ) = > {
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
2025-04-14 16:22:46 +02:00
await waitFor ( ( ) = > expect ( localStorage . getItem ( "mx_device_id" ) ) . toEqual ( deviceId ) ) ;
expect ( localStorage . getItem ( "mx_hs_url" ) ) . toEqual ( homeserverUrl ) ;
2023-11-24 14:51:28 -01:00
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 ( ) = > {
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
2024-10-03 09:55:06 +01:00
await waitFor ( ( ) = > expect ( localStorage . getItem ( "mx_oidc_client_id" ) ) . toEqual ( clientId ) ) ;
2025-04-14 16:22:46 +02:00
await waitFor ( ( ) = > expect ( localStorage . getItem ( "mx_oidc_token_issuer" ) ) . toEqual ( issuer ) ) ;
2023-11-24 14:51:28 -01:00
} ) ;
it ( "should set logged in and start MatrixClient" , async ( ) = > {
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
2025-04-09 19:03:09 +00:00
defaultDispatcher . dispatch ( {
2025-11-20 18:18:04 +00:00
action : Action.WillStartClient ,
2025-04-09 19:03:09 +00:00
} ) ;
2023-11-24 14:51:28 -01:00
// client successfully started
2024-10-03 09:55:06 +01:00
await waitFor ( ( ) = >
2025-11-20 18:18:04 +00:00
expect ( defaultDispatcher . dispatch ) . toHaveBeenCalledWith ( { action : Action.ClientStarted } ) ,
2024-10-03 09:55:06 +01:00
) ;
2023-11-24 14:51:28 -01:00
2025-11-19 12:28:08 -05:00
// set up keys screen is rendered
expect ( screen . getByText ( "Setting up keys" ) ) . toBeInTheDocument ( ) ;
2023-11-24 14:51:28 -01:00
} ) ;
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" ) ;
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
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" ) ;
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-11-24 14:51:28 -01:00
await flushPromises ( ) ;
expect ( Lifecycle . attemptDelegatedAuthLogin ) . toHaveBeenCalled ( ) ;
const languageAfter = SettingsStore . getValueAt ( SettingLevel . DEVICE , "language" , null , true , true ) ;
expect ( languageBefore ) . toEqual ( languageAfter ) ;
} ) ;
} ) ;
} ) ;
2023-05-25 14:35:22 +12:00
describe ( "with an existing session" , ( ) = > {
const mockidb : Record < string , Record < string , string > > = {
2024-10-03 09:55:06 +01:00
account : {
2023-05-25 14:35:22 +12:00
mx_access_token : accessToken ,
2024-10-03 09:55:06 +01:00
mx_refresh_token : refreshToken ,
2023-05-25 14:35:22 +12:00
} ,
} ;
2023-08-24 09:28:43 +01:00
beforeEach ( async ( ) = > {
await populateStorageForSession ( ) ;
2024-05-02 16:19:55 -06:00
jest . spyOn ( StorageAccess , "idbLoad" ) . mockImplementation ( async ( table , key ) = > {
2023-05-25 14:35:22 +12:00
const safeKey = Array . isArray ( key ) ? key [ 0 ] : key ;
return mockidb [ table ] ? . [ safeKey ] ;
} ) ;
} ) ;
const getComponentAndWaitForReady = async ( ) : Promise < RenderResult > = > {
const renderResult = getComponent ( ) ;
2023-06-23 08:57:16 +12:00
2023-05-25 14:35:22 +12:00
// 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" ) ;
2024-10-03 09:55:06 +01:00
2025-04-09 19:03:09 +00:00
await screen . findByRole ( "heading" , { level : 1 , name : "Welcome Ernie" } ) ;
2023-05-25 14:35:22 +12:00
} ) ;
2024-08-07 09:35:57 +01:00
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 ( ) ;
} ) ;
2024-08-22 13:54:01 +01:00
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 ( ) ;
} ) ;
2024-08-07 09:35:57 +01:00
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 ( ) ;
} ) ;
} ) ;
2023-05-25 14:35:22 +12:00
describe ( "onAction()" , ( ) = > {
2025-07-28 17:32:53 +01:00
afterEach ( ( ) = > {
jest . restoreAllMocks ( ) ;
2023-07-28 14:25:18 +12:00
} ) ;
2023-05-25 14:35:22 +12:00
2025-07-28 17:32:53 +01:00
it ( "ViewUserDeviceSettings should open user device settings" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
2023-05-25 14:35:22 +12:00
2025-07-28 17:32:53 +01:00
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 ,
) ,
) ;
2023-05-25 14:35:22 +12:00
} ) ;
} ) ;
2023-05-31 10:46:08 +12:00
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 ,
) ;
2023-07-28 14:25:18 +12:00
jest . spyOn ( spaceRoom , "isSpaceRoom" ) . mockReturnValue ( true ) ;
2024-04-29 16:30:19 +01:00
jest . spyOn ( ReleaseAnnouncementStore . instance , "getReleaseAnnouncement" ) . mockReturnValue ( null ) ;
2025-08-05 12:10:30 +01:00
( room as any ) . client = mockClient ;
( spaceRoom as any ) . client = mockClient ;
2024-04-29 16:30:19 +01:00
} ) ;
2025-05-15 15:34:05 +05:30
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 ,
} ) ;
} ) ;
} ) ;
} ) ;
2023-05-31 10:46:08 +12:00
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 ( ) ;
} ) ;
2025-08-05 12:10:30 +01:00
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 ( ) ;
} ) ;
2023-05-31 10:46:08 +12:00
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 ( ) ;
} ) ;
} ) ;
} ) ;
2025-05-28 19:48:32 +01:00
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 <em>world</em>" ,
} ) ;
} ) ;
it ( "should strip malicious tags from shared html message" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
defaultDispatcher . dispatch ( {
action : Action.Share ,
format : ShareFormat.Html ,
msg : ` evil<script src="http://evil.dummy/bad.js" /> ` ,
} ) ;
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 : "evil" ,
formatted_body : "evil" ,
} ) ;
} ) ;
2023-05-31 10:46:08 +12:00
} ) ;
2023-07-28 14:25:18 +12:00
describe ( "logout" , ( ) = > {
let logoutClient ! : ReturnType < typeof getMockClientWithEventEmitter > ;
const call1 = { disconnect : jest.fn ( ) } as unknown as Call ;
const call2 = { disconnect : jest.fn ( ) } as unknown as Call ;
const dispatchLogoutAndWait = async ( ) : Promise < void > = > {
defaultDispatcher . dispatch ( {
action : "logout" ,
} ) ;
await flushPromises ( ) ;
} ;
beforeEach ( ( ) = > {
// stub out various cleanup functions
jest . spyOn ( LegacyCallHandler . instance , "hangupAllCalls" )
. mockClear ( )
. mockImplementation ( ( ) = > { } ) ;
jest . spyOn ( PosthogAnalytics . instance , "logout" ) . mockImplementation ( ( ) = > { } ) ;
jest . spyOn ( EventIndexPeg , "deleteEventIndex" ) . mockImplementation ( async ( ) = > { } ) ;
2024-06-17 13:00:41 +02:00
jest . spyOn ( CallStore . instance , "connectedCalls" , "get" ) . mockReturnValue ( new Set ( [ call1 , call2 ] ) ) ;
2023-07-28 14:25:18 +12:00
mockPlatformPeg ( {
destroyPickleKey : jest.fn ( ) ,
} ) ;
logoutClient = getMockClientWithEventEmitter ( getMockClientMethods ( ) ) ;
mockClient = getMockClientWithEventEmitter ( getMockClientMethods ( ) ) ;
mockClient . logout . mockResolvedValue ( { } ) ;
mockClient . getDeviceId . mockReturnValue ( deviceId ) ;
// this is used to create a temporary client to cleanup after logout
jest . spyOn ( MatrixJs , "createClient" ) . mockClear ( ) . mockReturnValue ( logoutClient ) ;
jest . spyOn ( logger , "warn" ) . mockClear ( ) ;
} ) ;
it ( "should hangup all legacy calls" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
await dispatchLogoutAndWait ( ) ;
expect ( LegacyCallHandler . instance . hangupAllCalls ) . toHaveBeenCalled ( ) ;
} ) ;
it ( "should disconnect all calls" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
await dispatchLogoutAndWait ( ) ;
expect ( call1 . disconnect ) . toHaveBeenCalled ( ) ;
expect ( call2 . disconnect ) . toHaveBeenCalled ( ) ;
} ) ;
it ( "should logout of posthog" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
await dispatchLogoutAndWait ( ) ;
expect ( PosthogAnalytics . instance . logout ) . toHaveBeenCalled ( ) ;
} ) ;
it ( "should destroy pickle key" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
await dispatchLogoutAndWait ( ) ;
expect ( PlatformPeg . get ( ) ! . destroyPickleKey ) . toHaveBeenCalledWith ( userId , deviceId ) ;
} ) ;
describe ( "without delegated auth" , ( ) = > {
it ( "should call /logout" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
await dispatchLogoutAndWait ( ) ;
expect ( mockClient . logout ) . toHaveBeenCalledWith ( true ) ;
} ) ;
it ( "should warn and do post-logout cleanup anyway when logout fails" , async ( ) = > {
const error = new Error ( "test logout failed" ) ;
mockClient . logout . mockRejectedValue ( error ) ;
await getComponentAndWaitForReady ( ) ;
await dispatchLogoutAndWait ( ) ;
expect ( logger . warn ) . toHaveBeenCalledWith (
"Failed to call logout API: token will not be invalidated" ,
error ,
) ;
// stuff that happens in onloggedout
expect ( defaultDispatcher . fire ) . toHaveBeenCalledWith ( Action . OnLoggedOut , true ) ;
2024-10-03 09:55:06 +01:00
await waitFor ( ( ) = > expect ( logoutClient . clearStores ) . toHaveBeenCalled ( ) ) ;
2023-07-28 14:25:18 +12:00
} ) ;
it ( "should do post-logout cleanup" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
await dispatchLogoutAndWait ( ) ;
// stuff that happens in onloggedout
expect ( defaultDispatcher . fire ) . toHaveBeenCalledWith ( Action . OnLoggedOut , true ) ;
2024-10-03 09:55:06 +01:00
await waitFor ( ( ) = > expect ( EventIndexPeg . deleteEventIndex ) . toHaveBeenCalled ( ) ) ;
2023-07-28 14:25:18 +12:00
expect ( logoutClient . clearStores ) . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
} ) ;
2023-05-25 14:35:22 +12:00
} ) ;
2024-10-03 09:55:06 +01:00
describe ( "unskippable verification" , ( ) = > {
2025-03-20 15:10:08 +00:00
beforeEach ( ( ) = > {
// Force verification is turned on in settings
2024-10-03 09:55:06 +01:00
defaultProps . config . force_verification = true ;
2025-03-20 15:10:08 +00:00
// And this device is being force-verified (because it logged in after
// enforcement was turned on).
2024-10-03 09:55:06 +01:00
localStorage . setItem ( "must_verify_device" , "true" ) ;
2025-03-20 15:10:08 +00:00
// lostKeys returns false, meaning there are other devices to verify against
const realStore = SetupEncryptionStore . sharedInstance ( ) ;
jest . spyOn ( realStore , "lostKeys" ) . mockReturnValue ( false ) ;
} ) ;
afterEach ( ( ) = > {
jest . restoreAllMocks ( ) ;
// Reset things back to how they were before we started
defaultProps . config . force_verification = false ;
localStorage . removeItem ( "must_verify_device" ) ;
} ) ;
2025-06-17 11:31:08 +01:00
it ( "should show the Complete Security screen if unskippable verification is enabled" , async ( ) = > {
2025-03-20 15:10:08 +00:00
// Given we have force verification on, and an existing logged-in session
// that is not verified (see beforeEach())
// When we render MatrixChat
getComponent ( ) ;
// Then we are asked to verify our device
2026-03-18 16:53:28 +01:00
await screen . findByRole ( "heading" , { name : "Confirm your digital identity" , level : 2 } ) ;
2025-03-20 15:10:08 +00:00
// Sanity: we are not racing with another screen update, so this heading stays visible
2026-03-18 16:53:28 +01:00
await screen . findByRole ( "heading" , { name : "Confirm your digital identity" , level : 2 } ) ;
2025-03-20 15:10:08 +00:00
} ) ;
it ( "should not open app after cancelling device verify if unskippable verification is on" , async ( ) = > {
// See https://github.com/element-hq/element-web/issues/29230
// We used to allow bypassing force verification by choosing "Verify with
// another device" and not completing the verification.
// Given we have force verification on, and an existing logged-in session
// that is not verified (see beforeEach())
// And our crypto is set up
mockClient . getCrypto . mockReturnValue ( createMockCrypto ( ) ) ;
// And MatrixChat is rendered
2024-10-03 09:55:06 +01:00
getComponent ( ) ;
2025-09-12 14:37:14 -04:00
// When we click "Use another device"
2026-03-18 16:53:28 +01:00
await screen . findByRole ( "heading" , { name : "Confirm your digital identity" , level : 2 } ) ;
2025-09-12 14:37:14 -04:00
const verify = screen . getByRole ( "button" , { name : "Use another device" } ) ;
2025-03-20 15:10:08 +00:00
act ( ( ) = > verify . click ( ) ) ;
// And close the device verification dialog
2025-09-26 09:59:40 +01:00
const closeButton = screen . getByRole ( "button" , { name : "Close dialog" } ) ;
2025-03-20 15:10:08 +00:00
act ( ( ) = > closeButton . click ( ) ) ;
// Then we are not allowed in - we are still being asked to verify
2026-03-18 16:53:28 +01:00
await screen . findByRole ( "heading" , { name : "Confirm your digital identity" , level : 2 } ) ;
2024-10-03 09:55:06 +01:00
} ) ;
2025-03-20 15:10:08 +00:00
2025-06-17 11:31:08 +01:00
describe ( "when query params have a loginToken" , ( ) = > {
const loginToken = "test-login-token" ;
2026-04-15 10:35:02 +01:00
const urlParams = {
legacy_sso : {
loginToken ,
} ,
2025-06-17 11:31:08 +01:00
} ;
let loginClient ! : ReturnType < typeof getMockClientWithEventEmitter > ;
const deviceId = "test-device-id" ;
const accessToken = "test-access-token" ;
const clientLoginResponse = {
user_id : userId ,
device_id : deviceId ,
access_token : accessToken ,
} ;
beforeEach ( ( ) = > {
localStorage . setItem ( "mx_sso_hs_url" , serverConfig . hsUrl ) ;
localStorage . setItem ( "mx_sso_is_url" , serverConfig . isUrl ) ;
loginClient = getMockClientWithEventEmitter ( getMockClientMethods ( ) ) ;
// this is used to create a temporary client during login
jest . spyOn ( MatrixJs , "createClient" ) . mockReturnValue ( loginClient ) ;
loginClient . login . mockClear ( ) . mockResolvedValue ( clientLoginResponse ) ;
} ) ;
it ( "should show the Complete Security screen after OIDC login if unskippable ver. is on" , async ( ) = > {
// Given force_verification is on (outer describe)
// And we just logged in via OIDC (inner describe)
2025-11-19 12:28:08 -05:00
mocked ( loginClient . getCrypto ( ) ! . userHasCrossSigningKeys ) . mockResolvedValue ( true ) ;
2025-06-17 11:31:08 +01:00
// When we load the page
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2025-06-17 11:31:08 +01:00
defaultDispatcher . dispatch ( {
2025-11-20 18:18:04 +00:00
action : Action.WillStartClient ,
2025-06-17 11:31:08 +01:00
} ) ;
await waitFor ( ( ) = >
2025-11-20 18:18:04 +00:00
expect ( defaultDispatcher . dispatch ) . toHaveBeenCalledWith ( { action : Action.ClientStarted } ) ,
2025-06-17 11:31:08 +01:00
) ;
// Then we are not allowed in - we are being asked to verify
2026-03-18 16:53:28 +01:00
await screen . findByRole ( "heading" , { name : "Confirm your digital identity" , level : 2 } ) ;
2025-06-17 11:31:08 +01:00
} ) ;
} ) ;
2025-03-20 15:10:08 +00:00
function createMockCrypto ( ) : CryptoApi {
return {
getVersion : jest.fn ( ) . mockReturnValue ( "Version 0" ) ,
getVerificationRequestsToDeviceInProgress : jest.fn ( ) . mockReturnValue ( [ ] ) ,
getUserDeviceInfo : jest.fn ( ) . mockReturnValue ( {
get : jest
. fn ( )
. mockReturnValue (
new Map ( [
[ "devid" , { dehydrated : false , getIdentityKey : jest.fn ( ) . mockReturnValue ( "k" ) } ] ,
] ) ,
) ,
} ) ,
getUserVerificationStatus : jest
. fn ( )
. mockResolvedValue ( new UserVerificationStatus ( true , true , false ) ) ,
setDeviceIsolationMode : jest.fn ( ) ,
isDehydrationSupported : jest.fn ( ) . mockReturnValue ( false ) ,
getDeviceVerificationStatus : jest
. fn ( )
. mockResolvedValue ( { signedByOwner : true } as DeviceVerificationStatus ) ,
isCrossSigningReady : jest.fn ( ) . mockReturnValue ( false ) ,
2025-09-26 09:59:40 +01:00
requestOwnUserVerification : jest.fn ( ) . mockResolvedValue ( { cancel : jest.fn ( ) , on : jest.fn ( ) } ) ,
2025-03-20 15:10:08 +00:00
} as any ;
}
2024-10-03 09:55:06 +01:00
} ) ;
2025-05-28 19:48:32 +01:00
describe ( "showScreen" , ( ) = > {
it ( "should show the 'share' screen" , async ( ) = > {
await getComponent ( {
initialScreenAfterLogin : { screen : "share" , params : { msg : "Hello" , format : ShareFormat.Text } } ,
} ) ;
await waitFor ( ( ) = > {
expect ( defaultDispatcher . dispatch ) . toHaveBeenCalledWith ( {
action : "share" ,
msg : "Hello" ,
format : ShareFormat.Text ,
} ) ;
} ) ;
} ) ;
} ) ;
2023-05-25 14:35:22 +12:00
} ) ;
2023-06-23 08:57:16 +12:00
2023-08-14 13:52:08 +01:00
describe ( "with a soft-logged-out session" , ( ) = > {
const mockidb : Record < string , Record < string , string > > = { } ;
2023-08-24 09:28:43 +01:00
beforeEach ( async ( ) = > {
await populateStorageForSession ( ) ;
2023-08-16 17:28:46 +01:00
localStorage . setItem ( "mx_soft_logout" , "true" ) ;
2023-08-14 13:52:08 +01:00
mockClient . loginFlows . mockResolvedValue ( { flows : [ { type : "m.login.password" } ] } ) ;
2024-05-02 16:19:55 -06:00
jest . spyOn ( StorageAccess , "idbLoad" ) . mockImplementation ( async ( table , key ) = > {
2023-08-14 13:52:08 +01:00
const safeKey = Array . isArray ( key ) ? key [ 0 ] : key ;
return mockidb [ table ] ? . [ safeKey ] ;
} ) ;
} ) ;
it ( "should show the soft-logout page" , async ( ) = > {
2024-02-02 13:20:13 +01:00
// XXX This test is strange, it was working with legacy crypto
// without mocking the following but the initCrypto call was failing
// but as the exception was swallowed, the test was passing (see in `initClientCrypto`).
// There are several uses of the peg in the app, so during all these tests you might end-up
// with a real client instead of the mocked one. Not sure how reliable all these tests are.
2024-10-21 14:50:06 +01:00
jest . spyOn ( MatrixClientPeg , "replaceUsingCreds" ) ;
jest . spyOn ( MatrixClientPeg , "get" ) . mockReturnValue ( mockClient ) ;
2024-02-02 13:20:13 +01:00
2023-08-14 13:52:08 +01:00
const result = getComponent ( ) ;
await result . findByText ( "You're signed out" ) ;
expect ( result . container ) . toMatchSnapshot ( ) ;
} ) ;
} ) ;
2023-06-23 08:57:16 +12:00
describe ( "login via key/pass" , ( ) = > {
let loginClient ! : ReturnType < typeof getMockClientWithEventEmitter > ;
const userName = "ernie" ;
const password = "ilovebert" ;
const getComponentAndWaitForReady = async ( ) : Promise < RenderResult > = > {
const renderResult = getComponent ( ) ;
// wait for welcome page chrome render
2024-11-05 15:41:00 +00:00
await screen . findByText ( "Powered by Matrix" ) ;
2023-06-23 08:57:16 +12:00
// go to login page
2024-11-20 13:29:23 +00:00
act ( ( ) = >
defaultDispatcher . dispatch ( {
action : "start_login" ,
} ) ,
) ;
2023-06-23 08:57:16 +12:00
await flushPromises ( ) ;
return renderResult ;
} ;
2025-12-02 10:42:15 +00:00
const getComponentAndLogin = async ( ) : Promise < void > = > {
2023-06-23 08:57:16 +12:00
await getComponentAndWaitForReady ( ) ;
fireEvent . change ( screen . getByLabelText ( "Username" ) , { target : { value : userName } } ) ;
fireEvent . change ( screen . getByLabelText ( "Password" ) , { target : { value : password } } ) ;
// sign in button is an input
2026-02-18 16:07:25 +00:00
fireEvent . click ( screen . getByRole ( "button" , { name : "Sign in" } ) ) ;
2023-06-23 08:57:16 +12:00
} ;
beforeEach ( ( ) = > {
loginClient = getMockClientWithEventEmitter ( getMockClientMethods ( ) ) ;
// this is used to create a temporary client during login
2023-08-17 20:06:45 +01:00
// FIXME: except it is *also* used as the permanent client for the rest of the test.
2023-07-11 16:09:18 +12:00
jest . spyOn ( MatrixJs , "createClient" ) . mockClear ( ) . mockReturnValue ( loginClient ) ;
2023-06-23 08:57:16 +12:00
2023-07-04 14:49:27 +01:00
loginClient . login . mockClear ( ) . mockResolvedValue ( {
access_token : "TOKEN" ,
device_id : "IMADEVICE" ,
user_id : userId ,
} ) ;
2023-06-23 08:57:16 +12:00
loginClient . loginFlows . mockClear ( ) . mockResolvedValue ( { flows : [ { type : "m.login.password" } ] } ) ;
} ) ;
it ( "should render login page" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
expect ( screen . getAllByText ( "Sign in" ) [ 0 ] ) . toBeInTheDocument ( ) ;
} ) ;
describe ( "post login setup" , ( ) = > {
beforeEach ( ( ) = > {
2023-08-17 20:06:45 +01:00
const mockCrypto = {
2024-05-15 20:25:58 -04:00
getVersion : jest.fn ( ) . mockReturnValue ( "Version 0" ) ,
2023-08-17 20:06:45 +01:00
getVerificationRequestsToDeviceInProgress : jest.fn ( ) . mockReturnValue ( [ ] ) ,
getUserDeviceInfo : jest.fn ( ) . mockResolvedValue ( new Map ( ) ) ,
2024-04-16 10:43:27 +01:00
getUserVerificationStatus : jest
. fn ( )
. mockResolvedValue ( new UserVerificationStatus ( false , false , false ) ) ,
2024-09-30 11:29:14 +01:00
setDeviceIsolationMode : jest.fn ( ) ,
2024-10-15 09:50:26 +02:00
userHasCrossSigningKeys : jest.fn ( ) . mockResolvedValue ( false ) ,
2024-10-21 13:53:39 +02:00
// This needs to not finish immediately because we need to test the screen appears
bootstrapCrossSigning : jest.fn ( ) . mockImplementation ( ( ) = > bootstrapDeferred . promise ) ,
2024-12-17 14:50:48 +00:00
resetKeyBackup : jest.fn ( ) ,
2024-11-19 11:09:25 +01:00
isEncryptionEnabledInRoom : jest.fn ( ) . mockResolvedValue ( false ) ,
2024-12-17 14:50:48 +00:00
checkKeyBackupAndEnable : jest.fn ( ) . mockResolvedValue ( null ) ,
2025-01-31 13:29:59 -05:00
isDehydrationSupported : jest.fn ( ) . mockReturnValue ( false ) ,
2023-08-17 20:06:45 +01:00
} ;
2023-06-23 08:57:16 +12:00
loginClient . getCrypto . mockReturnValue ( mockCrypto as any ) ;
} ) ;
it ( "should go straight to logged in view when crypto is not enabled" , async ( ) = > {
2024-10-15 16:24:58 +02:00
loginClient . getCrypto . mockReturnValue ( undefined ) ;
2023-06-23 08:57:16 +12:00
2025-12-02 10:42:15 +00:00
await getComponentAndLogin ( ) ;
// wait for logged in view to load
await screen . findByLabelText ( "User menu" ) ;
2023-06-23 08:57:16 +12:00
2024-10-15 16:24:58 +02:00
expect ( screen . getByRole ( "heading" , { name : "Welcome Ernie" } ) ) . toBeInTheDocument ( ) ;
2023-06-23 08:57:16 +12:00
} ) ;
2025-12-02 10:42:15 +00:00
describe ( "when user does not have cross signing set up" , ( ) = > {
2023-06-27 21:42:31 +12:00
beforeEach ( ( ) = > {
2024-10-15 09:50:26 +02:00
jest . spyOn ( loginClient . getCrypto ( ) ! , "userHasCrossSigningKeys" ) . mockResolvedValue ( false ) ;
2023-06-27 21:42:31 +12:00
} ) ;
describe ( "when encryption is force disabled" , ( ) = > {
2024-09-30 10:07:53 -04:00
let unencryptedRoom : Room ;
let encryptedRoom : Room ;
2023-06-27 21:42:31 +12:00
beforeEach ( ( ) = > {
2024-09-30 10:07:53 -04:00
unencryptedRoom = new Room ( "!unencrypted:server.org" , loginClient , userId ) ;
encryptedRoom = new Room ( "!encrypted:server.org" , loginClient , userId ) ;
2023-06-27 21:42:31 +12:00
loginClient . getClientWellKnown . mockReturnValue ( {
"io.element.e2ee" : {
force_disable : true ,
} ,
} ) ;
2024-11-19 11:09:25 +01:00
jest . spyOn ( loginClient . getCrypto ( ) ! , "isEncryptionEnabledInRoom" ) . mockImplementation (
async ( roomId ) = > {
return roomId === encryptedRoom . roomId ;
} ,
) ;
2023-06-27 21:42:31 +12:00
} ) ;
it ( "should go straight to logged in view when user is not in any encrypted rooms" , async ( ) = > {
loginClient . getRooms . mockReturnValue ( [ unencryptedRoom ] ) ;
2025-12-02 10:42:15 +00:00
await getComponentAndLogin ( ) ;
2023-06-27 21:42:31 +12:00
2025-12-02 10:42:15 +00:00
// logged in, did not set up keys
2023-06-27 21:42:31 +12:00
await screen . findByLabelText ( "User menu" ) ;
} ) ;
2025-12-02 10:42:15 +00:00
it ( "should go to set up e2e screen when user is in encrypted rooms" , async ( ) = > {
2023-06-27 21:42:31 +12:00
loginClient . getRooms . mockReturnValue ( [ unencryptedRoom , encryptedRoom ] ) ;
await getComponentAndLogin ( ) ;
// set up keys screen is rendered
2025-12-02 10:42:15 +00:00
await screen . findByText ( "Setting up keys" ) ;
2023-06-27 21:42:31 +12:00
} ) ;
} ) ;
2025-12-02 10:42:15 +00:00
it ( "should go to set up e2e screen" , async ( ) = > {
2023-06-27 21:42:31 +12:00
await getComponentAndLogin ( ) ;
// set up keys screen is rendered
2025-12-02 10:42:15 +00:00
await screen . findByText ( "Setting up keys" ) ;
expect ( loginClient . getCrypto ( ) ! . userHasCrossSigningKeys ) . toHaveBeenCalled ( ) ;
2023-06-27 21:42:31 +12:00
} ) ;
} ) ;
2025-12-02 10:42:15 +00:00
it ( "should show complete security screen when user has cross signing set up" , async ( ) = > {
2024-10-15 09:50:26 +02:00
jest . spyOn ( loginClient . getCrypto ( ) ! , "userHasCrossSigningKeys" ) . mockResolvedValue ( true ) ;
2023-06-23 08:57:16 +12:00
await getComponentAndLogin ( ) ;
// Complete security begin screen is rendered
2026-03-18 16:53:28 +01:00
await screen . findByText ( "Confirm your digital identity" ) ;
2025-12-02 10:42:15 +00:00
expect ( loginClient . getCrypto ( ) ! . userHasCrossSigningKeys ) . toHaveBeenCalled ( ) ;
2023-06-23 08:57:16 +12:00
} ) ;
2025-12-02 10:42:15 +00:00
it ( "should set up e2e" , async ( ) = > {
2023-06-23 08:57:16 +12:00
await getComponentAndLogin ( ) ;
// set up keys screen is rendered
2025-12-02 10:42:15 +00:00
await screen . findByText ( "Setting up keys" ) ;
expect ( loginClient . getCrypto ( ) ! . userHasCrossSigningKeys ) . toHaveBeenCalled ( ) ;
2023-06-23 08:57:16 +12:00
} ) ;
} ) ;
} ) ;
2023-06-28 11:45:11 +12:00
describe ( "when query params have a loginToken" , ( ) = > {
const loginToken = "test-login-token" ;
2026-04-15 10:35:02 +01:00
const urlParams = {
legacy_sso : {
loginToken ,
} ,
2023-06-28 11:45:11 +12:00
} ;
let loginClient ! : ReturnType < typeof getMockClientWithEventEmitter > ;
const deviceId = "test-device-id" ;
const accessToken = "test-access-token" ;
const clientLoginResponse = {
user_id : userId ,
device_id : deviceId ,
access_token : accessToken ,
} ;
beforeEach ( ( ) = > {
2023-08-16 17:28:46 +01:00
localStorage . setItem ( "mx_sso_hs_url" , serverConfig . hsUrl ) ;
localStorage . setItem ( "mx_sso_is_url" , serverConfig . isUrl ) ;
2023-06-28 11:45:11 +12:00
loginClient = getMockClientWithEventEmitter ( getMockClientMethods ( ) ) ;
// this is used to create a temporary client during login
jest . spyOn ( MatrixJs , "createClient" ) . mockReturnValue ( loginClient ) ;
loginClient . login . mockClear ( ) . mockResolvedValue ( clientLoginResponse ) ;
} ) ;
it ( "should show an error dialog when no homeserver is found in local storage" , async ( ) = > {
2023-08-16 17:28:46 +01:00
localStorage . removeItem ( "mx_sso_hs_url" ) ;
const localStorageGetSpy = jest . spyOn ( localStorage . __proto__ , "getItem" ) ;
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-08-24 09:28:43 +01:00
await flushPromises ( ) ;
2023-06-28 11:45:11 +12:00
expect ( localStorageGetSpy ) . toHaveBeenCalledWith ( "mx_sso_hs_url" ) ;
expect ( localStorageGetSpy ) . toHaveBeenCalledWith ( "mx_sso_is_url" ) ;
const dialog = await screen . findByRole ( "dialog" ) ;
// warning dialog
expect (
within ( dialog ) . getByText (
"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." ,
) ,
) . toBeInTheDocument ( ) ;
} ) ;
it ( "should attempt token login" , async ( ) = > {
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-08-24 09:28:43 +01:00
await flushPromises ( ) ;
2023-06-28 11:45:11 +12:00
expect ( loginClient . login ) . toHaveBeenCalledWith ( "m.login.token" , {
initial_device_display_name : undefined ,
token : loginToken ,
} ) ;
} ) ;
it ( "should call onTokenLoginCompleted" , async ( ) = > {
const onTokenLoginCompleted = jest . fn ( ) ;
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams , onTokenLoginCompleted } ) ;
2023-06-28 11:45:11 +12:00
2024-10-03 09:55:06 +01:00
await waitFor ( ( ) = > expect ( onTokenLoginCompleted ) . toHaveBeenCalled ( ) ) ;
2023-06-28 11:45:11 +12:00
} ) ;
describe ( "when login fails" , ( ) = > {
beforeEach ( ( ) = > {
loginClient . login . mockRejectedValue ( new Error ( "oups" ) ) ;
} ) ;
it ( "should show a dialog" , async ( ) = > {
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-06-28 11:45:11 +12:00
await flushPromises ( ) ;
const dialog = await screen . findByRole ( "dialog" ) ;
// warning dialog
expect (
within ( dialog ) . getByText (
"There was a problem communicating with the homeserver, please try again later." ,
) ,
) . toBeInTheDocument ( ) ;
} ) ;
it ( "should not clear storage" , async ( ) = > {
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-06-28 11:45:11 +12:00
await flushPromises ( ) ;
expect ( loginClient . clearStores ) . not . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
describe ( "when login succeeds" , ( ) = > {
beforeEach ( ( ) = > {
2024-05-02 16:19:55 -06:00
jest . spyOn ( StorageAccess , "idbLoad" ) . mockImplementation (
2023-06-28 11:45:11 +12:00
async ( _table : string , key : string | string [ ] ) = > {
if ( key === "mx_access_token" ) {
return accessToken as any ;
}
} ,
) ;
} ) ;
2025-04-09 19:03:09 +00:00
2023-06-28 11:45:11 +12:00
it ( "should clear storage" , async ( ) = > {
2023-08-16 17:28:46 +01:00
const localStorageClearSpy = jest . spyOn ( localStorage . __proto__ , "clear" ) ;
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-06-28 11:45:11 +12:00
// just check we called the clearStorage function
2024-10-03 09:55:06 +01:00
await waitFor ( ( ) = > expect ( loginClient . clearStores ) . toHaveBeenCalled ( ) ) ;
2023-08-16 17:28:46 +01:00
expect ( localStorage . getItem ( "mx_sso_hs_url" ) ) . toBe ( null ) ;
2023-06-28 11:45:11 +12:00
expect ( localStorageClearSpy ) . toHaveBeenCalled ( ) ;
} ) ;
it ( "should persist login credentials" , async ( ) = > {
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-06-28 11:45:11 +12:00
2024-10-03 09:55:06 +01:00
await waitFor ( ( ) = > expect ( localStorage . getItem ( "mx_hs_url" ) ) . toEqual ( serverConfig . hsUrl ) ) ;
2023-08-16 17:28:46 +01:00
expect ( localStorage . getItem ( "mx_user_id" ) ) . toEqual ( userId ) ;
expect ( localStorage . getItem ( "mx_has_access_token" ) ) . toEqual ( "true" ) ;
expect ( localStorage . getItem ( "mx_device_id" ) ) . toEqual ( deviceId ) ;
2023-06-28 11:45:11 +12:00
} ) ;
it ( "should set fresh login flag in session storage" , async ( ) = > {
2023-08-16 17:28:46 +01:00
const sessionStorageSetSpy = jest . spyOn ( sessionStorage . __proto__ , "setItem" ) ;
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-06-28 11:45:11 +12:00
2024-10-03 09:55:06 +01:00
await waitFor ( ( ) = > expect ( sessionStorageSetSpy ) . toHaveBeenCalledWith ( "mx_fresh_login" , "true" ) ) ;
2023-06-28 11:45:11 +12:00
} ) ;
it ( "should override hsUrl in creds when login response wellKnown differs from config" , async ( ) = > {
const hsUrlFromWk = "https://hsfromwk.org" ;
const loginResponseWithWellKnown = {
. . . clientLoginResponse ,
well_known : {
"m.homeserver" : {
base_url : hsUrlFromWk ,
} ,
} ,
} ;
loginClient . login . mockResolvedValue ( loginResponseWithWellKnown ) ;
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2023-06-28 11:45:11 +12:00
2024-10-03 09:55:06 +01:00
await waitFor ( ( ) = > expect ( localStorage . getItem ( "mx_hs_url" ) ) . toEqual ( hsUrlFromWk ) ) ;
2023-06-28 11:45:11 +12:00
} ) ;
it ( "should continue to post login setup when no session is found in local storage" , async ( ) = > {
2026-04-15 10:35:02 +01:00
getComponent ( { urlParams } ) ;
2025-04-09 19:03:09 +00:00
defaultDispatcher . dispatch ( {
2025-11-20 18:18:04 +00:00
action : Action.WillStartClient ,
2025-04-09 19:03:09 +00:00
} ) ;
2023-06-28 11:45:11 +12:00
2025-11-19 12:28:08 -05:00
// set up keys screen is rendered
expect ( await screen . findByText ( "Setting up keys" ) ) . toBeInTheDocument ( ) ;
2023-06-28 11:45:11 +12:00
} ) ;
} ) ;
} ) ;
2023-07-11 16:09:18 +12:00
2023-10-03 16:19:54 +01:00
describe ( "automatic SSO selection" , ( ) = > {
let ssoClient : ReturnType < typeof getMockClientWithEventEmitter > ;
let hrefSetter : jest.Mock < void , [ string ] > ;
beforeEach ( ( ) = > {
ssoClient = getMockClientWithEventEmitter ( {
. . . getMockClientMethods ( ) ,
getHomeserverUrl : jest.fn ( ) . mockReturnValue ( "matrix.example.com" ) ,
getIdentityServerUrl : jest.fn ( ) . mockReturnValue ( "ident.example.com" ) ,
getSsoLoginUrl : jest.fn ( ) . mockReturnValue ( "http://my-sso-url" ) ,
} ) ;
// this is used to create a temporary client to cleanup after logout
jest . spyOn ( MatrixJs , "createClient" ) . mockClear ( ) . mockReturnValue ( ssoClient ) ;
mockPlatformPeg ( ) ;
// Ensure we don't have a client peg as we aren't logged in.
unmockClientPeg ( ) ;
hrefSetter = jest . fn ( ) ;
const originalHref = window . location . href . toString ( ) ;
Object . defineProperty ( window , "location" , {
value : {
get href() {
return originalHref ;
} ,
set href ( href ) {
hrefSetter ( href ) ;
} ,
} ,
writable : true ,
} ) ;
} ) ;
it ( "should automatically setup and redirect to SSO login" , async ( ) = > {
getComponent ( {
initialScreenAfterLogin : {
screen : "start_sso" ,
2026-04-15 10:35:02 +01:00
params : { } ,
2023-10-03 16:19:54 +01:00
} ,
} ) ;
2024-10-03 09:55:06 +01:00
await waitFor ( ( ) = >
expect ( ssoClient . getSsoLoginUrl ) . toHaveBeenCalledWith ( "http://localhost/" , "sso" , undefined , undefined ) ,
) ;
2023-10-03 16:19:54 +01:00
expect ( window . localStorage . getItem ( SSO_HOMESERVER_URL_KEY ) ) . toEqual ( "matrix.example.com" ) ;
expect ( window . localStorage . getItem ( SSO_ID_SERVER_URL_KEY ) ) . toEqual ( "ident.example.com" ) ;
expect ( hrefSetter ) . toHaveBeenCalledWith ( "http://my-sso-url" ) ;
} ) ;
it ( "should automatically setup and redirect to CAS login" , async ( ) = > {
getComponent ( {
initialScreenAfterLogin : {
screen : "start_cas" ,
2026-04-15 10:35:02 +01:00
params : { } ,
2023-10-03 16:19:54 +01:00
} ,
} ) ;
2024-10-03 09:55:06 +01:00
await waitFor ( ( ) = >
expect ( ssoClient . getSsoLoginUrl ) . toHaveBeenCalledWith ( "http://localhost/" , "cas" , undefined , undefined ) ,
) ;
2023-10-03 16:19:54 +01:00
expect ( window . localStorage . getItem ( SSO_HOMESERVER_URL_KEY ) ) . toEqual ( "matrix.example.com" ) ;
expect ( window . localStorage . getItem ( SSO_ID_SERVER_URL_KEY ) ) . toEqual ( "ident.example.com" ) ;
expect ( hrefSetter ) . toHaveBeenCalledWith ( "http://my-sso-url" ) ;
} ) ;
} ) ;
2023-08-24 09:28:43 +01:00
describe ( "Multi-tab lockout" , ( ) = > {
2025-09-23 15:46:01 +01:00
beforeEach ( ( ) = > {
mockPlatformPeg ( ) ;
} ) ;
2023-08-24 09:28:43 +01:00
afterEach ( ( ) = > {
Lifecycle . setSessionLockNotStolen ( ) ;
} ) ;
2025-07-18 17:05:51 +05:30
// Flaky test, see https://github.com/element-hq/element-web/issues/30337
2025-07-28 22:07:20 +01:00
it ( "waits for other tab to stop during startup" , async ( ) = > {
2023-08-24 09:28:43 +01:00
jest . spyOn ( Lifecycle , "attemptDelegatedAuthLogin" ) ;
// simulate an active window
localStorage . setItem ( "react_sdk_session_lock_ping" , String ( Date . now ( ) ) ) ;
const rendered = getComponent ( { } ) ;
await flushPromises ( ) ;
expect ( rendered . container ) . toMatchSnapshot ( ) ;
// user confirms
rendered . getByRole ( "button" , { name : "Continue" } ) . click ( ) ;
await flushPromises ( ) ;
// we should have claimed the session, but gone no further
expect ( Lifecycle . attemptDelegatedAuthLogin ) . not . toHaveBeenCalled ( ) ;
const sessionId = localStorage . getItem ( "react_sdk_session_lock_claimant" ) ;
expect ( sessionId ) . toEqual ( expect . stringMatching ( /./ ) ) ;
expect ( rendered . container ) . toMatchSnapshot ( ) ;
// the other tab shuts down
localStorage . removeItem ( "react_sdk_session_lock_ping" ) ;
// fire the storage event manually, because writes to localStorage from the same javascript context don't
// fire it automatically
window . dispatchEvent ( new StorageEvent ( "storage" , { key : "react_sdk_session_lock_ping" } ) ) ;
// startup continues
await flushPromises ( ) ;
expect ( Lifecycle . attemptDelegatedAuthLogin ) . toHaveBeenCalled ( ) ;
// should just show the welcome screen
2026-04-22 16:32:05 +01:00
await rendered . findByText ( "Welcome to Test" ) ;
2023-08-24 09:28:43 +01:00
expect ( rendered . container ) . toMatchSnapshot ( ) ;
} ) ;
describe ( "shows the lockout page when a second tab opens" , ( ) = > {
beforeEach ( ( ) = > {
// make sure we start from a clean DOM for each of these tests
document . body . replaceChildren ( ) ;
2025-09-23 15:46:01 +01:00
// use the MockPlatform
mockPlatformPeg ( ) ;
2023-08-24 09:28:43 +01:00
} ) ;
function simulateSessionLockClaim() {
localStorage . setItem ( "react_sdk_session_lock_claimant" , "testtest" ) ;
2024-11-20 13:29:23 +00:00
act ( ( ) = >
window . dispatchEvent ( new StorageEvent ( "storage" , { key : "react_sdk_session_lock_claimant" } ) ) ,
) ;
2023-08-24 09:28:43 +01:00
}
it ( "after a session is restored" , async ( ) = > {
await populateStorageForSession ( ) ;
const client = getMockClientWithEventEmitter ( getMockClientMethods ( ) ) ;
jest . spyOn ( MatrixJs , "createClient" ) . mockReturnValue ( client ) ;
const rendered = getComponent ( { } ) ;
2025-12-02 10:42:15 +00:00
await rendered . findByText ( "Welcome Ernie" ) ;
2023-08-24 09:28:43 +01:00
// we're now at the welcome page. Another session wants the lock...
simulateSessionLockClaim ( ) ;
await flushPromises ( ) ;
expect ( rendered . container ) . toMatchSnapshot ( ) ;
} ) ;
it ( "while we were waiting for the lock ourselves" , async ( ) = > {
// simulate there already being one session
localStorage . setItem ( "react_sdk_session_lock_ping" , String ( Date . now ( ) ) ) ;
const rendered = getComponent ( { } ) ;
await flushPromises ( ) ;
// user confirms continue
rendered . getByRole ( "button" , { name : "Continue" } ) . click ( ) ;
await flushPromises ( ) ;
expect ( rendered . getByTestId ( "spinner" ) ) . toBeInTheDocument ( ) ;
// now a third session starts
simulateSessionLockClaim ( ) ;
await flushPromises ( ) ;
expect ( rendered . container ) . toMatchSnapshot ( ) ;
} ) ;
it ( "while we are checking the sync store" , async ( ) = > {
const rendered = getComponent ( { } ) ;
expect ( rendered . getByTestId ( "spinner" ) ) . toBeInTheDocument ( ) ;
// now a third session starts
simulateSessionLockClaim ( ) ;
await flushPromises ( ) ;
expect ( rendered . container ) . toMatchSnapshot ( ) ;
} ) ;
it ( "during crypto init" , async ( ) = > {
await populateStorageForSession ( ) ;
const client = new MockClientWithEventEmitter ( {
. . . getMockClientMethods ( ) ,
} ) as unknown as Mocked < MatrixClient > ;
jest . spyOn ( MatrixJs , "createClient" ) . mockReturnValue ( client ) ;
// intercept initCrypto and have it block until we complete the deferred
2025-05-08 11:03:43 +01:00
const initCryptoCompleteDefer = Promise . withResolvers < void > ( ) ;
2023-08-24 09:28:43 +01:00
const initCryptoCalled = new Promise < void > ( ( resolve ) = > {
2024-02-02 13:20:13 +01:00
client . initRustCrypto . mockImplementation ( ( ) = > {
2023-08-24 09:28:43 +01:00
resolve ( ) ;
return initCryptoCompleteDefer . promise ;
} ) ;
} ) ;
const rendered = getComponent ( { } ) ;
await initCryptoCalled ;
console . log ( "initCrypto called" ) ;
simulateSessionLockClaim ( ) ;
await flushPromises ( ) ;
// now we should see the error page
2023-11-22 11:46:11 +01:00
rendered . getByText ( "Test is connected in another tab" ) ;
2023-08-24 09:28:43 +01:00
// let initCrypto complete, and check we don't get a modal
initCryptoCompleteDefer . resolve ( ) ;
await sleep ( 10 ) ; // Modals take a few ms to appear
expect ( document . body ) . toMatchSnapshot ( ) ;
} ) ;
} ) ;
} ) ;
2024-09-20 12:24:39 +01:00
describe ( "mobile registration" , ( ) = > {
const getComponentAndWaitForReady = async ( ) : Promise < RenderResult > = > {
const renderResult = getComponent ( ) ;
// wait for welcome page chrome render
2024-11-05 15:41:00 +00:00
await screen . findByText ( "Powered by Matrix" ) ;
2024-09-20 12:24:39 +01:00
// go to mobile_register page
defaultDispatcher . dispatch ( {
action : "start_mobile_registration" ,
} ) ;
return renderResult ;
} ;
const enabledMobileRegistration = ( ) : void = > {
2024-12-23 20:25:15 +00:00
jest . spyOn ( SettingsStore , "getValue" ) . mockImplementation ( ( settingName ) : any = > {
2024-09-20 12:24:39 +01:00
if ( settingName === "Registration.mobileRegistrationHelper" ) return true ;
if ( settingName === UIFeature . Registration ) return true ;
} ) ;
} ;
it ( "should render welcome screen if mobile registration is not enabled in settings" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
2024-11-05 15:41:00 +00:00
await screen . findByText ( "Powered by Matrix" ) ;
2024-09-20 12:24:39 +01:00
} ) ;
it ( "should render mobile registration" , async ( ) = > {
enabledMobileRegistration ( ) ;
await getComponentAndWaitForReady ( ) ;
2024-10-21 14:50:06 +01:00
await flushPromises ( ) ;
2024-09-20 12:24:39 +01:00
expect ( screen . getByTestId ( "mobile-register" ) ) . toBeInTheDocument ( ) ;
} ) ;
} ) ;
2024-10-18 11:45:45 +02:00
describe ( "when key backup failed" , ( ) = > {
it ( "should show the new recovery method dialog" , async ( ) = > {
2024-11-12 21:19:11 +00:00
const spy = jest . spyOn ( Modal , "createDialog" ) ;
2024-10-18 11:45:45 +02:00
jest . mock ( "../../../../src/async-components/views/dialogs/security/NewRecoveryMethodDialog" , ( ) = > ( {
2024-11-04 11:34:00 +00:00
__test : true ,
2024-10-18 11:45:45 +02:00
__esModule : true ,
default : ( ) = > < span > mocked dialog < / span > ,
} ) ) ;
jest . spyOn ( mockClient . getCrypto ( ) ! , "getActiveSessionBackupVersion" ) . mockResolvedValue ( "version" ) ;
getComponent ( { } ) ;
defaultDispatcher . dispatch ( {
2025-11-20 18:18:04 +00:00
action : Action.WillStartClient ,
2024-10-18 11:45:45 +02:00
} ) ;
await flushPromises ( ) ;
mockClient . emit ( CryptoEvent . KeyBackupFailed , "error code" ) ;
2025-04-09 19:03:09 +00:00
await waitFor ( ( ) = >
expect ( spy ) . toHaveBeenCalledWith (
expect . objectContaining ( {
_payload : expect.objectContaining ( { _result : expect.objectContaining ( { __test : true } ) } ) ,
} ) ,
) ,
) ;
2024-11-12 21:19:11 +00:00
} ) ;
it ( "should show the recovery method removed dialog" , async ( ) = > {
const spy = jest . spyOn ( Modal , "createDialog" ) ;
jest . mock ( "../../../../src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog" , ( ) = > ( {
__test : true ,
__esModule : true ,
default : ( ) = > < span > mocked dialog < / span > ,
} ) ) ;
getComponent ( { } ) ;
defaultDispatcher . dispatch ( {
2025-11-20 18:18:04 +00:00
action : Action.WillStartClient ,
2024-11-12 21:19:11 +00:00
} ) ;
await flushPromises ( ) ;
mockClient . emit ( CryptoEvent . KeyBackupFailed , "error code" ) ;
2025-04-09 19:03:09 +00:00
await waitFor ( ( ) = >
expect ( spy ) . toHaveBeenCalledWith (
expect . objectContaining ( {
_payload : expect.objectContaining ( { _result : expect.objectContaining ( { __test : true } ) } ) ,
} ) ,
) ,
) ;
2024-10-18 11:45:45 +02:00
} ) ;
} ) ;
2026-02-10 15:26:57 +00:00
describe ( "blacklistUnverifiedDevices settings" , ( ) = > {
beforeEach ( async ( ) = > {
mockPlatformPeg ( ) ;
getComponent ( { } ) ;
// Force a client start manually to avoid needing to go through the login flow.
defaultDispatcher . dispatch ( {
action : Action.ClientStarted ,
} ) ;
await flushPromises ( ) ;
} ) ;
afterEach ( ( ) = > {
SettingsStore . reset ( ) ;
} ) ;
it ( "should ignore room-device-level blacklistUnverifiedDevices updates" , async ( ) = > {
// Set the blacklist toggle at a room-specific level ...
await SettingsStore . setValue (
"blacklistUnverifiedDevices" ,
"!room:example.com" ,
SettingLevel . ROOM_DEVICE ,
true ,
) ;
// ... which SHOULD NOT affect the global blacklist property.
expect ( mockClient . getCrypto ( ) ! . globalBlacklistUnverifiedDevices ) . toBeFalsy ( ) ;
} , 10 e3 ) ;
it ( "should update globalBlacklistUnverifiedDevices on device-level updates" , async ( ) = > {
// Set the blacklist toggle at a device level ...
await SettingsStore . setValue ( "blacklistUnverifiedDevices" , null , SettingLevel . DEVICE , true ) ;
// shich SHOULD affect the global blacklist property.
expect ( mockClient . getCrypto ( ) ! . globalBlacklistUnverifiedDevices ) . toBeTruthy ( ) ;
} , 10 e3 ) ;
} ) ;
2023-05-25 14:35:22 +12:00
} ) ;