2023-05-25 10:35:22 +08:00
/ *
Copyright 2023 The Matrix . org Foundation C . I . C .
Licensed under the Apache License , Version 2.0 ( the "License" ) ;
you may not use this file except in compliance with the License .
You may obtain a copy of the License at
http : //www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing , software
distributed under the License is distributed on an "AS IS" BASIS ,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND , either express or implied .
See the License for the specific language governing permissions and
limitations under the License .
* /
import React , { ComponentProps } from "react" ;
2024-04-10 21:13:08 +08:00
import { fireEvent , render , RenderResult , screen , waitFor , within } from "@testing-library/react" ;
2023-07-11 12:09:18 +08:00
import fetchMock from "fetch-mock-jest" ;
2023-08-18 03:06:45 +08:00
import { Mocked , mocked } from "jest-mock" ;
2023-08-10 16:01:14 +08:00
import { ClientEvent , MatrixClient , MatrixEvent , Room , SyncState } from "matrix-js-sdk/src/matrix" ;
2023-05-25 10:35:22 +08:00
import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler" ;
2023-06-23 04:57:16 +08:00
import * as MatrixJs from "matrix-js-sdk/src/matrix" ;
2023-07-11 12:09:18 +08: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" ;
import { BearerTokenResponse } from "matrix-js-sdk/src/oidc/validate" ;
2023-08-24 16:28:43 +08:00
import { defer , sleep } from "matrix-js-sdk/src/utils" ;
2024-04-16 17:43:27 +08:00
import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api" ;
2023-05-25 10:35:22 +08:00
import MatrixChat from "../../../src/components/structures/MatrixChat" ;
2024-05-03 06:19:55 +08:00
import * as StorageAccess from "../../../src/utils/StorageAccess" ;
2023-05-25 10:35:22 +08:00
import defaultDispatcher from "../../../src/dispatcher/dispatcher" ;
import { Action } from "../../../src/dispatcher/actions" ;
import { UserTab } from "../../../src/components/views/dialogs/UserTab" ;
2023-06-23 04:57:16 +08:00
import {
clearAllModals ,
2023-10-30 23:14:27 +08:00
createStubMatrixRTC ,
2023-06-23 04:57:16 +08:00
filterConsole ,
flushPromises ,
getMockClientWithEventEmitter ,
2024-02-13 17:11:50 +08:00
mockClientMethodsServer ,
2023-06-23 04:57:16 +08:00
mockClientMethodsUser ,
2023-08-24 16:28:43 +08:00
MockClientWithEventEmitter ,
2023-07-28 10:25:18 +08:00
mockPlatformPeg ,
2023-08-24 16:28:43 +08:00
resetJsDomAfterEach ,
2023-10-03 23:19:54 +08:00
unmockClientPeg ,
2023-06-23 04:57:16 +08:00
} from "../../test-utils" ;
2023-05-31 06:46:08 +08:00
import * as leaveRoomUtils from "../../../src/utils/leave-behaviour" ;
2023-10-19 10:46:37 +08:00
import { OidcClientError } from "../../../src/utils/oidc/error" ;
2023-07-28 10:25:18 +08:00
import * as voiceBroadcastUtils from "../../../src/voice-broadcast/utils/cleanUpBroadcasts" ;
import LegacyCallHandler from "../../../src/LegacyCallHandler" ;
import { CallStore } from "../../../src/stores/CallStore" ;
import { Call } from "../../../src/models/Call" ;
import { PosthogAnalytics } from "../../../src/PosthogAnalytics" ;
import PlatformPeg from "../../../src/PlatformPeg" ;
import EventIndexPeg from "../../../src/indexing/EventIndexPeg" ;
2023-08-24 16:28:43 +08:00
import * as Lifecycle from "../../../src/Lifecycle" ;
2023-10-03 23:19:54 +08:00
import { SSO_HOMESERVER_URL_KEY , SSO_ID_SERVER_URL_KEY } from "../../../src/BasePlatform" ;
2023-11-24 23:51:28 +08:00
import SettingsStore from "../../../src/settings/SettingsStore" ;
import { SettingLevel } from "../../../src/settings/SettingLevel" ;
2024-02-02 20:20:13 +08:00
import { MatrixClientPeg as peg } from "../../../src/MatrixClientPeg" ;
2024-04-10 21:13:08 +08:00
import DMRoomMap from "../../../src/utils/DMRoomMap" ;
2024-04-29 23:30:19 +08:00
import { ReleaseAnnouncementStore } from "../../../src/stores/ReleaseAnnouncementStore" ;
2023-05-25 10:35:22 +08:00
2023-07-11 12:09:18 +08:00
jest . mock ( "matrix-js-sdk/src/oidc/authorize" , ( ) = > ( {
completeAuthorizationCodeGrant : jest.fn ( ) ,
} ) ) ;
2024-01-05 21:24:00 +08: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 10:35:22 +08:00
describe ( "<MatrixChat />" , ( ) = > {
const userId = "@alice:server.org" ;
const deviceId = "qwertyui" ;
const accessToken = "abc123" ;
2023-06-23 04:57:16 +08:00
// reused in createClient mock below
const getMockClientMethods = ( ) = > ( {
2023-05-25 10:35:22 +08:00
. . . mockClientMethodsUser ( userId ) ,
2024-02-13 17:11:50 +08:00
. . . mockClientMethodsServer ( ) ,
2024-01-05 21:24:00 +08:00
getVersions : jest.fn ( ) . mockResolvedValue ( { versions : SERVER_SUPPORTED_MATRIX_VERSIONS } ) ,
2023-05-25 10:35:22 +08:00
startClient : jest.fn ( ) ,
stopClient : jest.fn ( ) ,
setCanResetTimelineCallback : jest.fn ( ) ,
isInitialSyncComplete : jest.fn ( ) ,
getSyncState : jest.fn ( ) ,
2023-10-03 23:19:54 +08:00
getSsoLoginUrl : jest.fn ( ) ,
2023-05-25 10:35:22 +08:00
getSyncStateData : jest.fn ( ) . mockReturnValue ( null ) ,
getThirdpartyProtocols : jest.fn ( ) . mockResolvedValue ( { } ) ,
getClientWellKnown : jest.fn ( ) . mockReturnValue ( { } ) ,
isVersionSupported : jest.fn ( ) . mockResolvedValue ( false ) ,
isCryptoEnabled : jest.fn ( ) . mockReturnValue ( false ) ,
2024-02-02 20:20:13 +08:00
initRustCrypto : jest.fn ( ) ,
2023-05-25 10:35:22 +08: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 12:09:18 +08:00
startup : jest.fn ( ) ,
2023-05-25 10:35:22 +08:00
} ,
2023-06-23 04:57:16 +08:00
login : jest.fn ( ) ,
loginFlows : jest.fn ( ) ,
isGuest : jest.fn ( ) . mockReturnValue ( false ) ,
clearStores : jest.fn ( ) ,
setGuest : jest.fn ( ) ,
setNotifTimelineSet : jest.fn ( ) ,
getAccountData : jest.fn ( ) ,
doesServerSupportUnstableFeature : jest.fn ( ) ,
getDevices : jest.fn ( ) . mockResolvedValue ( { devices : [ ] } ) ,
getProfileInfo : jest.fn ( ) ,
getVisibleRooms : jest.fn ( ) . mockReturnValue ( [ ] ) ,
getRooms : jest.fn ( ) . mockReturnValue ( [ ] ) ,
userHasCrossSigningKeys : jest.fn ( ) ,
setGlobalBlacklistUnverifiedDevices : jest.fn ( ) ,
setGlobalErrorOnUnknownDevices : jest.fn ( ) ,
getCrypto : jest.fn ( ) ,
secretStorage : {
isStored : jest.fn ( ) . mockReturnValue ( null ) ,
} ,
2023-10-30 23:14:27 +08:00
matrixRTC : createStubMatrixRTC ( ) ,
2023-06-23 04:57:16 +08:00
getDehydratedDevice : jest.fn ( ) ,
2023-07-11 12:09:18 +08:00
whoami : jest.fn ( ) ,
2023-06-27 17:42:31 +08:00
isRoomEncrypted : jest.fn ( ) ,
2023-07-28 10:25:18 +08:00
logout : jest.fn ( ) ,
getDeviceId : jest.fn ( ) ,
2023-05-25 10:35:22 +08:00
} ) ;
2023-08-18 03:06:45 +08:00
let mockClient : Mocked < MatrixClient > ;
2023-05-25 10:35:22 +08:00
const serverConfig = {
hsUrl : "https://test.com" ,
hsName : "Test Server" ,
hsNameIsDifferent : false ,
isUrl : "https://is.com" ,
isDefault : true ,
isNameResolvable : true ,
warning : "" ,
} ;
const defaultProps : ComponentProps < typeof MatrixChat > = {
config : {
brand : "Test" ,
2023-06-14 15:45:19 +08:00
help_url : "help_url" ,
help_encryption_url : "help_encryption_url" ,
2023-05-25 10:35:22 +08:00
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 : { } ,
} ;
const getComponent = ( props : Partial < ComponentProps < typeof MatrixChat > > = { } ) = >
render ( < MatrixChat { ...defaultProps } { ...props } / > ) ;
2023-06-28 07:45:11 +08:00
// make test results readable
2023-08-24 16:28:43 +08: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 10:35:22 +08:00
2023-07-11 12:09:18 +08:00
/ * *
* Wait for a bunch of stuff to happen
* between deciding we are logged in and removing the spinner
* including waiting for initial sync
* /
const waitForSyncAndLoad = async ( client : MatrixClient , withoutSecuritySetup? : boolean ) : Promise < void > = > {
// need to wait for different elements depending on which flow
// without security setup we go to a loading page
if ( withoutSecuritySetup ) {
// we think we are logged in, but are still waiting for the /sync to complete
await screen . findByText ( "Logout" ) ;
// initial sync
client . emit ( ClientEvent . Sync , SyncState . Prepared , null ) ;
// 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
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 ( ) ;
} ;
2023-06-23 04:57:16 +08:00
beforeEach ( async ( ) = > {
mockClient = getMockClientWithEventEmitter ( getMockClientMethods ( ) ) ;
2023-07-11 12:09:18 +08:00
fetchMock . get ( "https://test.com/_matrix/client/versions" , {
2023-05-25 10:35:22 +08:00
unstable_features : { } ,
2024-01-05 21:24:00 +08:00
versions : SERVER_SUPPORTED_MATRIX_VERSIONS ,
2023-05-25 10:35:22 +08:00
} ) ;
2024-02-24 00:43:14 +08:00
fetchMock . catch ( {
status : 404 ,
body : '{"errcode": "M_UNRECOGNIZED", "error": "Unrecognized request"}' ,
headers : { "content-type" : "application/json" } ,
} ) ;
2023-07-28 10:25:18 +08:00
2024-05-03 06:19:55 +08:00
jest . spyOn ( StorageAccess , "idbLoad" ) . mockReset ( ) ;
jest . spyOn ( StorageAccess , "idbSave" ) . mockResolvedValue ( undefined ) ;
2023-05-25 10:35:22 +08:00
jest . spyOn ( defaultDispatcher , "dispatch" ) . mockClear ( ) ;
2024-04-10 21:13:08 +08:00
jest . spyOn ( defaultDispatcher , "fire" ) . mockClear ( ) ;
DMRoomMap . makeShared ( mockClient ) ;
2023-06-28 07:45:11 +08:00
await clearAllModals ( ) ;
2023-05-25 10:35:22 +08:00
} ) ;
2023-08-24 16:28:43 +08:00
resetJsDomAfterEach ( ) ;
2023-07-28 10:25:18 +08:00
afterEach ( ( ) = > {
2024-04-10 21:13:08 +08:00
// @ts-ignore
DMRoomMap . setShared ( null ) ;
2023-08-18 03:06:45 +08:00
jest . restoreAllMocks ( ) ;
// emit a loggedOut event so that all of the Store singletons forget about their references to the mock client
defaultDispatcher . dispatch ( { action : Action.OnLoggedOut } ) ;
2023-07-28 10:25:18 +08:00
} ) ;
2023-05-25 10:35:22 +08:00
it ( "should render spinner while app is loading" , ( ) = > {
const { container } = getComponent ( ) ;
expect ( container ) . toMatchSnapshot ( ) ;
} ) ;
2024-04-10 21:13:08 +08: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 ) ;
} ) ;
} ) ;
2023-11-24 23:51:28 +08: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" ;
const realQueryParams = {
code ,
state : state ,
} ;
const userId = "@alice:server.org" ;
const deviceId = "test-device-id" ;
const accessToken = "test-access-token-from-oidc" ;
const tokenResponse : BearerTokenResponse = {
access_token : accessToken ,
refresh_token : "def456" ,
2024-05-07 19:27:37 +08:00
id_token : "ghi789" ,
2023-11-24 23:51:28 +08: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" ) ;
expect ( within ( dialog ) . getByText ( errorMessage ) ) . toBeInTheDocument ( ) ;
// just check we're back on welcome page
expect ( document . querySelector ( ".mx_Welcome" ) ! ) . toBeInTheDocument ( ) ;
} ;
beforeEach ( ( ) = > {
mocked ( completeAuthorizationCodeGrant )
. mockClear ( )
. mockResolvedValue ( {
oidcClientSettings : {
clientId ,
issuer ,
} ,
tokenResponse ,
homeserverUrl ,
identityServerUrl ,
idTokenClaims : {
aud : "123" ,
iss : issuer ,
sub : "123" ,
exp : 123 ,
iat : 456 ,
} ,
} ) ;
jest . spyOn ( logger , "error" ) . mockClear ( ) ;
} ) ;
beforeEach ( ( ) = > {
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 flushPromises ( ) ;
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 ( ( ) = > {
2024-05-03 06:19:55 +08:00
jest . spyOn ( StorageAccess , "idbLoad" ) . mockImplementation (
2023-11-24 23:51:28 +08:00
async ( _table : string , key : string | string [ ] ) = > ( key === "mx_access_token" ? accessToken : null ) ,
) ;
loginClient . getProfileInfo . mockResolvedValue ( {
displayname : "Ernie" ,
} ) ;
} ) ;
it ( "should persist login credentials" , async ( ) = > {
getComponent ( { realQueryParams } ) ;
await flushPromises ( ) ;
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" ) ;
expect ( localStorage . getItem ( "mx_device_id" ) ) . toEqual ( deviceId ) ;
} ) ;
it ( "should store clientId and issuer in session storage" , async ( ) = > {
getComponent ( { realQueryParams } ) ;
await flushPromises ( ) ;
2024-01-23 21:34:10 +08:00
expect ( localStorage . getItem ( "mx_oidc_client_id" ) ) . toEqual ( clientId ) ;
expect ( localStorage . getItem ( "mx_oidc_token_issuer" ) ) . toEqual ( issuer ) ;
2023-11-24 23:51:28 +08:00
} ) ;
it ( "should set logged in and start MatrixClient" , async ( ) = > {
getComponent ( { realQueryParams } ) ;
await flushPromises ( ) ;
await flushPromises ( ) ;
expect ( logger . log ) . toHaveBeenCalledWith (
"setLoggedIn: mxid: " +
userId +
" deviceId: " +
deviceId +
" guest: " +
false +
" hs: " +
homeserverUrl +
" softLogout: " +
false ,
" freshLogin: " + true ,
) ;
// client successfully started
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 ) ;
} ) ;
} ) ;
} ) ;
2023-05-25 10:35:22 +08:00
describe ( "with an existing session" , ( ) = > {
const mockidb : Record < string , Record < string , string > > = {
acccount : {
mx_access_token : accessToken ,
} ,
} ;
2023-08-24 16:28:43 +08:00
beforeEach ( async ( ) = > {
await populateStorageForSession ( ) ;
2024-05-03 06:19:55 +08:00
jest . spyOn ( StorageAccess , "idbLoad" ) . mockImplementation ( async ( table , key ) = > {
2023-05-25 10:35:22 +08:00
const safeKey = Array . isArray ( key ) ? key [ 0 ] : key ;
return mockidb [ table ] ? . [ safeKey ] ;
} ) ;
} ) ;
const getComponentAndWaitForReady = async ( ) : Promise < RenderResult > = > {
const renderResult = getComponent ( ) ;
2023-06-23 04:57:16 +08:00
2023-05-25 10:35:22 +08: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 ( ) ;
// we think we are logged in, but are still waiting for the /sync to complete
const logoutButton = await screen . findByText ( "Logout" ) ;
expect ( logoutButton ) . toBeInTheDocument ( ) ;
expect ( screen . getByRole ( "progressbar" ) ) . toBeInTheDocument ( ) ;
// 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 ( ) ;
expect ( screen . queryByRole ( "progressbar" ) ) . not . toBeInTheDocument ( ) ;
expect ( screen . getByText ( ` Welcome ${ userId } ` ) ) . toBeInTheDocument ( ) ;
} ) ;
describe ( "onAction()" , ( ) = > {
2023-07-28 10:25:18 +08:00
beforeEach ( ( ) = > {
jest . spyOn ( defaultDispatcher , "dispatch" ) . mockClear ( ) ;
jest . spyOn ( defaultDispatcher , "fire" ) . mockClear ( ) ;
} ) ;
2023-05-25 10:35:22 +08:00
it ( "should open user device settings" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
defaultDispatcher . dispatch ( {
action : Action.ViewUserDeviceSettings ,
} ) ;
await flushPromises ( ) ;
expect ( defaultDispatcher . dispatch ) . toHaveBeenCalledWith ( {
action : Action.ViewUserSettings ,
2023-05-26 09:58:28 +08:00
initialTabId : UserTab.SessionManager ,
2023-05-25 10:35:22 +08:00
} ) ;
} ) ;
2023-05-31 06:46:08 +08: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 10:25:18 +08:00
jest . spyOn ( spaceRoom , "isSpaceRoom" ) . mockReturnValue ( true ) ;
2024-04-29 23:30:19 +08:00
jest . spyOn ( ReleaseAnnouncementStore . instance , "getReleaseAnnouncement" ) . mockReturnValue ( null ) ;
} ) ;
afterEach ( ( ) = > {
jest . restoreAllMocks ( ) ;
2023-05-31 06:46:08 +08: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 ( ) ;
} ) ;
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 ( ) ;
} ) ;
} ) ;
} ) ;
} ) ;
2023-07-28 10:25:18 +08: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 ( voiceBroadcastUtils , "cleanUpBroadcasts" ) . mockImplementation ( async ( ) = > { } ) ;
jest . spyOn ( PosthogAnalytics . instance , "logout" ) . mockImplementation ( ( ) = > { } ) ;
jest . spyOn ( EventIndexPeg , "deleteEventIndex" ) . mockImplementation ( async ( ) = > { } ) ;
jest . spyOn ( CallStore . instance , "activeCalls" , "get" ) . mockReturnValue ( new Set ( [ call1 , call2 ] ) ) ;
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 ( ) ;
} ) ;
afterAll ( ( ) = > {
jest . spyOn ( voiceBroadcastUtils , "cleanUpBroadcasts" ) . mockRestore ( ) ;
} ) ;
it ( "should hangup all legacy calls" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
await dispatchLogoutAndWait ( ) ;
expect ( LegacyCallHandler . instance . hangupAllCalls ) . toHaveBeenCalled ( ) ;
} ) ;
it ( "should cleanup broadcasts" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
await dispatchLogoutAndWait ( ) ;
expect ( voiceBroadcastUtils . cleanUpBroadcasts ) . 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 ) ;
expect ( logoutClient . clearStores ) . toHaveBeenCalled ( ) ;
} ) ;
it ( "should do post-logout cleanup" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
await dispatchLogoutAndWait ( ) ;
// stuff that happens in onloggedout
expect ( defaultDispatcher . fire ) . toHaveBeenCalledWith ( Action . OnLoggedOut , true ) ;
expect ( EventIndexPeg . deleteEventIndex ) . toHaveBeenCalled ( ) ;
expect ( logoutClient . clearStores ) . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
} ) ;
2023-05-25 10:35:22 +08:00
} ) ;
} ) ;
2023-06-23 04:57:16 +08:00
2023-08-14 20:52:08 +08:00
describe ( "with a soft-logged-out session" , ( ) = > {
const mockidb : Record < string , Record < string , string > > = { } ;
2023-08-24 16:28:43 +08:00
beforeEach ( async ( ) = > {
await populateStorageForSession ( ) ;
2023-08-17 00:28:46 +08:00
localStorage . setItem ( "mx_soft_logout" , "true" ) ;
2023-08-14 20:52:08 +08:00
mockClient . loginFlows . mockResolvedValue ( { flows : [ { type : "m.login.password" } ] } ) ;
2024-05-03 06:19:55 +08:00
jest . spyOn ( StorageAccess , "idbLoad" ) . mockImplementation ( async ( table , key ) = > {
2023-08-14 20:52:08 +08:00
const safeKey = Array . isArray ( key ) ? key [ 0 ] : key ;
return mockidb [ table ] ? . [ safeKey ] ;
} ) ;
} ) ;
it ( "should show the soft-logout page" , async ( ) = > {
2024-02-02 20:20:13 +08: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.
const originalReplace = peg . replaceUsingCreds ;
peg . replaceUsingCreds = jest . fn ( ) . mockResolvedValue ( mockClient ) ;
// @ts-ignore - need to mock this for the test
peg . matrixClient = mockClient ;
2023-08-14 20:52:08 +08:00
const result = getComponent ( ) ;
await result . findByText ( "You're signed out" ) ;
expect ( result . container ) . toMatchSnapshot ( ) ;
2024-02-02 20:20:13 +08:00
peg . replaceUsingCreds = originalReplace ;
2023-08-14 20:52:08 +08:00
} ) ;
} ) ;
2023-06-23 04:57:16 +08: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
await screen . findByText ( "powered by Matrix" ) ;
// go to login page
defaultDispatcher . dispatch ( {
action : "start_login" ,
} ) ;
await flushPromises ( ) ;
return renderResult ;
} ;
const getComponentAndLogin = async ( withoutSecuritySetup? : boolean ) : Promise < void > = > {
await getComponentAndWaitForReady ( ) ;
fireEvent . change ( screen . getByLabelText ( "Username" ) , { target : { value : userName } } ) ;
fireEvent . change ( screen . getByLabelText ( "Password" ) , { target : { value : password } } ) ;
// sign in button is an input
fireEvent . click ( screen . getByDisplayValue ( "Sign in" ) ) ;
await waitForSyncAndLoad ( loginClient , withoutSecuritySetup ) ;
} ;
beforeEach ( ( ) = > {
loginClient = getMockClientWithEventEmitter ( getMockClientMethods ( ) ) ;
// this is used to create a temporary client during login
2023-08-18 03:06:45 +08:00
// FIXME: except it is *also* used as the permanent client for the rest of the test.
2023-07-11 12:09:18 +08:00
jest . spyOn ( MatrixJs , "createClient" ) . mockClear ( ) . mockReturnValue ( loginClient ) ;
2023-06-23 04:57:16 +08:00
2023-07-04 21:49:27 +08:00
loginClient . login . mockClear ( ) . mockResolvedValue ( {
access_token : "TOKEN" ,
device_id : "IMADEVICE" ,
user_id : userId ,
} ) ;
2023-06-23 04:57:16 +08:00
loginClient . loginFlows . mockClear ( ) . mockResolvedValue ( { flows : [ { type : "m.login.password" } ] } ) ;
loginClient . getProfileInfo . mockResolvedValue ( {
displayname : "Ernie" ,
} ) ;
} ) ;
it ( "should render login page" , async ( ) = > {
await getComponentAndWaitForReady ( ) ;
expect ( screen . getAllByText ( "Sign in" ) [ 0 ] ) . toBeInTheDocument ( ) ;
} ) ;
describe ( "post login setup" , ( ) = > {
beforeEach ( ( ) = > {
2023-08-18 03:06:45 +08:00
const mockCrypto = {
2024-05-16 08:25:58 +08:00
getVersion : jest.fn ( ) . mockReturnValue ( "Version 0" ) ,
2023-08-18 03:06:45 +08:00
getVerificationRequestsToDeviceInProgress : jest.fn ( ) . mockReturnValue ( [ ] ) ,
getUserDeviceInfo : jest.fn ( ) . mockResolvedValue ( new Map ( ) ) ,
2024-04-16 17:43:27 +08:00
getUserVerificationStatus : jest
. fn ( )
. mockResolvedValue ( new UserVerificationStatus ( false , false , false ) ) ,
2023-08-18 03:06:45 +08:00
} ;
2023-06-23 04:57:16 +08:00
loginClient . isCryptoEnabled . mockReturnValue ( true ) ;
loginClient . getCrypto . mockReturnValue ( mockCrypto as any ) ;
loginClient . userHasCrossSigningKeys . mockClear ( ) . mockResolvedValue ( false ) ;
} ) ;
it ( "should go straight to logged in view when crypto is not enabled" , async ( ) = > {
loginClient . isCryptoEnabled . mockReturnValue ( false ) ;
await getComponentAndLogin ( true ) ;
expect ( loginClient . userHasCrossSigningKeys ) . not . toHaveBeenCalled ( ) ;
} ) ;
it ( "should go straight to logged in view when user does not have cross signing keys and server does not support cross signing" , async ( ) = > {
loginClient . doesServerSupportUnstableFeature . mockResolvedValue ( false ) ;
await getComponentAndLogin ( false ) ;
expect ( loginClient . doesServerSupportUnstableFeature ) . toHaveBeenCalledWith (
"org.matrix.e2e_cross_signing" ,
) ;
await flushPromises ( ) ;
// logged in
await screen . findByLabelText ( "User menu" ) ;
} ) ;
2023-06-27 17:42:31 +08:00
describe ( "when server supports cross signing and user does not have cross signing setup" , ( ) = > {
beforeEach ( ( ) = > {
loginClient . doesServerSupportUnstableFeature . mockResolvedValue ( true ) ;
loginClient . userHasCrossSigningKeys . mockResolvedValue ( false ) ;
} ) ;
describe ( "when encryption is force disabled" , ( ) = > {
const unencryptedRoom = new Room ( "!unencrypted:server.org" , loginClient , userId ) ;
const encryptedRoom = new Room ( "!encrypted:server.org" , loginClient , userId ) ;
beforeEach ( ( ) = > {
loginClient . getClientWellKnown . mockReturnValue ( {
"io.element.e2ee" : {
force_disable : true ,
} ,
} ) ;
loginClient . isRoomEncrypted . mockImplementation ( ( roomId ) = > roomId === encryptedRoom . roomId ) ;
} ) ;
it ( "should go straight to logged in view when user is not in any encrypted rooms" , async ( ) = > {
loginClient . getRooms . mockReturnValue ( [ unencryptedRoom ] ) ;
await getComponentAndLogin ( false ) ;
await flushPromises ( ) ;
// logged in, did not setup keys
await screen . findByLabelText ( "User menu" ) ;
} ) ;
it ( "should go to setup e2e screen when user is in encrypted rooms" , async ( ) = > {
loginClient . getRooms . mockReturnValue ( [ unencryptedRoom , encryptedRoom ] ) ;
await getComponentAndLogin ( ) ;
await flushPromises ( ) ;
// set up keys screen is rendered
expect ( screen . getByText ( "Setting up keys" ) ) . toBeInTheDocument ( ) ;
} ) ;
} ) ;
it ( "should go to setup e2e screen" , async ( ) = > {
loginClient . doesServerSupportUnstableFeature . mockResolvedValue ( true ) ;
await getComponentAndLogin ( ) ;
expect ( loginClient . userHasCrossSigningKeys ) . toHaveBeenCalled ( ) ;
await flushPromises ( ) ;
// set up keys screen is rendered
expect ( screen . getByText ( "Setting up keys" ) ) . toBeInTheDocument ( ) ;
} ) ;
} ) ;
2023-06-23 04:57:16 +08:00
it ( "should show complete security screen when user has cross signing setup" , async ( ) = > {
loginClient . userHasCrossSigningKeys . mockResolvedValue ( true ) ;
await getComponentAndLogin ( ) ;
expect ( loginClient . userHasCrossSigningKeys ) . toHaveBeenCalled ( ) ;
await flushPromises ( ) ;
// Complete security begin screen is rendered
expect ( screen . getByText ( "Unable to verify this device" ) ) . toBeInTheDocument ( ) ;
} ) ;
it ( "should setup e2e when server supports cross signing" , async ( ) = > {
loginClient . doesServerSupportUnstableFeature . mockResolvedValue ( true ) ;
await getComponentAndLogin ( ) ;
expect ( loginClient . userHasCrossSigningKeys ) . toHaveBeenCalled ( ) ;
await flushPromises ( ) ;
// set up keys screen is rendered
expect ( screen . getByText ( "Setting up keys" ) ) . toBeInTheDocument ( ) ;
} ) ;
} ) ;
} ) ;
2023-06-28 07:45:11 +08:00
describe ( "when query params have a loginToken" , ( ) = > {
const loginToken = "test-login-token" ;
const realQueryParams = {
loginToken ,
} ;
let loginClient ! : ReturnType < typeof getMockClientWithEventEmitter > ;
const userId = "@alice:server.org" ;
const deviceId = "test-device-id" ;
const accessToken = "test-access-token" ;
const clientLoginResponse = {
user_id : userId ,
device_id : deviceId ,
access_token : accessToken ,
} ;
beforeEach ( ( ) = > {
2023-08-17 00:28:46 +08:00
localStorage . setItem ( "mx_sso_hs_url" , serverConfig . hsUrl ) ;
localStorage . setItem ( "mx_sso_is_url" , serverConfig . isUrl ) ;
2023-06-28 07:45:11 +08: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-17 00:28:46 +08:00
localStorage . removeItem ( "mx_sso_hs_url" ) ;
const localStorageGetSpy = jest . spyOn ( localStorage . __proto__ , "getItem" ) ;
2023-06-28 07:45:11 +08:00
getComponent ( { realQueryParams } ) ;
2023-08-24 16:28:43 +08:00
await flushPromises ( ) ;
2023-06-28 07:45:11 +08: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 ( ) = > {
getComponent ( { realQueryParams } ) ;
2023-08-24 16:28:43 +08:00
await flushPromises ( ) ;
2023-06-28 07:45:11 +08:00
expect ( loginClient . login ) . toHaveBeenCalledWith ( "m.login.token" , {
initial_device_display_name : undefined ,
token : loginToken ,
} ) ;
} ) ;
it ( "should call onTokenLoginCompleted" , async ( ) = > {
const onTokenLoginCompleted = jest . fn ( ) ;
getComponent ( { realQueryParams , onTokenLoginCompleted } ) ;
await flushPromises ( ) ;
expect ( onTokenLoginCompleted ) . toHaveBeenCalled ( ) ;
} ) ;
describe ( "when login fails" , ( ) = > {
beforeEach ( ( ) = > {
loginClient . login . mockRejectedValue ( new Error ( "oups" ) ) ;
} ) ;
it ( "should show a dialog" , async ( ) = > {
getComponent ( { realQueryParams } ) ;
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 ( ) = > {
getComponent ( { realQueryParams } ) ;
await flushPromises ( ) ;
expect ( loginClient . clearStores ) . not . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
describe ( "when login succeeds" , ( ) = > {
beforeEach ( ( ) = > {
2024-05-03 06:19:55 +08:00
jest . spyOn ( StorageAccess , "idbLoad" ) . mockImplementation (
2023-06-28 07:45:11 +08:00
async ( _table : string , key : string | string [ ] ) = > {
if ( key === "mx_access_token" ) {
return accessToken as any ;
}
} ,
) ;
} ) ;
it ( "should clear storage" , async ( ) = > {
2023-08-17 00:28:46 +08:00
const localStorageClearSpy = jest . spyOn ( localStorage . __proto__ , "clear" ) ;
2023-06-28 07:45:11 +08:00
getComponent ( { realQueryParams } ) ;
await flushPromises ( ) ;
// just check we called the clearStorage function
expect ( loginClient . clearStores ) . toHaveBeenCalled ( ) ;
2023-08-17 00:28:46 +08:00
expect ( localStorage . getItem ( "mx_sso_hs_url" ) ) . toBe ( null ) ;
2023-06-28 07:45:11 +08:00
expect ( localStorageClearSpy ) . toHaveBeenCalled ( ) ;
} ) ;
it ( "should persist login credentials" , async ( ) = > {
getComponent ( { realQueryParams } ) ;
await flushPromises ( ) ;
2023-08-17 00:28:46 +08:00
expect ( localStorage . getItem ( "mx_hs_url" ) ) . toEqual ( serverConfig . hsUrl ) ;
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 07:45:11 +08:00
} ) ;
it ( "should set fresh login flag in session storage" , async ( ) = > {
2023-08-17 00:28:46 +08:00
const sessionStorageSetSpy = jest . spyOn ( sessionStorage . __proto__ , "setItem" ) ;
2023-06-28 07:45:11 +08:00
getComponent ( { realQueryParams } ) ;
await flushPromises ( ) ;
expect ( sessionStorageSetSpy ) . toHaveBeenCalledWith ( "mx_fresh_login" , "true" ) ;
} ) ;
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 ) ;
getComponent ( { realQueryParams } ) ;
await flushPromises ( ) ;
2023-08-17 00:28:46 +08:00
expect ( localStorage . getItem ( "mx_hs_url" ) ) . toEqual ( hsUrlFromWk ) ;
2023-06-28 07:45:11 +08:00
} ) ;
it ( "should continue to post login setup when no session is found in local storage" , async ( ) = > {
getComponent ( { realQueryParams } ) ;
// logged in but waiting for sync screen
await screen . findByText ( "Logout" ) ;
} ) ;
} ) ;
} ) ;
2023-07-11 12:09:18 +08:00
2023-10-03 23:19:54 +08: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" ,
} ,
} ) ;
await flushPromises ( ) ;
expect ( ssoClient . getSsoLoginUrl ) . toHaveBeenCalledWith ( "http://localhost/" , "sso" , undefined , undefined ) ;
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" ,
} ,
} ) ;
await flushPromises ( ) ;
expect ( ssoClient . getSsoLoginUrl ) . toHaveBeenCalledWith ( "http://localhost/" , "cas" , undefined , undefined ) ;
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 16:28:43 +08:00
describe ( "Multi-tab lockout" , ( ) = > {
afterEach ( ( ) = > {
Lifecycle . setSessionLockNotStolen ( ) ;
} ) ;
it ( "waits for other tab to stop during startup" , async ( ) = > {
fetchMock . get ( "/welcome.html" , { body : "<h1>Hello</h1>" } ) ;
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
await rendered . findByText ( "Hello" ) ;
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 ( ) ;
} ) ;
function simulateSessionLockClaim() {
localStorage . setItem ( "react_sdk_session_lock_claimant" , "testtest" ) ;
window . dispatchEvent ( new StorageEvent ( "storage" , { key : "react_sdk_session_lock_claimant" } ) ) ;
}
it ( "after a session is restored" , async ( ) = > {
await populateStorageForSession ( ) ;
const client = getMockClientWithEventEmitter ( getMockClientMethods ( ) ) ;
jest . spyOn ( MatrixJs , "createClient" ) . mockReturnValue ( client ) ;
client . getProfileInfo . mockResolvedValue ( { displayname : "Ernie" } ) ;
const rendered = getComponent ( { } ) ;
await waitForSyncAndLoad ( client , true ) ;
rendered . getByText ( "Welcome Ernie" ) ;
// 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 ( { } ) ;
await flushPromises ( ) ;
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
const initCryptoCompleteDefer = defer ( ) ;
const initCryptoCalled = new Promise < void > ( ( resolve ) = > {
2024-02-02 20:20:13 +08:00
client . initRustCrypto . mockImplementation ( ( ) = > {
2023-08-24 16:28:43 +08: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 18:46:11 +08:00
rendered . getByText ( "Test is connected in another tab" ) ;
2023-08-24 16:28:43 +08: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 ( ) ;
} ) ;
} ) ;
} ) ;
2023-05-25 10:35:22 +08:00
} ) ;