Hubert Chathi aee24be1b4
Key storage out of sync: reset key backup when needed (#31279)
* add function to pause device listener

* add function to check if key backup key missing both locally and in 4s

* reset backup if backup key missing both locally and in 4s

* fixup! add function to check if key backup key missing both locally and in 4s

* Drop KEY_STORAGE_OUT_OF_SYNC_STORE in favour of checking cross-signing

Check if cross-signing needs resetting, because that seems to be what
KEY_STORAGE_OUT_OF_SYNC_STORE is actually trying to do.

* add a function for resetting key backup and waiting until it's ready

* trigger key storage out of sync toast when missing backup key locally

and fetch it when user enters their recovery key

* reset backup when needed if user forgets recovery key

* rename function as suggested in code review
2025-11-20 20:25:31 +00:00

255 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, waitFor } from "jest-matrix-react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { ChangeRecoveryKey } from "../../../../../../src/components/views/settings/encryption/ChangeRecoveryKey";
import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
import { copyPlaintext } from "../../../../../../src/utils/strings";
import Modal from "../../../../../../src/Modal";
import ErrorDialog from "../../../../../../src/components/views/dialogs/ErrorDialog";
import DeviceListener from "../../../../../../src/DeviceListener";
jest.mock("../../../../../../src/utils/strings", () => ({
copyPlaintext: jest.fn(),
}));
afterEach(() => {
jest.restoreAllMocks();
});
describe("<ChangeRecoveryKey />", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = createTestClient();
});
function renderComponent(userHasRecoveryKey = true, onFinish = jest.fn(), onCancelClick = jest.fn()) {
return render(
<ChangeRecoveryKey
userHasRecoveryKey={userHasRecoveryKey}
onFinish={onFinish}
onCancelClick={onCancelClick}
/>,
withClientContextRenderOptions(matrixClient),
);
}
describe("flow to set up a recovery key", () => {
it("should display information about the recovery key", async () => {
const user = userEvent.setup();
const onCancelClick = jest.fn();
const { asFragment } = renderComponent(false, jest.fn(), onCancelClick);
await waitFor(() =>
expect(
screen.getByText(
"Your key storage is protected by a recovery key. If you need a new recovery key after setup, you can recreate it by selecting Change recovery key.",
),
).toBeInTheDocument(),
);
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancelClick).toHaveBeenCalled();
});
it("should display the recovery key", async () => {
const user = userEvent.setup();
const onCancelClick = jest.fn();
const { asFragment } = renderComponent(false, jest.fn(), onCancelClick);
await waitFor(() => user.click(screen.getByRole("button", { name: "Continue" })));
expect(screen.getByText("Save your recovery key somewhere safe")).toBeInTheDocument();
expect(screen.getByText("encoded private key")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
// Test copy button
await user.click(screen.getByRole("button", { name: "Copy" }));
expect(copyPlaintext).toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancelClick).toHaveBeenCalled();
});
it("should ask the user to enter the recovery key", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false);
const user = userEvent.setup();
const onFinish = jest.fn();
const { asFragment } = renderComponent(false, onFinish);
// Display the recovery key to save
await waitFor(() => user.click(screen.getByRole("button", { name: "Continue" })));
// Display the form to confirm the recovery key
await waitFor(() => user.click(screen.getByRole("button", { name: "Continue" })));
await waitFor(() => expect(screen.getByText("Enter your recovery key to confirm")).toBeInTheDocument());
expect(asFragment()).toMatchSnapshot();
// The finish button should be disabled by default
const finishButton = screen.getByRole("button", { name: "Finish set up" });
expect(finishButton).toHaveAttribute("aria-disabled", "true");
const input = screen.getByTitle("Enter recovery key");
// If the user enters an incorrect recovery key, the finish button should be disabled
// and we display an error message
await userEvent.type(input, "wrong recovery key");
expect(finishButton).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("The recovery key you entered is not correct.")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
const setAccountDataSpy = jest.spyOn(matrixClient, "setAccountData");
await userEvent.clear(input);
// If the user enters the correct recovery key, the finish button should be enabled
await userEvent.type(input, "encoded private key");
await waitFor(() => expect(finishButton).not.toHaveAttribute("aria-disabled", "true"));
await user.click(finishButton);
expect(setAccountDataSpy).toHaveBeenCalledWith("io.element.recovery", { enabled: true });
expect(onFinish).toHaveBeenCalledWith();
});
it("should reset key backup if needed", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true);
const user = userEvent.setup();
const onFinish = jest.fn();
renderComponent(false, onFinish);
// Display the recovery key to save
await waitFor(() => user.click(screen.getByRole("button", { name: "Continue" })));
// Display the form to confirm the recovery key
await waitFor(() => user.click(screen.getByRole("button", { name: "Continue" })));
await waitFor(() => expect(screen.getByText("Enter your recovery key to confirm")).toBeInTheDocument());
const finishButton = screen.getByRole("button", { name: "Finish set up" });
const input = screen.getByTitle("Enter recovery key");
// If the user enters the correct recovery key, the finish button should be enabled
await userEvent.type(input, "encoded private key");
await user.click(finishButton);
expect(matrixClient.getCrypto()!.resetKeyBackup).toHaveBeenCalled();
});
it("should not reset key backup if not needed", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false);
const user = userEvent.setup();
const onFinish = jest.fn();
renderComponent(false, onFinish);
// Display the recovery key to save
await waitFor(() => user.click(screen.getByRole("button", { name: "Continue" })));
// Display the form to confirm the recovery key
await waitFor(() => user.click(screen.getByRole("button", { name: "Continue" })));
await waitFor(() => expect(screen.getByText("Enter your recovery key to confirm")).toBeInTheDocument());
const finishButton = screen.getByRole("button", { name: "Finish set up" });
const input = screen.getByTitle("Enter recovery key");
// If the user enters the correct recovery key, the finish button should be enabled
await userEvent.type(input, "encoded private key");
await user.click(finishButton);
expect(matrixClient.getCrypto()!.resetKeyBackup).not.toHaveBeenCalled();
});
it("should display errors from bootstrapSecretStorage", async () => {
const consoleErrorSpy = jest.spyOn(console, "error").mockReturnValue(undefined);
mocked(matrixClient.getCrypto()!).bootstrapSecretStorage.mockRejectedValue(new Error("can't bootstrap"));
const user = userEvent.setup();
const { getByRole, getByText, getByTitle } = renderComponent(false);
// Display the recovery key to save
await waitFor(() => user.click(getByRole("button", { name: "Continue" })));
// Display the form to confirm the recovery key
await waitFor(() => user.click(getByRole("button", { name: "Continue" })));
await waitFor(() => expect(getByText("Enter your recovery key to confirm")).toBeInTheDocument());
const finishButton = getByRole("button", { name: "Finish set up" });
const input = getByTitle("Enter recovery key");
await userEvent.type(input, "encoded private key");
await waitFor(() => {
expect(finishButton).not.toBeDisabled();
});
jest.spyOn(Modal, "createDialog");
await user.click(finishButton);
expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, {
title: "Failed to set up secret storage",
description: "Error: can't bootstrap",
});
await waitFor(() => user.click(getByRole("button", { name: "OK" })));
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to set up secret storage:",
new Error("can't bootstrap"),
);
});
it("should disallow repeated attempts to change the recovery key", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false);
const mockFn = mocked(matrixClient.getCrypto()!).bootstrapSecretStorage.mockImplementation(() => {
// Pretend to do some work.
return new Promise((r) => setTimeout(r, 200));
});
const user = userEvent.setup();
const { getByRole, getByText, getByTitle } = renderComponent(false);
// Display the recovery key to save
await waitFor(() => user.click(getByRole("button", { name: "Continue" })));
// Display the form to confirm the recovery key
await waitFor(() => user.click(getByRole("button", { name: "Continue" })));
await waitFor(() => expect(getByText("Enter your recovery key to confirm")).toBeInTheDocument());
const finishButton = getByRole("button", { name: "Finish set up" });
const input = getByTitle("Enter recovery key");
await userEvent.type(input, "encoded private key");
await waitFor(() => {
expect(finishButton).not.toBeDisabled();
});
await user.click(finishButton);
await user.click(finishButton);
await user.click(finishButton);
await waitFor(() => expect(mockFn).toHaveBeenCalledTimes(1));
});
});
describe("flow to change the recovery key", () => {
it("should display the recovery key", async () => {
const { asFragment } = renderComponent();
await waitFor(() => expect(screen.getByText("Change recovery key?")).toBeInTheDocument());
expect(screen.getByText("encoded private key")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
});
});