diff --git a/package.json b/package.json index 82aca86eb8..2df1926c8a 100644 --- a/package.json +++ b/package.json @@ -198,6 +198,7 @@ "axe-core": "4.10.0", "babel-jest": "^29.0.0", "blob-polyfill": "^9.0.0", + "core-js": "^3.38.1", "eslint": "8.57.1", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^9.0.0", diff --git a/playwright/e2e/login/login.spec.ts b/playwright/e2e/login/login.spec.ts index 9337cc57e8..696ecb183c 100644 --- a/playwright/e2e/login/login.spec.ts +++ b/playwright/e2e/login/login.spec.ts @@ -6,20 +6,85 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ +import { Page } from "playwright-core"; + import { expect, test } from "../../element-web-test"; import { doTokenRegistration } from "./utils"; import { isDendrite } from "../../plugins/homeserver/dendrite"; import { selectHomeserver } from "../utils"; +import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; + +const username = "user1234"; +const password = "p4s5W0rD"; + +// Pre-generated dummy signing keys to create an account that has signing keys set. +// Note the signatures are specific to the username and must be valid or the HS will reject the keys. +const DEVICE_SIGNING_KEYS_BODY = { + master_key: { + keys: { + "ed25519:6qCouJsi2j7DzOmpxPTBALpvDTqa8p2mjrQR2P8wEbg": "6qCouJsi2j7DzOmpxPTBALpvDTqa8p2mjrQR2P8wEbg", + }, + signatures: { + "@user1234:localhost": { + "ed25519:6qCouJsi2j7DzOmpxPTBALpvDTqa8p2mjrQR2P8wEbg": + "mvwqsYiGa2gPH6ueJsiJnceHMrZhf1pqIMGxkvKisN3ucz8sU7LwyzndbYaLkUKEDx1JuOKFfZ9Mb3mqc7PMBQ", + "ed25519:SRHVWTNVBH": + "HVGmVIzsJe3d+Un/6S9tXPsU7YA8HjZPdxogVzdjEFIU8OjLyElccvjupow0rVWgkEqU8sO21LIHw9cWRZEmDw", + }, + }, + usage: ["master"], + user_id: "@user1234:localhost", + }, + self_signing_key: { + keys: { + "ed25519:eqzRly4S1GvTA36v48hOKokHMtYBLm02zXRgPHue5/8": "eqzRly4S1GvTA36v48hOKokHMtYBLm02zXRgPHue5/8", + }, + signatures: { + "@user1234:localhost": { + "ed25519:6qCouJsi2j7DzOmpxPTBALpvDTqa8p2mjrQR2P8wEbg": + "M2rt5xs+23egbVUwUcZuU7pMpn0chBNC5rpdyZGayfU3FDlx1DbopbakIcl5v4uOSGMbqUotyzkE6CchB+dgDw", + }, + }, + usage: ["self_signing"], + user_id: "@user1234:localhost", + }, + user_signing_key: { + keys: { + "ed25519:h6C7sonjKSSa/VMvmpmFnwMA02H2rKIMSYZ2ddwgJn4": "h6C7sonjKSSa/VMvmpmFnwMA02H2rKIMSYZ2ddwgJn4", + }, + signatures: { + "@user1234:localhost": { + "ed25519:6qCouJsi2j7DzOmpxPTBALpvDTqa8p2mjrQR2P8wEbg": + "5ZMJ7SG2qr76vU2nITKap88AxLZ/RZQmF/mBcAcVZ9Bknvos3WQp8qN9jKuiqOHCq/XpPORA6XBmiDIyPqTFAA", + }, + }, + usage: ["user_signing"], + user_id: "@user1234:localhost", + }, + auth: { + type: "m.login.password", + identifier: { type: "m.id.user", user: "@user1234:localhost" }, + password: password, + }, +}; + +async function login(page: Page, homeserver: HomeserverInstance) { + await page.getByRole("link", { name: "Sign in" }).click(); + await selectHomeserver(page, homeserver.config.baseUrl); + + await page.getByRole("textbox", { name: "Username" }).fill(username); + await page.getByPlaceholder("Password").fill(password); + await page.getByRole("button", { name: "Sign in" }).click(); +} test.describe("Login", () => { test.describe("Password login", () => { test.use({ startHomeserverOpts: "consent" }); - const username = "user1234"; - const password = "p4s5W0rD"; + let creds: Credentials; test.beforeEach(async ({ homeserver }) => { - await homeserver.registerUser(username, password); + creds = await homeserver.registerUser(username, password); }); test("Loads the welcome page by default; then logs in with an existing account and lands on the home screen", async ({ @@ -65,17 +130,97 @@ test.describe("Login", () => { test("Follows the original link after login", async ({ page, homeserver }) => { await page.goto("/#/room/!room:id"); // should redirect to the welcome page - await page.getByRole("link", { name: "Sign in" }).click(); - - await selectHomeserver(page, homeserver.config.baseUrl); - - await page.getByRole("textbox", { name: "Username" }).fill(username); - await page.getByPlaceholder("Password").fill(password); - await page.getByRole("button", { name: "Sign in" }).click(); + await login(page, homeserver); await expect(page).toHaveURL(/\/#\/room\/!room:id$/); await expect(page.getByRole("button", { name: "Join the discussion" })).toBeVisible(); }); + + test.describe("verification after login", () => { + test("Shows verification prompt after login if signing keys are set up, skippable by default", async ({ + page, + homeserver, + request, + }) => { + const res = await request.post( + `${homeserver.config.baseUrl}/_matrix/client/v3/keys/device_signing/upload`, + { headers: { Authorization: `Bearer ${creds.accessToken}` }, data: DEVICE_SIGNING_KEYS_BODY }, + ); + if (res.status() / 100 !== 2) { + console.log("Uploading dummy keys failed", await res.json()); + } + expect(res.status() / 100).toEqual(2); + + await page.goto("/"); + await login(page, homeserver); + + await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible(); + + await expect(page.getByRole("button", { name: "Skip verification for now" })).toBeVisible(); + }); + + test.describe("with force_verification off", () => { + test.use({ + config: { + force_verification: false, + }, + }); + + test("Shows skippable verification prompt after login if signing keys are set up", async ({ + page, + homeserver, + request, + }) => { + const res = await request.post( + `${homeserver.config.baseUrl}/_matrix/client/v3/keys/device_signing/upload`, + { headers: { Authorization: `Bearer ${creds.accessToken}` }, data: DEVICE_SIGNING_KEYS_BODY }, + ); + if (res.status() / 100 !== 2) { + console.log("Uploading dummy keys failed", await res.json()); + } + expect(res.status() / 100).toEqual(2); + + await page.goto("/"); + await login(page, homeserver); + + await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible(); + + await expect(page.getByRole("button", { name: "Skip verification for now" })).toBeVisible(); + }); + }); + + test.describe("with force_verification on", () => { + test.use({ + config: { + force_verification: true, + }, + }); + + test("Shows unskippable verification prompt after login if signing keys are set up", async ({ + page, + homeserver, + request, + }) => { + console.log(`uid ${creds.userId} body`, DEVICE_SIGNING_KEYS_BODY); + const res = await request.post( + `${homeserver.config.baseUrl}/_matrix/client/v3/keys/device_signing/upload`, + { headers: { Authorization: `Bearer ${creds.accessToken}` }, data: DEVICE_SIGNING_KEYS_BODY }, + ); + if (res.status() / 100 !== 2) { + console.log("Uploading dummy keys failed", await res.json()); + } + expect(res.status() / 100).toEqual(2); + + await page.goto("/"); + await login(page, homeserver); + + const h1 = await page.getByRole("heading", { name: "Verify this device", level: 1 }); + await expect(h1).toBeVisible(); + + expect(h1.locator(".mx_CompleteSecurity_skip")).not.toBeVisible(); + }); + }); + }); }); // tests for old-style SSO login, in which we exchange tokens with Synapse, and Synapse talks to an auth server diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index a8ddaf0a1d..682d11aba5 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -824,6 +824,8 @@ async function doSetLoggedIn( } checkSessionLock(); + // We are now logged in, so fire this. We have yet to start the client but the + // client_started dispatch is for that. dis.fire(Action.OnLoggedIn); const clientPegOpts: MatrixClientPegAssignOpts = {}; @@ -846,6 +848,12 @@ async function doSetLoggedIn( // Run the migrations after the MatrixClientPeg has been assigned SettingsStore.runMigrations(isFreshLogin); + if (isFreshLogin && !credentials.guest) { + // For newly registered users, set a flag so that we force them to verify, + // (we don't want to force users with existing sessions to verify though) + localStorage.setItem("must_verify_device", "true"); + } + return client; } diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index 96b33923c0..abf9933e2b 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -24,6 +24,7 @@ export const DEFAULTS: DeepReadonly = { integrations_rest_url: "https://scalar.vector.im/api", uisi_autorageshake_app: "element-auto-uisi", show_labs_settings: false, + force_verification: false, jitsi: { preferred_domain: "meet.element.io", diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 1726c8462d..f3b75dfce8 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -166,6 +166,12 @@ interface IProps { initialScreenAfterLogin?: IScreen; // displayname, if any, to set on the device when logging in/registering. defaultDeviceDisplayName?: string; + + // Used by tests, this function is called when session initialisation starts + // with a promise that resolves or rejects once the initialiation process + // has finished, so that tests can wait for this to avoid them executing over + // each other. + initPromiseCallback?: (p: Promise) => void; } interface IState { @@ -309,7 +315,12 @@ export default class MatrixChat extends React.PureComponent { * Kick off a call to {@link initSession}, and handle any errors */ private startInitSession = (): void => { - this.initSession().catch((err) => { + const initProm = this.initSession(); + if (this.props.initPromiseCallback) { + this.props.initPromiseCallback(initProm); + } + + initProm.catch((err) => { // TODO: show an error screen, rather than a spinner of doom logger.error("Error initialising Matrix session", err); }); @@ -881,7 +892,10 @@ export default class MatrixChat extends React.PureComponent { }); break; case "client_started": - this.onClientStarted(); + // No need to make this handler async to wait for the result of this + this.onClientStarted().catch((e) => { + logger.error("Exception in onClientStarted", e); + }); break; case "send_event": this.onSendEvent(payload.room_id, payload.event); @@ -1320,6 +1334,25 @@ export default class MatrixChat extends React.PureComponent { } } + /** + * Returns true if the user must go through the device verification process before they + * can use the app. + * @returns true if the user must verify + */ + private async shouldForceVerification(): Promise { + if (!SdkConfig.get("force_verification")) return false; + const mustVerifyFlag = localStorage.getItem("must_verify_device"); + if (!mustVerifyFlag) return false; + + const client = MatrixClientPeg.safeGet(); + if (client.isGuest()) return false; + + const crypto = client.getCrypto(); + const crossSigningReady = await crypto?.isCrossSigningReady(); + + return !crossSigningReady; + } + /** * Called when a new logged in session has started */ @@ -1328,30 +1361,7 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher.recheck(); StorageManager.tryPersistStorage(); - if (MatrixClientPeg.currentUserIsJustRegistered() && SettingsStore.getValue("FTUE.useCaseSelection") === null) { - this.setStateForNewView({ view: Views.USE_CASE_SELECTION }); - - // Listen to changes in settings and hide the use case screen if appropriate - this is necessary because - // account settings can still be changing at this point in app init (due to the initial sync being cached, - // then subsequent syncs being received from the server) - // - // This seems unlikely for something that should happen directly after registration, but if a user does - // their initial login on another device/browser than they registered on, we want to avoid asking this - // question twice - // - // initPosthogAnalyticsToast pioneered this technique, we’re just reusing it here. - SettingsStore.watchSetting( - "FTUE.useCaseSelection", - null, - (originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue) => { - if (newValue !== null && this.state.view === Views.USE_CASE_SELECTION) { - this.onShowPostLoginScreen(); - } - }, - ); - } else { - return this.onShowPostLoginScreen(); - } + await this.onShowPostLoginScreen(); } private async onShowPostLoginScreen(useCase?: UseCase): Promise { @@ -1557,9 +1567,6 @@ export default class MatrixChat extends React.PureComponent { } dis.fire(Action.FocusSendMessageComposer); - this.setState({ - ready: true, - }); }); cli.on(HttpApiEvent.SessionLoggedOut, function (errObj) { @@ -1702,9 +1709,20 @@ export default class MatrixChat extends React.PureComponent { * setting up anything that requires the client to be started. * @private */ - private onClientStarted(): void { + private async onClientStarted(): Promise { const cli = MatrixClientPeg.safeGet(); + const shouldForceVerification = await this.shouldForceVerification(); + // XXX: Don't replace the screen if it's already one of these: postLoginSetup + // changes to these screens in certain circumstances so we shouldn't clobber it. + // We should probably have one place where we decide what the next screen is after + // login. + if (![Views.COMPLETE_SECURITY, Views.E2E_SETUP].includes(this.state.view)) { + if (shouldForceVerification) { + this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); + } + } + if (cli.isCryptoEnabled()) { const blacklistEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices"); cli.setGlobalBlacklistUnverifiedDevices(blacklistEnabled); @@ -1722,6 +1740,10 @@ export default class MatrixChat extends React.PureComponent { if (PosthogAnalytics.instance.isEnabled() && SettingsStore.isLevelSupported(SettingLevel.ACCOUNT)) { this.initPosthogAnalyticsToast(); } + + this.setState({ + ready: true, + }); } public showScreen(screen: string, params?: { [key: string]: any }): void { @@ -2013,7 +2035,33 @@ export default class MatrixChat extends React.PureComponent { // complete security / e2e setup has finished private onCompleteSecurityE2eSetupFinished = (): void => { - this.onLoggedIn(); + if (MatrixClientPeg.currentUserIsJustRegistered() && SettingsStore.getValue("FTUE.useCaseSelection") === null) { + this.setStateForNewView({ view: Views.USE_CASE_SELECTION }); + + // Listen to changes in settings and hide the use case screen if appropriate - this is necessary because + // account settings can still be changing at this point in app init (due to the initial sync being cached, + // then subsequent syncs being received from the server) + // + // This seems unlikely for something that should happen directly after registration, but if a user does + // their initial login on another device/browser than they registered on, we want to avoid asking this + // question twice + // + // initPosthogAnalyticsToast pioneered this technique, we’re just reusing it here. + SettingsStore.watchSetting( + "FTUE.useCaseSelection", + null, + (originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue) => { + if (newValue !== null && this.state.view === Views.USE_CASE_SELECTION) { + this.onShowPostLoginScreen(); + } + }, + ); + } else { + // This is async but we makign this function async to wait for it isn't useful + this.onShowPostLoginScreen().catch((e) => { + logger.error("Exception showing post-login screen", e); + }); + } }; private getFragmentAfterLogin(): string { diff --git a/src/components/structures/auth/CompleteSecurity.tsx b/src/components/structures/auth/CompleteSecurity.tsx index 568b7bffbd..a74e07692d 100644 --- a/src/components/structures/auth/CompleteSecurity.tsx +++ b/src/components/structures/auth/CompleteSecurity.tsx @@ -83,7 +83,7 @@ export default class CompleteSecurity extends React.Component { throw new Error(`Unknown phase ${phase}`); } - const forceVerification = SdkConfig.get("force_verification") ?? false; + const forceVerification = SdkConfig.get("force_verification"); let skipButton; if (!forceVerification && (phase === Phase.Intro || phase === Phase.ConfirmReset)) { diff --git a/src/components/views/auth/Welcome.tsx b/src/components/views/auth/Welcome.tsx index 8b2a04d54b..13755f6ca1 100644 --- a/src/components/views/auth/Welcome.tsx +++ b/src/components/views/auth/Welcome.tsx @@ -47,6 +47,7 @@ export default class Welcome extends React.PureComponent { className={classNames("mx_Welcome", { mx_WelcomePage_registrationDisabled: !SettingsStore.getValue(UIFeature.Registration), })} + data-testid="mx_welcome_screen" > diff --git a/src/hooks/useUserOnboardingContext.ts b/src/hooks/useUserOnboardingContext.ts index 37d9650e25..8bd93603d4 100644 --- a/src/hooks/useUserOnboardingContext.ts +++ b/src/hooks/useUserOnboardingContext.ts @@ -14,7 +14,7 @@ import { Notifier, NotifierEvent } from "../Notifier"; import DMRoomMap from "../utils/DMRoomMap"; import { useMatrixClientContext } from "../contexts/MatrixClientContext"; import { useSettingValue } from "./useSettings"; -import { useEventEmitter } from "./useEventEmitter"; +import { useEventEmitter, useTypedEventEmitter } from "./useEventEmitter"; export interface UserOnboardingContext { hasAvatar: boolean; @@ -77,14 +77,27 @@ function useUserOnboardingContextValue(defaultValue: T, callback: (cli: Matri } function useShowNotificationsPrompt(): boolean { - const [value, setValue] = useState(Notifier.shouldShowPrompt()); + const client = useMatrixClientContext(); + + const [value, setValue] = useState(client.pushRules ? Notifier.shouldShowPrompt() : true); + + const updateValue = useCallback(() => { + setValue(client.pushRules ? Notifier.shouldShowPrompt() : true); + }, [client]); + useEventEmitter(Notifier, NotifierEvent.NotificationHiddenChange, () => { - setValue(Notifier.shouldShowPrompt()); + updateValue(); }); + const setting = useSettingValue("notificationsEnabled"); useEffect(() => { - setValue(Notifier.shouldShowPrompt()); - }, [setting]); + updateValue(); + }, [setting, updateValue]); + + // shouldShowPrompt is dependent on the client having push rules. There isn't an event for the client + // fetching its push rules, but we'll know it has them by the time it sync, so we update this on sync. + useTypedEventEmitter(client, ClientEvent.Sync, updateValue); + return value; } diff --git a/test/components/structures/MatrixChat-test.tsx b/test/components/structures/MatrixChat-test.tsx index dd4ae5de64..8876fdc802 100644 --- a/test/components/structures/MatrixChat-test.tsx +++ b/test/components/structures/MatrixChat-test.tsx @@ -6,6 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ +// fake-indexeddb needs this and the tests crash without it +// https://github.com/dumbmatter/fakeIndexedDB?tab=readme-ov-file#jsdom-often-used-with-jest +import "core-js/stable/structured-clone"; +import "fake-indexeddb/auto"; import React, { ComponentProps } from "react"; import { fireEvent, render, RenderResult, screen, waitFor, within } from "@testing-library/react"; import fetchMock from "fetch-mock-jest"; @@ -17,7 +21,7 @@ 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"; -import { defer, sleep } from "matrix-js-sdk/src/utils"; +import { defer, IDeferred, sleep } from "matrix-js-sdk/src/utils"; import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import MatrixChat from "../../../src/components/structures/MatrixChat"; @@ -51,11 +55,13 @@ import * as Lifecycle from "../../../src/Lifecycle"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../src/BasePlatform"; import SettingsStore from "../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../src/settings/SettingLevel"; -import { MatrixClientPeg as peg } from "../../../src/MatrixClientPeg"; +import { MatrixClientPeg, MatrixClientPeg as peg } from "../../../src/MatrixClientPeg"; import DMRoomMap from "../../../src/utils/DMRoomMap"; import { ReleaseAnnouncementStore } from "../../../src/stores/ReleaseAnnouncementStore"; import { DRAFT_LAST_CLEANUP_KEY } from "../../../src/DraftCleaner"; import { UIFeature } from "../../../src/settings/UIFeature"; +import AutoDiscoveryUtils from "../../../src/utils/AutoDiscoveryUtils"; +import { ValidatedServerConfig } from "../../../src/utils/ValidatedServerConfig"; jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({ completeAuthorizationCodeGrant: jest.fn(), @@ -72,12 +78,17 @@ describe("", () => { const userId = "@alice:server.org"; const deviceId = "qwertyui"; const accessToken = "abc123"; + const refreshToken = "def456"; + let bootstrapDeferred: IDeferred; // reused in createClient mock below const getMockClientMethods = () => ({ ...mockClientMethodsUser(userId), ...mockClientMethodsServer(), getVersions: jest.fn().mockResolvedValue({ versions: SERVER_SUPPORTED_MATRIX_VERSIONS }), - startClient: jest.fn(), + startClient: function () { + // @ts-ignore + this.emit(ClientEvent.Sync, SyncState.Prepared, null); + }, stopClient: jest.fn(), setCanResetTimelineCallback: jest.fn(), isInitialSyncComplete: jest.fn(), @@ -110,13 +121,24 @@ describe("", () => { getAccountData: jest.fn(), doesServerSupportUnstableFeature: jest.fn(), getDevices: jest.fn().mockResolvedValue({ devices: [] }), - getProfileInfo: jest.fn(), + getProfileInfo: jest.fn().mockResolvedValue({ + displayname: "Ernie", + }), getVisibleRooms: jest.fn().mockReturnValue([]), getRooms: jest.fn().mockReturnValue([]), userHasCrossSigningKeys: jest.fn(), setGlobalBlacklistUnverifiedDevices: jest.fn(), setGlobalErrorOnUnknownDevices: jest.fn(), - getCrypto: jest.fn(), + getCrypto: jest.fn().mockReturnValue({ + getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]), + isCrossSigningReady: jest.fn().mockReturnValue(false), + getUserDeviceInfo: jest.fn().mockReturnValue(new Map()), + getUserVerificationStatus: jest.fn().mockResolvedValue(new UserVerificationStatus(false, false, false)), + getVersion: jest.fn().mockReturnValue("1"), + setDeviceIsolationMode: jest.fn(), + }), + // This needs to not finish immediately because we need to test the screen appears + bootstrapCrossSigning: jest.fn().mockImplementation(() => bootstrapDeferred.promise), secretStorage: { isStored: jest.fn().mockReturnValue(null), }, @@ -137,22 +159,8 @@ describe("", () => { isNameResolvable: true, warning: "", }; - const defaultProps: ComponentProps = { - config: { - brand: "Test", - help_url: "help_url", - help_encryption_url: "help_encryption_url", - element_call: {}, - feedback: { - existing_issues_url: "https://feedback.org/existing", - new_issue_url: "https://feedback.org/new", - }, - validated_server_config: serverConfig, - }, - onNewScreen: jest.fn(), - onTokenLoginCompleted: jest.fn(), - realQueryParams: {}, - }; + let initPromise: Promise | undefined; + let defaultProps: ComponentProps; const getComponent = (props: Partial> = {}) => render(); @@ -184,10 +192,6 @@ describe("", () => { // 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"); @@ -207,39 +211,64 @@ describe("", () => { }; beforeEach(async () => { + defaultProps = { + config: { + brand: "Test", + help_url: "help_url", + help_encryption_url: "help_encryption_url", + element_call: {}, + feedback: { + existing_issues_url: "https://feedback.org/existing", + new_issue_url: "https://feedback.org/new", + }, + validated_server_config: serverConfig, + }, + onNewScreen: jest.fn(), + onTokenLoginCompleted: jest.fn(), + realQueryParams: {}, + initPromiseCallback: (p: Promise) => (initPromise = p), + }; + + initPromise = undefined; mockClient = getMockClientWithEventEmitter(getMockClientMethods()); - fetchMock.get("https://test.com/_matrix/client/versions", { - unstable_features: {}, - versions: SERVER_SUPPORTED_MATRIX_VERSIONS, - }); - fetchMock.catch({ - status: 404, - body: '{"errcode": "M_UNRECOGNIZED", "error": "Unrecognized request"}', - headers: { "content-type": "application/json" }, - }); + jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient); - jest.spyOn(StorageAccess, "idbLoad").mockReset(); - jest.spyOn(StorageAccess, "idbSave").mockResolvedValue(undefined); jest.spyOn(defaultDispatcher, "dispatch").mockClear(); jest.spyOn(defaultDispatcher, "fire").mockClear(); DMRoomMap.makeShared(mockClient); + jest.spyOn(AutoDiscoveryUtils, "validateServerConfigWithStaticUrls").mockResolvedValue( + {} as ValidatedServerConfig, + ); + + bootstrapDeferred = defer(); + await clearAllModals(); }); - resetJsDomAfterEach(); + afterEach(async () => { + // Wait for the promise that MatrixChat gives us to complete so that we know + // it's finished running its login code. We either need to do this or make the + // login code abort halfway through once the test finishes testing whatever it + // needs to test. If we do nothing, the login code will just continue running + // and interfere with the subsequent tests. + await initPromise; - afterEach(() => { // @ts-ignore DMRoomMap.setShared(null); 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 }); + // (must be sync otherwise the next test will start before it happens) + defaultDispatcher.dispatch({ action: Action.OnLoggedOut }, true); + + localStorage.clear(); }); + resetJsDomAfterEach(); + it("should render spinner while app is loading", () => { const { container } = getComponent(); @@ -298,7 +327,7 @@ describe("", () => { expect(within(dialog).getByText(errorMessage)).toBeInTheDocument(); // just check we're back on welcome page - expect(document.querySelector(".mx_Welcome")!).toBeInTheDocument(); + await expect(await screen.findByTestId("mx_welcome_screen")).toBeInTheDocument(); }; beforeEach(() => { @@ -395,9 +424,7 @@ describe("", () => { const onTokenLoginCompleted = jest.fn(); getComponent({ realQueryParams, onTokenLoginCompleted }); - await flushPromises(); - - expect(onTokenLoginCompleted).toHaveBeenCalled(); + await waitFor(() => expect(onTokenLoginCompleted).toHaveBeenCalled()); }); describe("when login fails", () => { @@ -461,17 +488,12 @@ describe("", () => { jest.spyOn(StorageAccess, "idbLoad").mockImplementation( 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); + await waitFor(() => 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); @@ -480,34 +502,17 @@ describe("", () => { it("should store clientId and issuer in session storage", async () => { getComponent({ realQueryParams }); - await flushPromises(); - - expect(localStorage.getItem("mx_oidc_client_id")).toEqual(clientId); + await waitFor(() => expect(localStorage.getItem("mx_oidc_client_id")).toEqual(clientId)); expect(localStorage.getItem("mx_oidc_token_issuer")).toEqual(issuer); }); 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" }); + await waitFor(() => + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "client_started" }), + ); // check we get to logged in view await waitForSyncAndLoad(loginClient, true); @@ -545,8 +550,9 @@ describe("", () => { describe("with an existing session", () => { const mockidb: Record> = { - acccount: { + account: { mx_access_token: accessToken, + mx_refresh_token: refreshToken, }, }; @@ -579,21 +585,12 @@ describe("", () => { 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(); + const h1Element = screen.getByRole("heading", { level: 1 }); + expect(h1Element).toHaveTextContent(`Welcome Ernie`); }); describe("clean up drafts", () => { @@ -888,7 +885,7 @@ describe("", () => { // stuff that happens in onloggedout expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.OnLoggedOut, true); - expect(logoutClient.clearStores).toHaveBeenCalled(); + await waitFor(() => expect(logoutClient.clearStores).toHaveBeenCalled()); }); it("should do post-logout cleanup", async () => { @@ -897,12 +894,22 @@ describe("", () => { // stuff that happens in onloggedout expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.OnLoggedOut, true); - expect(EventIndexPeg.deleteEventIndex).toHaveBeenCalled(); + await waitFor(() => expect(EventIndexPeg.deleteEventIndex).toHaveBeenCalled()); expect(logoutClient.clearStores).toHaveBeenCalled(); }); }); }); }); + + describe("unskippable verification", () => { + it("should show the complete security screen if unskippable verification is enabled", async () => { + defaultProps.config.force_verification = true; + localStorage.setItem("must_verify_device", "true"); + getComponent(); + + await screen.findByRole("heading", { name: "Unable to verify this device", level: 1 }); + }); + }); }); describe("with a soft-logged-out session", () => { @@ -985,10 +992,6 @@ describe("", () => { user_id: userId, }); loginClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] }); - - loginClient.getProfileInfo.mockResolvedValue({ - displayname: "Ernie", - }); }); it("should render login page", async () => { @@ -1056,7 +1059,9 @@ describe("", () => { }, }); - loginClient.isRoomEncrypted.mockImplementation((roomId) => roomId === encryptedRoom.roomId); + loginClient.isRoomEncrypted.mockImplementation((roomId) => { + return roomId === encryptedRoom.roomId; + }); }); it("should go straight to logged in view when user is not in any encrypted rooms", async () => { @@ -1085,10 +1090,8 @@ describe("", () => { expect(loginClient.userHasCrossSigningKeys).toHaveBeenCalled(); - await flushPromises(); - // set up keys screen is rendered - expect(screen.getByText("Setting up keys")).toBeInTheDocument(); + await expect(await screen.findByText("Setting up keys")).toBeInTheDocument(); }); }); @@ -1117,6 +1120,17 @@ describe("", () => { // set up keys screen is rendered expect(screen.getByText("Setting up keys")).toBeInTheDocument(); }); + + it("should go to use case selection if user just registered", async () => { + loginClient.doesServerSupportUnstableFeature.mockResolvedValue(true); + MatrixClientPeg.setJustRegisteredUserId(userId); + + await getComponentAndLogin(); + + bootstrapDeferred.resolve(); + + await expect(await screen.findByRole("heading", { name: "You're in", level: 1 })).toBeInTheDocument(); + }); }); }); @@ -1180,9 +1194,7 @@ describe("", () => { const onTokenLoginCompleted = jest.fn(); getComponent({ realQueryParams, onTokenLoginCompleted }); - await flushPromises(); - - expect(onTokenLoginCompleted).toHaveBeenCalled(); + await waitFor(() => expect(onTokenLoginCompleted).toHaveBeenCalled()); }); describe("when login fails", () => { @@ -1228,10 +1240,8 @@ describe("", () => { getComponent({ realQueryParams }); - await flushPromises(); - // just check we called the clearStorage function - expect(loginClient.clearStores).toHaveBeenCalled(); + await waitFor(() => expect(loginClient.clearStores).toHaveBeenCalled()); expect(localStorage.getItem("mx_sso_hs_url")).toBe(null); expect(localStorageClearSpy).toHaveBeenCalled(); }); @@ -1239,9 +1249,7 @@ describe("", () => { it("should persist login credentials", async () => { getComponent({ realQueryParams }); - await flushPromises(); - - expect(localStorage.getItem("mx_hs_url")).toEqual(serverConfig.hsUrl); + await waitFor(() => 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); @@ -1251,8 +1259,7 @@ describe("", () => { const sessionStorageSetSpy = jest.spyOn(sessionStorage.__proto__, "setItem"); getComponent({ realQueryParams }); - await flushPromises(); - expect(sessionStorageSetSpy).toHaveBeenCalledWith("mx_fresh_login", "true"); + await waitFor(() => expect(sessionStorageSetSpy).toHaveBeenCalledWith("mx_fresh_login", "true")); }); it("should override hsUrl in creds when login response wellKnown differs from config", async () => { @@ -1268,9 +1275,7 @@ describe("", () => { loginClient.login.mockResolvedValue(loginResponseWithWellKnown); getComponent({ realQueryParams }); - await flushPromises(); - - expect(localStorage.getItem("mx_hs_url")).toEqual(hsUrlFromWk); + await waitFor(() => expect(localStorage.getItem("mx_hs_url")).toEqual(hsUrlFromWk)); }); it("should continue to post login setup when no session is found in local storage", async () => { @@ -1319,8 +1324,10 @@ describe("", () => { screen: "start_sso", }, }); - await flushPromises(); - expect(ssoClient.getSsoLoginUrl).toHaveBeenCalledWith("http://localhost/", "sso", undefined, undefined); + + await waitFor(() => + 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"); @@ -1332,8 +1339,10 @@ describe("", () => { screen: "start_cas", }, }); - await flushPromises(); - expect(ssoClient.getSsoLoginUrl).toHaveBeenCalledWith("http://localhost/", "cas", undefined, undefined); + + await waitFor(() => + 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"); @@ -1397,7 +1406,6 @@ describe("", () => { const client = getMockClientWithEventEmitter(getMockClientMethods()); jest.spyOn(MatrixJs, "createClient").mockReturnValue(client); - client.getProfileInfo.mockResolvedValue({ displayname: "Ernie" }); const rendered = getComponent({}); await waitForSyncAndLoad(client, true); diff --git a/test/components/structures/__snapshots__/MatrixChat-test.tsx.snap b/test/components/structures/__snapshots__/MatrixChat-test.tsx.snap index 3234c45741..71bde418ff 100644 --- a/test/components/structures/__snapshots__/MatrixChat-test.tsx.snap +++ b/test/components/structures/__snapshots__/MatrixChat-test.tsx.snap @@ -117,6 +117,7 @@ exports[` Multi-tab lockout waits for other tab to stop during sta >