From 5e5949257c6b2169fe27276f3982771f3b96e743 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 21 Oct 2024 18:07:03 +0200 Subject: [PATCH 1/7] Hide card tooltip in pinned message e2e test (#28257) --- playwright/e2e/pinned-messages/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/playwright/e2e/pinned-messages/index.ts b/playwright/e2e/pinned-messages/index.ts index ac50b62294b..75928a438bb 100644 --- a/playwright/e2e/pinned-messages/index.ts +++ b/playwright/e2e/pinned-messages/index.ts @@ -196,7 +196,14 @@ export class Helpers { */ async assertEmptyPinnedMessagesList() { const rightPanel = this.getRightPanel(); - await expect(rightPanel).toMatchScreenshot(`pinned-messages-list-empty.png`); + await expect(rightPanel).toMatchScreenshot(`pinned-messages-list-empty.png`, { + css: ` + // hide the tooltip "Room information" to avoid flakiness + [data-floating-ui-portal] { + display: none !important; + } + `, + }); } /** From 3c8ac6fc492feec325b781b4b80fe2e71b71b83a Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 21 Oct 2024 18:45:01 +0100 Subject: [PATCH 2/7] playwright: remove flaky check (#28260) This sometimes happens too quickly for us to test. Fixes: #27585 --- playwright/e2e/crypto/crypto.spec.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts index e3285b898d5..2ab49e72ec9 100644 --- a/playwright/e2e/crypto/crypto.spec.ts +++ b/playwright/e2e/crypto/crypto.spec.ts @@ -114,13 +114,10 @@ test.describe("Cryptography", function () { await dialog.getByRole("button", { name: "Continue" }).click(); await copyAndContinue(page); - // When the device is verified, the `Setting up keys` step is skipped - if (!isDeviceVerified) { - const uiaDialogTitle = page.locator(".mx_InteractiveAuthDialog .mx_Dialog_title"); - await expect(uiaDialogTitle.getByText("Setting up keys")).toBeVisible(); - await expect(uiaDialogTitle.getByText("Setting up keys")).not.toBeVisible(); - } + // If the device is unverified, there should be a "Setting up keys" step; however, it + // can be quite quick, and playwright can miss it, so we can't test for it. + // Either way, we end up at a success dialog: await expect(dialog.getByText("Secure Backup successful")).toBeVisible(); await dialog.getByRole("button", { name: "Done" }).click(); await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible(); From 4a1f86f273f3e65bb41ca807aaf7bb6d3c32cdc3 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 22 Oct 2024 10:09:07 +0200 Subject: [PATCH 3/7] Remove `MatrixClient.getDehydratedDevice` call (#28254) --- src/stores/SetupEncryptionStore.ts | 10 +++----- .../stores/SetupEncryptionStore-test.ts | 23 ------------------- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/src/stores/SetupEncryptionStore.ts b/src/stores/SetupEncryptionStore.ts index 6fafd6efaa2..6b67bcfc492 100644 --- a/src/stores/SetupEncryptionStore.ts +++ b/src/stores/SetupEncryptionStore.ts @@ -101,18 +101,14 @@ export class SetupEncryptionStore extends EventEmitter { this.keyInfo = keys[this.keyId]; } - // do we have any other verified devices which are E2EE which we can verify against? - const dehydratedDevice = await cli.getDehydratedDevice(); const ownUserId = cli.getUserId()!; const crypto = cli.getCrypto()!; + // do we have any other verified devices which are E2EE which we can verify against? const userDevices: Iterable = (await crypto.getUserDeviceInfo([ownUserId])).get(ownUserId)?.values() ?? []; this.hasDevicesToVerifyAgainst = await asyncSome(userDevices, async (device) => { - // Ignore dehydrated devices. `dehydratedDevice` is set by the - // implementation of MSC2697, whereas MSC3814 proposes that devices - // should set a `dehydrated` flag in the device key. We ignore - // both types of dehydrated devices. - if (dehydratedDevice && device.deviceId == dehydratedDevice?.device_id) return false; + // Ignore dehydrated devices. MSC3814 proposes that devices + // should set a `dehydrated` flag in the device key. if (device.dehydrated) return false; // ignore devices without an identity key diff --git a/test/unit-tests/stores/SetupEncryptionStore-test.ts b/test/unit-tests/stores/SetupEncryptionStore-test.ts index a39f3c7a8e0..cc798968550 100644 --- a/test/unit-tests/stores/SetupEncryptionStore-test.ts +++ b/test/unit-tests/stores/SetupEncryptionStore-test.ts @@ -10,7 +10,6 @@ import { mocked, Mocked } from "jest-mock"; import { IBootstrapCrossSigningOpts } from "matrix-js-sdk/src/crypto"; import { MatrixClient, Device } from "matrix-js-sdk/src/matrix"; import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorage } from "matrix-js-sdk/src/secret-storage"; -import { IDehydratedDevice } from "matrix-js-sdk/src/crypto/dehydration"; import { CryptoApi, DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import { SdkContextClass } from "../../../src/contexts/SDKContext"; @@ -97,28 +96,6 @@ describe("SetupEncryptionStore", () => { expect(setupEncryptionStore.hasDevicesToVerifyAgainst).toBe(true); }); - it("should ignore the MSC2697 dehydrated device", async () => { - mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 }); - - client.getDehydratedDevice.mockResolvedValue({ device_id: "dehydrated" } as IDehydratedDevice); - - const fakeDevice = new Device({ - deviceId: "dehydrated", - userId: "", - algorithms: [], - keys: new Map([["curve25519:dehydrated", "identityKey"]]), - }); - mockCrypto.getUserDeviceInfo.mockResolvedValue( - new Map([[client.getSafeUserId(), new Map([[fakeDevice.deviceId, fakeDevice]])]]), - ); - - setupEncryptionStore.start(); - await emitPromise(setupEncryptionStore, "update"); - - expect(setupEncryptionStore.hasDevicesToVerifyAgainst).toBe(false); - expect(mockCrypto.getDeviceVerificationStatus).not.toHaveBeenCalled(); - }); - it("should ignore the MSC3812 dehydrated device", async () => { mockSecretStorage.isStored.mockResolvedValue({ sskeyid: {} as SecretStorageKeyDescriptionAesV1 }); From 1ec2f9261f3043d8176773d25cfaafd5921515a7 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 22 Oct 2024 11:18:30 +0200 Subject: [PATCH 4/7] Replace `IBootstrapCrossSigningOpts` by `BootstrapCrossSigningOpts` to use CryptoApi type instead of old crypto type. (#28263) --- test/unit-tests/stores/SetupEncryptionStore-test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/unit-tests/stores/SetupEncryptionStore-test.ts b/test/unit-tests/stores/SetupEncryptionStore-test.ts index cc798968550..388f1965d74 100644 --- a/test/unit-tests/stores/SetupEncryptionStore-test.ts +++ b/test/unit-tests/stores/SetupEncryptionStore-test.ts @@ -7,10 +7,9 @@ Please see LICENSE files in the repository root for full details. */ import { mocked, Mocked } from "jest-mock"; -import { IBootstrapCrossSigningOpts } from "matrix-js-sdk/src/crypto"; import { MatrixClient, Device } from "matrix-js-sdk/src/matrix"; import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorage } from "matrix-js-sdk/src/secret-storage"; -import { CryptoApi, DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api"; +import { BootstrapCrossSigningOpts, CryptoApi, DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import { SdkContextClass } from "../../../src/contexts/SDKContext"; import { accessSecretStorage } from "../../../src/SecurityManager"; @@ -162,7 +161,7 @@ describe("SetupEncryptionStore", () => { it("resetConfirm should work with a cached account password", async () => { const makeRequest = jest.fn(); - mockCrypto.bootstrapCrossSigning.mockImplementation(async (opts: IBootstrapCrossSigningOpts) => { + mockCrypto.bootstrapCrossSigning.mockImplementation(async (opts: BootstrapCrossSigningOpts) => { await opts?.authUploadDeviceSigningKeys?.(makeRequest); }); mocked(accessSecretStorage).mockImplementation(async (func?: () => Promise) => { From 539025cf8cf7af8fca2b66b00de751ae0979679e Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 22 Oct 2024 12:00:20 +0200 Subject: [PATCH 5/7] Hide scroll to bottom button in pinned message e2e test (#28255) * Hide scroll to bottom button in pinned message e2e test * Remove redundant mask * Update playwright/e2e/pinned-messages/pinned-messages.spec.ts Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --------- Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- .../pinned-messages/pinned-messages.spec.ts | 6 ++++++ .../pinned-message-Msg1-linux.png | Bin 6975 -> 5840 bytes 2 files changed, 6 insertions(+) diff --git a/playwright/e2e/pinned-messages/pinned-messages.spec.ts b/playwright/e2e/pinned-messages/pinned-messages.spec.ts index 9f6f38f1774..d72e8eaec36 100644 --- a/playwright/e2e/pinned-messages/pinned-messages.spec.ts +++ b/playwright/e2e/pinned-messages/pinned-messages.spec.ts @@ -31,6 +31,12 @@ test.describe("Pinned messages", () => { const tile = util.getEventTile("Msg1"); await expect(tile).toMatchScreenshot("pinned-message-Msg1.png", { mask: [tile.locator(".mx_MessageTimestamp")], + css: ` + // Hide the jump to bottom button in the timeline to avoid flakiness + .mx_JumpToBottomButton { + display: none !important; + } + `, }); }); diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png index 2f9c3841ac3ee220e5d16bc1a8df58a5afc7cc5c..97b751ec6a1e1cc98a4cbed75e177efc36e58f00 100644 GIT binary patch literal 5840 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 X-tgb(=|CJ2tdgjJHC~n|y#4eaVh#}g literal 6975 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 From 19ef3267c084809d37bc5bd03ee6d953eb8ad241 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 22 Oct 2024 12:42:07 +0100 Subject: [PATCH 6/7] Refactor CreateCrossSigningDialog (#28218) * Refactor CreateCrossSigningDialog * Converts CreateCrossSigningDialog to a functional component * Pulls logic out to its own class * Updates usage of deprecated cross signing bootstrap method on client to be on the crypto object and updates test to match Moved from https://github.com/element-hq/matrix-react-sdk/pull/131 * Add mock here too * Use the right mock * Remove duplicate mock * Stray jest mock line * Un-move mocks * tsdoc * Typo Co-authored-by: Andy Balaam --------- Co-authored-by: Andy Balaam --- src/CreateCrossSigning.ts | 118 ++++++++++ src/components/structures/MatrixChat.tsx | 1 + src/components/structures/auth/E2eSetup.tsx | 5 +- .../security/CreateCrossSigningDialog.tsx | 216 +++++------------- test/CreateCrossSigning-test.ts | 93 ++++++++ .../CreateCrossSigningDialog-test.tsx | 131 +++++++++++ test/test-utils/test-utils.ts | 1 + .../components/structures/MatrixChat-test.tsx | 2 - 8 files changed, 408 insertions(+), 159 deletions(-) create mode 100644 src/CreateCrossSigning.ts create mode 100644 test/CreateCrossSigning-test.ts create mode 100644 test/components/views/dialogs/security/CreateCrossSigningDialog-test.tsx diff --git a/src/CreateCrossSigning.ts b/src/CreateCrossSigning.ts new file mode 100644 index 00000000000..e67e030f60e --- /dev/null +++ b/src/CreateCrossSigning.ts @@ -0,0 +1,118 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2018, 2019 New Vector Ltd + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { logger } from "matrix-js-sdk/src/logger"; +import { AuthDict, CrossSigningKeys, MatrixClient, MatrixError, UIAFlow, UIAResponse } from "matrix-js-sdk/src/matrix"; + +import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents"; +import Modal from "./Modal"; +import { _t } from "./languageHandler"; +import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog"; + +/** + * Determine if the homeserver allows uploading device keys with only password auth. + * @param cli The Matrix Client to use + * @returns True if the homeserver allows uploading device keys with only password auth, otherwise false + */ +async function canUploadKeysWithPasswordOnly(cli: MatrixClient): Promise { + try { + await cli.uploadDeviceSigningKeys(undefined, {} as CrossSigningKeys); + // We should never get here: the server should always require + // UI auth to upload device signing keys. If we do, we upload + // no keys which would be a no-op. + logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); + return false; + } catch (error) { + if (!(error instanceof MatrixError) || !error.data || !error.data.flows) { + logger.log("uploadDeviceSigningKeys advertised no flows!"); + return false; + } + const canUploadKeysWithPasswordOnly = error.data.flows.some((f: UIAFlow) => { + return f.stages.length === 1 && f.stages[0] === "m.login.password"; + }); + return canUploadKeysWithPasswordOnly; + } +} + +/** + * Ensures that cross signing keys are created and uploaded for the user. + * The homeserver may require user-interactive auth to upload the keys, in + * which case the user will be prompted to authenticate. If the homeserver + * allows uploading keys with just an account password and one is provided, + * the keys will be uploaded without user interaction. + * + * This function does not set up backups of the created cross-signing keys + * (or message keys): the cross-signing keys are stored locally and will be + * lost requiring a crypto reset, if the user logs out or loses their session. + * + * @param cli The Matrix Client to use + * @param isTokenLogin True if the user logged in via a token login, otherwise false + * @param accountPassword The password that the user logged in with + */ +export async function createCrossSigning( + cli: MatrixClient, + isTokenLogin: boolean, + accountPassword?: string, +): Promise { + const cryptoApi = cli.getCrypto(); + if (!cryptoApi) { + throw new Error("No crypto API found!"); + } + + const doBootstrapUIAuth = async ( + makeRequest: (authData: AuthDict) => Promise>, + ): Promise => { + if (accountPassword && (await canUploadKeysWithPasswordOnly(cli))) { + await makeRequest({ + type: "m.login.password", + identifier: { + type: "m.id.user", + user: cli.getUserId(), + }, + password: accountPassword, + }); + } else if (isTokenLogin) { + // We are hoping the grace period is active + await makeRequest({}); + } else { + const dialogAesthetics = { + [SSOAuthEntry.PHASE_PREAUTH]: { + title: _t("auth|uia|sso_title"), + body: _t("auth|uia|sso_preauth_body"), + continueText: _t("auth|sso"), + continueKind: "primary", + }, + [SSOAuthEntry.PHASE_POSTAUTH]: { + title: _t("encryption|confirm_encryption_setup_title"), + body: _t("encryption|confirm_encryption_setup_body"), + continueText: _t("action|confirm"), + continueKind: "primary", + }, + }; + + const { finished } = Modal.createDialog(InteractiveAuthDialog, { + title: _t("encryption|bootstrap_title"), + matrixClient: cli, + makeRequest, + aestheticsForStagePhases: { + [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, + [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, + }, + }); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + } + }; + + await cryptoApi.bootstrapCrossSigning({ + authUploadDeviceSigningKeys: doBootstrapUIAuth, + }); +} diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index c5ef3975a55..d0edcccd4f8 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -2088,6 +2088,7 @@ export default class MatrixChat extends React.PureComponent { } else if (this.state.view === Views.E2E_SETUP) { view = ( void; accountPassword?: string; - tokenLogin?: boolean; + tokenLogin: boolean; } export default class E2eSetup extends React.Component { @@ -24,6 +26,7 @@ export default class E2eSetup extends React.Component { void; } -interface IState { - error: boolean; - canUploadKeysWithPasswordOnly: boolean | null; - accountPassword: string; -} - /* * Walks the user through the process of creating a cross-signing keys. In most * cases, only a spinner is shown, but for more complex auth like SSO, the user * may need to complete some steps to proceed. */ -export default class CreateCrossSigningDialog extends React.PureComponent { - public constructor(props: IProps) { - super(props); +const CreateCrossSigningDialog: React.FC = ({ matrixClient, accountPassword, tokenLogin, onFinished }) => { + const [error, setError] = useState(false); - this.state = { - error: false, - // Does the server offer a UI auth flow with just m.login.password - // for /keys/device_signing/upload? - // If we have an account password in memory, let's simplify and - // assume it means password auth is also supported for device - // signing key upload as well. This avoids hitting the server to - // test auth flows, which may be slow under high load. - canUploadKeysWithPasswordOnly: props.accountPassword ? true : null, - accountPassword: props.accountPassword || "", - }; + const bootstrapCrossSigning = useCallback(async () => { + const cryptoApi = matrixClient.getCrypto(); + if (!cryptoApi) return; - if (!this.state.accountPassword) { - this.queryKeyUploadAuth(); - } - } + setError(false); - public componentDidMount(): void { - this.bootstrapCrossSigning(); - } - - private async queryKeyUploadAuth(): Promise { try { - await MatrixClientPeg.safeGet().uploadDeviceSigningKeys(undefined, {} as CrossSigningKeys); - // We should never get here: the server should always require - // UI auth to upload device signing keys. If we do, we upload - // no keys which would be a no-op. - logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); - } catch (error) { - if (!(error instanceof MatrixError) || !error.data || !error.data.flows) { - logger.log("uploadDeviceSigningKeys advertised no flows!"); - return; - } - const canUploadKeysWithPasswordOnly = error.data.flows.some((f: UIAFlow) => { - return f.stages.length === 1 && f.stages[0] === "m.login.password"; - }); - this.setState({ - canUploadKeysWithPasswordOnly, - }); - } - } - - private doBootstrapUIAuth = async ( - makeRequest: (authData: AuthDict) => Promise>, - ): Promise => { - if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { - await makeRequest({ - type: "m.login.password", - identifier: { - type: "m.id.user", - user: MatrixClientPeg.safeGet().getUserId(), - }, - password: this.state.accountPassword, - }); - } else if (this.props.tokenLogin) { - // We are hoping the grace period is active - await makeRequest({}); - } else { - const dialogAesthetics = { - [SSOAuthEntry.PHASE_PREAUTH]: { - title: _t("auth|uia|sso_title"), - body: _t("auth|uia|sso_preauth_body"), - continueText: _t("auth|sso"), - continueKind: "primary", - }, - [SSOAuthEntry.PHASE_POSTAUTH]: { - title: _t("encryption|confirm_encryption_setup_title"), - body: _t("encryption|confirm_encryption_setup_body"), - continueText: _t("action|confirm"), - continueKind: "primary", - }, - }; - - const { finished } = Modal.createDialog(InteractiveAuthDialog, { - title: _t("encryption|bootstrap_title"), - matrixClient: MatrixClientPeg.safeGet(), - makeRequest, - aestheticsForStagePhases: { - [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, - [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, - }, - }); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Cross-signing key upload auth canceled"); - } - } - }; - - private bootstrapCrossSigning = async (): Promise => { - this.setState({ - error: false, - }); - - try { - const cli = MatrixClientPeg.safeGet(); - await cli.getCrypto()?.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: this.doBootstrapUIAuth, - }); - this.props.onFinished(true); + await createCrossSigning(matrixClient, tokenLogin, accountPassword); + onFinished(true); } catch (e) { - if (this.props.tokenLogin) { + if (tokenLogin) { // ignore any failures, we are relying on grace period here - this.props.onFinished(false); + onFinished(false); return; } - this.setState({ error: true }); + setError(true); logger.error("Error bootstrapping cross-signing", e); } - }; - - private onCancel = (): void => { - this.props.onFinished(false); - }; - - public render(): React.ReactNode { - let content; - if (this.state.error) { - content = ( -
-

{_t("encryption|unable_to_setup_keys_error")}

-
- -
+ }, [matrixClient, tokenLogin, accountPassword, onFinished]); + + const onCancel = useCallback(() => { + onFinished(false); + }, [onFinished]); + + useEffect(() => { + bootstrapCrossSigning(); + }, [bootstrapCrossSigning]); + + let content; + if (error) { + content = ( +
+

{_t("encryption|unable_to_setup_keys_error")}

+
+
- ); - } else { - content = ( -
- -
- ); - } - - return ( - -
{content}
-
+
+ ); + } else { + content = ( +
+ +
); } -} + + return ( + +
{content}
+
+ ); +}; + +export default CreateCrossSigningDialog; diff --git a/test/CreateCrossSigning-test.ts b/test/CreateCrossSigning-test.ts new file mode 100644 index 00000000000..e1762bb5040 --- /dev/null +++ b/test/CreateCrossSigning-test.ts @@ -0,0 +1,93 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2018-2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; +import { mocked } from "jest-mock"; + +import { createCrossSigning } from "../src/CreateCrossSigning"; +import { createTestClient } from "./test-utils"; +import Modal from "../src/Modal"; + +describe("CreateCrossSigning", () => { + let client: MatrixClient; + + beforeEach(() => { + client = createTestClient(); + }); + + it("should call bootstrapCrossSigning with an authUploadDeviceSigningKeys function", async () => { + await createCrossSigning(client, false, "password"); + + expect(client.getCrypto()?.bootstrapCrossSigning).toHaveBeenCalledWith({ + authUploadDeviceSigningKeys: expect.any(Function), + }); + }); + + it("should upload with password auth if possible", async () => { + client.uploadDeviceSigningKeys = jest.fn().mockRejectedValueOnce( + new MatrixError({ + flows: [ + { + stages: ["m.login.password"], + }, + ], + }), + ); + + await createCrossSigning(client, false, "password"); + + const { authUploadDeviceSigningKeys } = mocked(client.getCrypto()!).bootstrapCrossSigning.mock.calls[0][0]; + + const makeRequest = jest.fn(); + await authUploadDeviceSigningKeys!(makeRequest); + expect(makeRequest).toHaveBeenCalledWith({ + type: "m.login.password", + identifier: { + type: "m.id.user", + user: client.getUserId(), + }, + password: "password", + }); + }); + + it("should attempt to upload keys without auth if using token login", async () => { + await createCrossSigning(client, true, undefined); + + const { authUploadDeviceSigningKeys } = mocked(client.getCrypto()!).bootstrapCrossSigning.mock.calls[0][0]; + + const makeRequest = jest.fn(); + await authUploadDeviceSigningKeys!(makeRequest); + expect(makeRequest).toHaveBeenCalledWith({}); + }); + + it("should prompt user if password upload not possible", async () => { + const createDialog = jest.spyOn(Modal, "createDialog").mockReturnValue({ + finished: Promise.resolve([true]), + close: jest.fn(), + }); + + client.uploadDeviceSigningKeys = jest.fn().mockRejectedValueOnce( + new MatrixError({ + flows: [ + { + stages: ["dummy.mystery_flow_nobody_knows"], + }, + ], + }), + ); + + await createCrossSigning(client, false, "password"); + + const { authUploadDeviceSigningKeys } = mocked(client.getCrypto()!).bootstrapCrossSigning.mock.calls[0][0]; + + const makeRequest = jest.fn(); + await authUploadDeviceSigningKeys!(makeRequest); + expect(makeRequest).not.toHaveBeenCalledWith(); + expect(createDialog).toHaveBeenCalled(); + }); +}); diff --git a/test/components/views/dialogs/security/CreateCrossSigningDialog-test.tsx b/test/components/views/dialogs/security/CreateCrossSigningDialog-test.tsx new file mode 100644 index 00000000000..3e5dc4eb94e --- /dev/null +++ b/test/components/views/dialogs/security/CreateCrossSigningDialog-test.tsx @@ -0,0 +1,131 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2018-2022 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { render, screen, waitFor } from "jest-matrix-react"; +import { mocked } from "jest-mock"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { createCrossSigning } from "../../../../../src/CreateCrossSigning"; +import CreateCrossSigningDialog from "../../../../../src/components/views/dialogs/security/CreateCrossSigningDialog"; +import { createTestClient } from "../../../../test-utils"; + +jest.mock("../../../../../src/CreateCrossSigning", () => ({ + createCrossSigning: jest.fn(), +})); + +describe("CreateCrossSigningDialog", () => { + let client: MatrixClient; + let createCrossSigningResolve: () => void; + let createCrossSigningReject: (e: Error) => void; + + beforeEach(() => { + client = createTestClient(); + mocked(createCrossSigning).mockImplementation(() => { + return new Promise((resolve, reject) => { + createCrossSigningResolve = resolve; + createCrossSigningReject = reject; + }); + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it("should call createCrossSigning and show a spinner while it runs", async () => { + const onFinished = jest.fn(); + + render( + , + ); + + expect(createCrossSigning).toHaveBeenCalledWith(client, false, "hunter2"); + expect(screen.getByTestId("spinner")).toBeInTheDocument(); + + createCrossSigningResolve!(); + + await waitFor(() => expect(onFinished).toHaveBeenCalledWith(true)); + }); + + it("should display an error if createCrossSigning fails", async () => { + render( + , + ); + + createCrossSigningReject!(new Error("generic error message")); + + await expect(await screen.findByRole("button", { name: "Retry" })).toBeInTheDocument(); + }); + + it("ignores failures when tokenLogin is true", async () => { + const onFinished = jest.fn(); + + render( + , + ); + + createCrossSigningReject!(new Error("generic error message")); + + await waitFor(() => expect(onFinished).toHaveBeenCalledWith(false)); + }); + + it("cancels the dialog when the cancel button is clicked", async () => { + const onFinished = jest.fn(); + + render( + , + ); + + createCrossSigningReject!(new Error("generic error message")); + + const cancelButton = await screen.findByRole("button", { name: "Cancel" }); + cancelButton.click(); + + expect(onFinished).toHaveBeenCalledWith(false); + }); + + it("should retry when the retry button is clicked", async () => { + render( + , + ); + + createCrossSigningReject!(new Error("generic error message")); + + const retryButton = await screen.findByRole("button", { name: "Retry" }); + retryButton.click(); + + expect(createCrossSigning).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 6dc9533ac94..73ff714ae0f 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -125,6 +125,7 @@ export function createTestClient(): MatrixClient { getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]), setDeviceIsolationMode: jest.fn(), prepareToEncrypt: jest.fn(), + bootstrapCrossSigning: jest.fn(), getActiveSessionBackupVersion: jest.fn().mockResolvedValue(null), }), diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index af0453af2af..7f565d682f5 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -1112,8 +1112,6 @@ describe("", () => { expect(loginClient.getCrypto()!.userHasCrossSigningKeys).toHaveBeenCalled(); - await flushPromises(); - // set up keys screen is rendered expect(screen.getByText("Setting up keys")).toBeInTheDocument(); }); From d4cf3881bc1cfe6e4dba48bdb506120ecfe90fd2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 22 Oct 2024 12:58:45 +0100 Subject: [PATCH 7/7] Switch away from deprecated ReactDOM findDOMNode (#28259) * Remove unused method getVisibleDecryptionFailures Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Switch away from ReactDOM findDOMNode Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/NodeAnimator.tsx | 30 +++++---- src/components/structures/MessagePanel.tsx | 7 +- src/components/structures/ScrollPanel.tsx | 2 +- src/components/structures/TimelinePanel.tsx | 64 ++++--------------- src/components/views/messages/TextualBody.tsx | 19 ++++-- src/components/views/rooms/Autocomplete.tsx | 11 +++- .../structures/TimelinePanel-test.tsx | 25 ++++++++ 7 files changed, 77 insertions(+), 81 deletions(-) diff --git a/src/NodeAnimator.tsx b/src/NodeAnimator.tsx index b5abcd24402..3ca098311fc 100644 --- a/src/NodeAnimator.tsx +++ b/src/NodeAnimator.tsx @@ -6,12 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { Key, MutableRefObject, ReactElement, ReactInstance } from "react"; -import ReactDom from "react-dom"; +import React, { Key, MutableRefObject, ReactElement, RefCallback } from "react"; interface IChildProps { style: React.CSSProperties; - ref: (node: React.ReactInstance) => void; + ref: RefCallback; } interface IProps { @@ -36,7 +35,7 @@ function isReactElement(c: ReturnType<(typeof React.Children)["toArray"]>[number * automatic positional animation, look at react-shuffle or similar libraries. */ export default class NodeAnimator extends React.Component { - private nodes: Record = {}; + private nodes: Record = {}; private children: { [key: string]: ReactElement } = {}; public static defaultProps: Partial = { startStyles: [], @@ -71,10 +70,10 @@ export default class NodeAnimator extends React.Component { if (!isReactElement(c)) return; if (oldChildren[c.key!]) { const old = oldChildren[c.key!]; - const oldNode = ReactDom.findDOMNode(this.nodes[old.key!]); + const oldNode = this.nodes[old.key!]; - if (oldNode && (oldNode as HTMLElement).style.left !== c.props.style.left) { - this.applyStyles(oldNode as HTMLElement, { left: c.props.style.left }); + if (oldNode && oldNode.style.left !== c.props.style.left) { + this.applyStyles(oldNode, { left: c.props.style.left }); } // clone the old element with the props (and children) of the new element // so prop updates are still received by the children. @@ -98,26 +97,29 @@ export default class NodeAnimator extends React.Component { }); } - private collectNode(k: Key, node: React.ReactInstance, restingStyle: React.CSSProperties): void { + private collectNode(k: Key, domNode: HTMLElement | null, restingStyle: React.CSSProperties): void { const key = typeof k === "bigint" ? Number(k) : k; - if (node && this.nodes[key] === undefined && this.props.startStyles.length > 0) { + if (domNode && this.nodes[key] === undefined && this.props.startStyles.length > 0) { const startStyles = this.props.startStyles; - const domNode = ReactDom.findDOMNode(node); // start from startStyle 1: 0 is the one we gave it // to start with, so now we animate 1 etc. for (let i = 1; i < startStyles.length; ++i) { - this.applyStyles(domNode as HTMLElement, startStyles[i]); + this.applyStyles(domNode, startStyles[i]); } // and then we animate to the resting state window.setTimeout(() => { - this.applyStyles(domNode as HTMLElement, restingStyle); + this.applyStyles(domNode, restingStyle); }, 0); } - this.nodes[key] = node; + if (domNode) { + this.nodes[key] = domNode; + } else { + delete this.nodes[key]; + } if (this.props.innerRef) { - this.props.innerRef.current = node; + this.props.innerRef.current = domNode; } } diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 97b8bc9c02d..7383e06f073 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details. */ import React, { createRef, ReactNode, TransitionEvent } from "react"; -import ReactDOM from "react-dom"; import classNames from "classnames"; import { Room, MatrixClient, RoomStateEvent, EventStatus, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; @@ -245,7 +244,7 @@ export default class MessagePanel extends React.Component { private readMarkerNode = createRef(); private whoIsTyping = createRef(); - private scrollPanel = createRef(); + public scrollPanel = createRef(); private readonly showTypingNotificationsWatcherRef: string; private eventTiles: Record = {}; @@ -376,13 +375,13 @@ export default class MessagePanel extends React.Component { // +1: read marker is below the window public getReadMarkerPosition(): number | null { const readMarker = this.readMarkerNode.current; - const messageWrapper = this.scrollPanel.current; + const messageWrapper = this.scrollPanel.current?.divScroll; if (!readMarker || !messageWrapper) { return null; } - const wrapperRect = (ReactDOM.findDOMNode(messageWrapper) as HTMLElement).getBoundingClientRect(); + const wrapperRect = messageWrapper.getBoundingClientRect(); const readMarkerRect = readMarker.getBoundingClientRect(); // the read-marker pretends to have zero height when it is actually diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index 6c0da3018ff..d072c322ce3 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -186,7 +186,7 @@ export default class ScrollPanel extends React.Component { private bottomGrowth!: number; private minListHeight!: number; private heightUpdateInProgress = false; - private divScroll: HTMLDivElement | null = null; + public divScroll: HTMLDivElement | null = null; public constructor(props: IProps) { super(props); diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index b55709e8c28..846fc56d178 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details. */ import React, { createRef, ReactNode } from "react"; -import ReactDOM from "react-dom"; import { Room, RoomEvent, @@ -67,9 +66,6 @@ const READ_RECEIPT_INTERVAL_MS = 500; const READ_MARKER_DEBOUNCE_MS = 100; -// How far off-screen a decryption failure can be for it to still count as "visible" -const VISIBLE_DECRYPTION_FAILURE_MARGIN = 100; - const debuglog = (...args: any[]): void => { if (SettingsStore.getValue("debug_timeline_panel")) { logger.log.call(console, "TimelinePanel debuglog:", ...args); @@ -398,6 +394,10 @@ class TimelinePanel extends React.Component { } } + private get messagePanelDiv(): HTMLDivElement | null { + return this.messagePanel.current?.scrollPanel.current?.divScroll ?? null; + } + /** * Logs out debug info to describe the state of the TimelinePanel and the * events in the room according to the matrix-js-sdk. This is useful when @@ -418,15 +418,12 @@ class TimelinePanel extends React.Component { // And we can suss out any corrupted React `key` problems. let renderedEventIds: string[] | undefined; try { - const messagePanel = this.messagePanel.current; - if (messagePanel) { - const messagePanelNode = ReactDOM.findDOMNode(messagePanel) as Element; - if (messagePanelNode) { - const actuallyRenderedEvents = messagePanelNode.querySelectorAll("[data-event-id]"); - renderedEventIds = [...actuallyRenderedEvents].map((renderedEvent) => { - return renderedEvent.getAttribute("data-event-id")!; - }); - } + const messagePanelNode = this.messagePanelDiv; + if (messagePanelNode) { + const actuallyRenderedEvents = messagePanelNode.querySelectorAll("[data-event-id]"); + renderedEventIds = [...actuallyRenderedEvents].map((renderedEvent) => { + return renderedEvent.getAttribute("data-event-id")!; + }); } } catch (err) { logger.error(`onDumpDebugLogs: Failed to get the actual event ID's in the DOM`, err); @@ -1766,45 +1763,6 @@ class TimelinePanel extends React.Component { return index > -1 ? index : null; } - /** - * Get a list of undecryptable events currently visible on-screen. - * - * @param {boolean} addMargin Whether to add an extra margin beyond the viewport - * where events are still considered "visible" - * - * @returns {MatrixEvent[] | null} A list of undecryptable events, or null if - * the list of events could not be determined. - */ - public getVisibleDecryptionFailures(addMargin?: boolean): MatrixEvent[] | null { - const messagePanel = this.messagePanel.current; - if (!messagePanel) return null; - - const messagePanelNode = ReactDOM.findDOMNode(messagePanel) as Element; - if (!messagePanelNode) return null; // sometimes this happens for fresh rooms/post-sync - const wrapperRect = messagePanelNode.getBoundingClientRect(); - const margin = addMargin ? VISIBLE_DECRYPTION_FAILURE_MARGIN : 0; - const screenTop = wrapperRect.top - margin; - const screenBottom = wrapperRect.bottom + margin; - - const result: MatrixEvent[] = []; - for (const ev of this.state.liveEvents) { - const eventId = ev.getId(); - if (!eventId) continue; - const node = messagePanel.getNodeForEventId(eventId); - if (!node) continue; - - const boundingRect = node.getBoundingClientRect(); - if (boundingRect.top > screenBottom) { - // we have gone past the visible section of timeline - break; - } else if (boundingRect.bottom >= screenTop) { - // the tile for this event is in the visible part of the screen (or just above/below it). - if (ev.isDecryptionFailure()) result.push(ev); - } - } - return result; - } - private getLastDisplayedEventIndex(opts: IEventIndexOpts = {}): number | null { const ignoreOwn = opts.ignoreOwn || false; const allowPartial = opts.allowPartial || false; @@ -1812,7 +1770,7 @@ class TimelinePanel extends React.Component { const messagePanel = this.messagePanel.current; if (!messagePanel) return null; - const messagePanelNode = ReactDOM.findDOMNode(messagePanel) as Element; + const messagePanelNode = this.messagePanelDiv; if (!messagePanelNode) return null; // sometimes this happens for fresh rooms/post-sync const wrapperRect = messagePanelNode.getBoundingClientRect(); const myUserId = MatrixClientPeg.safeGet().credentials.userId; diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 08b79918075..0e0c29747f9 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -52,6 +52,8 @@ export default class TextualBody extends React.Component { private tooltips: Element[] = []; private reactRoots: Element[] = []; + private ref = createRef(); + public static contextType = RoomContext; public declare context: React.ContextType; @@ -84,8 +86,8 @@ export default class TextualBody extends React.Component { if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") { // Handle expansion and add buttons - const pres = (ReactDOM.findDOMNode(this) as Element).getElementsByTagName("pre"); - if (pres.length > 0) { + const pres = this.ref.current?.getElementsByTagName("pre"); + if (pres && pres.length > 0) { for (let i = 0; i < pres.length; i++) { // If there already is a div wrapping the codeblock we want to skip this. // This happens after the codeblock was edited. @@ -477,7 +479,12 @@ export default class TextualBody extends React.Component { if (isEmote) { return ( -
+
{mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()} @@ -490,7 +497,7 @@ export default class TextualBody extends React.Component { } if (isNotice) { return ( -
+
{body} {widgets}
@@ -498,14 +505,14 @@ export default class TextualBody extends React.Component { } if (isCaption) { return ( -
+
{body} {widgets}
); } return ( -
+
{body} {widgets}
diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index db4ac374152..af33fb2d9e0 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { createRef, KeyboardEvent } from "react"; +import React, { createRef, KeyboardEvent, RefObject } from "react"; import classNames from "classnames"; import { flatMap } from "lodash"; import { Room } from "matrix-js-sdk/src/matrix"; @@ -45,6 +45,7 @@ export default class Autocomplete extends React.PureComponent { public queryRequested?: string; public debounceCompletionsRequest?: number; private containerRef = createRef(); + private completionRefs: Record> = {}; public static contextType = RoomContext; public declare context: React.ContextType; @@ -260,7 +261,7 @@ export default class Autocomplete extends React.PureComponent { public componentDidUpdate(prevProps: IProps): void { this.applyNewProps(prevProps.query, prevProps.room); // this is the selected completion, so scroll it into view if needed - const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`] as HTMLElement; + const selectedCompletion = this.completionRefs[`completion${this.state.selectionOffset}`]?.current; if (selectedCompletion) { selectedCompletion.scrollIntoView({ @@ -286,9 +287,13 @@ export default class Autocomplete extends React.PureComponent { this.onCompletionClicked(componentPosition); }; + const refId = `completion${componentPosition}`; + if (!this.completionRefs[refId]) { + this.completionRefs[refId] = createRef(); + } return React.cloneElement(completion.component, { "key": j, - "ref": `completion${componentPosition}`, + "ref": this.completionRefs[refId], "id": generateCompletionDomId(componentPosition - 1), // 0 index the completion IDs className, onClick, diff --git a/test/unit-tests/components/structures/TimelinePanel-test.tsx b/test/unit-tests/components/structures/TimelinePanel-test.tsx index 7de688bfe64..4a663517795 100644 --- a/test/unit-tests/components/structures/TimelinePanel-test.tsx +++ b/test/unit-tests/components/structures/TimelinePanel-test.tsx @@ -41,6 +41,8 @@ import { mkThread } from "../../../test-utils/threads"; import { createMessageEventContent } from "../../../test-utils/events"; import SettingsStore from "../../../../src/settings/SettingsStore"; import ScrollPanel from "../../../../src/components/structures/ScrollPanel"; +import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../src/dispatcher/actions"; // ScrollPanel calls this, but jsdom doesn't mock it for us HTMLDivElement.prototype.scrollBy = () => {}; @@ -1002,4 +1004,27 @@ describe("TimelinePanel", () => { await waitFor(() => expect(screen.queryByRole("progressbar")).toBeNull()); await waitFor(() => expect(container.querySelector(".mx_RoomView_MessageList")).not.toBeEmptyDOMElement()); }); + + it("should dump debug logs on Action.DumpDebugLogs", async () => { + const spy = jest.spyOn(console, "debug"); + + const [, room, events] = setupTestData(); + const eventsPage2 = events.slice(1, 2); + + // Start with only page 2 of the main events in the window + const [, timelineSet] = mkTimeline(room, eventsPage2); + room.getTimelineSets = jest.fn().mockReturnValue([timelineSet]); + + await withScrollPanelMountSpy(async () => { + const { container } = render(); + + await waitFor(() => expectEvents(container, [events[1]])); + }); + + defaultDispatcher.fire(Action.DumpDebugLogs); + + await waitFor(() => + expect(spy).toHaveBeenCalledWith(expect.stringContaining("TimelinePanel(Room): Debugging info for roomId")), + ); + }); });