Prompt the user when key storage is unexpectedly off (#29912)

* Assert that we set backup_disabled when turning off key storage

* Prompt the user when key storage is unexpectedly off

* Playwright tests for the Turn on key storage toast
This commit is contained in:
Andy Balaam
2025-05-20 13:28:22 +01:00
committed by GitHub
parent 22c7bf346c
commit b539eda4fe
16 changed files with 655 additions and 20 deletions

View File

@@ -24,7 +24,7 @@ import {
} from "matrix-js-sdk/src/crypto-api";
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
import DeviceListener from "../../src/DeviceListener";
import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../src/DeviceListener";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import * as SetupEncryptionToast from "../../src/toasts/SetupEncryptionToast";
import * as UnverifiedSessionToast from "../../src/toasts/UnverifiedSessionToast";
@@ -118,6 +118,7 @@ describe("DeviceListener", () => {
getDeviceId: jest.fn().mockReturnValue(deviceId),
setAccountData: jest.fn(),
getAccountData: jest.fn(),
getAccountDataFromServer: jest.fn(),
deleteAccountData: jest.fn(),
getCrypto: jest.fn().mockReturnValue(mockCrypto),
secretStorage: {
@@ -309,6 +310,8 @@ describe("DeviceListener", () => {
it("hides setup encryption toast when cross signing and secret storage are ready", async () => {
mockCrypto!.isCrossSigningReady.mockResolvedValue(true);
mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
mockCrypto!.getActiveSessionBackupVersion.mockResolvedValue("1");
await createAndStart();
expect(SetupEncryptionToast.hideToast).toHaveBeenCalled();
});
@@ -377,6 +380,7 @@ describe("DeviceListener", () => {
it("hides the out-of-sync toast when one of the secrets is missing", async () => {
mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
mockCrypto!.getActiveSessionBackupVersion.mockResolvedValue("1");
// First show the toast
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
@@ -414,6 +418,7 @@ describe("DeviceListener", () => {
it("shows set up recovery toast when user has a key backup available", async () => {
// non falsy response
mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo);
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue("1");
mockClient.secretStorage.getDefaultKeyId.mockResolvedValue(null);
await createAndStart();
@@ -444,6 +449,9 @@ describe("DeviceListener", () => {
it("dispatches keybackup event when key backup is not enabled", async () => {
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue(null);
mockClient.getAccountDataFromServer.mockImplementation((eventType) =>
eventType === BACKUP_DISABLED_ACCOUNT_DATA_KEY ? ({ disabled: true } as any) : null,
);
await createAndStart();
expect(mockDispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ReportKeyBackupNotEnabled,
@@ -463,6 +471,137 @@ describe("DeviceListener", () => {
});
});
it("sets backup_disabled account data when we call recordKeyBackupDisabled", async () => {
const instance = await createAndStart();
await instance.recordKeyBackupDisabled();
expect(mockClient.setAccountData).toHaveBeenCalledWith("m.org.matrix.custom.backup_disabled", {
disabled: true,
});
});
describe("when crypto is in use and set up", () => {
beforeEach(() => {
// Encryption is in use
mockClient.getRooms.mockReturnValue([{ roomId: "!room1" }, { roomId: "!room2" }] as unknown as Room[]);
jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true);
// The device is verified
mockCrypto.getDeviceVerificationStatus.mockResolvedValue(
new DeviceVerificationStatus({ crossSigningVerified: true }),
);
});
describe("but key storage is off", () => {
beforeEach(() => {
// There is no active key backup/storage
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue(null);
});
it("shows the 'Turn on key storage' toast if we never explicitly turned off key storage", async () => {
// Given key backup is off but the account data saying we turned it off is not set
// (m.org.matrix.custom.backup_disabled)
mockClient.getAccountData.mockReturnValue(undefined);
// When we launch the DeviceListener
await createAndStart();
// Then the toast is displayed
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
});
it("shows the 'Turn on key storage' toast if we turned on key storage", async () => {
// Given key backup is off but the account data says we turned it on (this should not happen - the
// account data should only be updated if we turn on key storage)
mockClient.getAccountData.mockImplementation((eventType) =>
eventType === BACKUP_DISABLED_ACCOUNT_DATA_KEY
? new MatrixEvent({ content: { disabled: false } })
: undefined,
);
// When we launch the DeviceListener
await createAndStart();
// Then the toast is displayed
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
});
it("does not show the 'Turn on key storage' toast if we turned off key storage", async () => {
// Given key backup is off but the account data saying we turned it off is set
mockClient.getAccountDataFromServer.mockImplementation((eventType) =>
eventType === BACKUP_DISABLED_ACCOUNT_DATA_KEY ? ({ disabled: true } as any) : null,
);
// When we launch the DeviceListener
await createAndStart();
// Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
});
});
describe("and key storage is on", () => {
beforeEach(() => {
// There is an active key backup/storage
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue("1");
});
it("does not show the 'Turn on key storage' toast if we never explicitly turned off key storage", async () => {
// Given key backup is on and the account data saying we turned it off is not set
mockClient.getAccountData.mockReturnValue(undefined);
// When we launch the DeviceListener
await createAndStart();
// Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
});
it("does not show the 'Turn on key storage' toast if we turned on key storage", async () => {
// Given key backup is on and the account data says we turned it on
mockClient.getAccountData.mockImplementation((eventType) =>
eventType === BACKUP_DISABLED_ACCOUNT_DATA_KEY
? new MatrixEvent({ content: { disabled: false } })
: undefined,
);
// When we launch the DeviceListener
await createAndStart();
// Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
});
it("does not show the 'Turn on key storage' toast if we turned off key storage", async () => {
// Given key backup is on but the account data saying we turned it off is set (this should never
// happen - it should only be set when we turn off key storage or dismiss the toast)
mockClient.getAccountData.mockImplementation((eventType) =>
eventType === BACKUP_DISABLED_ACCOUNT_DATA_KEY
? new MatrixEvent({ content: { disabled: true } })
: undefined,
);
// When we launch the DeviceListener
await createAndStart();
// Then the toast is not displayed
expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith(
SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE,
);
});
});
});
describe("unverified sessions toasts", () => {
const currentDevice = new Device({ deviceId, userId: userId, algorithms: [], keys: new Map() });
const device2 = new Device({ deviceId: "d2", userId: userId, algorithms: [], keys: new Map() });
@@ -997,6 +1136,8 @@ describe("DeviceListener", () => {
});
it("shows the 'set up recovery' toast if user has not set up 4S", async () => {
mockCrypto!.getActiveSessionBackupVersion.mockResolvedValue("1");
await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(SetupEncryptionToast.Kind.SET_UP_RECOVERY);

View File

@@ -87,5 +87,8 @@ describe("KeyStoragePanelViewModel", () => {
await result.current.setEnabled(false);
expect(mocked(matrixClient.getCrypto()!.disableKeyStorage)).toHaveBeenCalled();
expect(mocked(matrixClient.setAccountData)).toHaveBeenCalledWith("m.org.matrix.custom.backup_disabled", {
disabled: true,
});
});
});

View File

@@ -0,0 +1,40 @@
/*
Copyright 2025 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 } from "jest-matrix-react";
import ConfirmKeyStorageOffDialog from "../../../../../src/components/views/dialogs/ConfirmKeyStorageOffDialog";
describe("ConfirmKeyStorageOffDialog", () => {
beforeEach(() => {
jest.resetAllMocks();
});
it("renders", () => {
const dialog = render(<ConfirmKeyStorageOffDialog onFinished={jest.fn()} />);
expect(dialog.asFragment()).toMatchSnapshot();
});
it("calls onFinished with dismissed=true if we dismiss", () => {
const onFinished = jest.fn();
const dialog = render(<ConfirmKeyStorageOffDialog onFinished={onFinished} />);
dialog.getByRole("button", { name: "Yes, dismiss" }).click();
expect(onFinished).toHaveBeenCalledWith(true);
});
it("calls onFinished with dismissed=true if we continue", () => {
const onFinished = jest.fn();
const dialog = render(<ConfirmKeyStorageOffDialog onFinished={onFinished} />);
dialog.getByRole("button", { name: "Go to Settings" }).click();
expect(onFinished).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,82 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ConfirmKeyStorageOffDialog renders 1`] = `
<DocumentFragment>
<div
class="mx_EncryptionCard"
>
<div
class="mx_EncryptionCard_header"
>
<div
class="_content_o77nw_8 _destructive_o77nw_34"
data-size="large"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20"
/>
</svg>
</div>
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
>
Are you sure you want to keep key storage turned off?
</h2>
</div>
<span>
If you sign out of all your devices you will lose your message history and will need to verify all your existing contacts again.
<br />
<a
href="https://element.io/help#encryption5"
rel="noreferrer noopener"
target="_blank"
>
Learn more
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 3h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2"
/>
<path
d="M15 3h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L17.586 5H15a1 1 0 1 1 0-2"
/>
</svg>
</a>
</span>
<div
class="mx_EncryptionCard_buttons"
>
<button
class="_button_vczzf_8"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Go to Settings
</button>
<button
class="_button_vczzf_8"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
Yes, dismiss
</button>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -14,6 +14,8 @@ import ToastContainer from "../../../src/components/structures/ToastContainer";
import { Kind, showToast } from "../../../src/toasts/SetupEncryptionToast";
import dis from "../../../src/dispatcher/dispatcher";
import DeviceListener from "../../../src/DeviceListener";
import Modal from "../../../src/Modal";
import ConfirmKeyStorageOffDialog from "../../../src/components/views/dialogs/ConfirmKeyStorageOffDialog";
jest.mock("../../../src/dispatcher/dispatcher", () => ({
dispatch: jest.fn(),
@@ -83,4 +85,55 @@ describe("SetupEncryptionToast", () => {
});
});
});
describe("Turn on key storage", () => {
it("should render the toast", async () => {
showToast(Kind.TURN_ON_KEY_STORAGE);
await expect(screen.findByText("Turn on key storage")).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Dismiss" })).resolves.toBeInTheDocument();
await expect(screen.findByRole("button", { name: "Continue" })).resolves.toBeInTheDocument();
});
it("should open settings to the Encryption tab when 'Continue' clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "recordKeyBackupDisabled");
showToast(Kind.TURN_ON_KEY_STORAGE);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Continue" }));
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_user_settings",
initialTabId: "USER_ENCRYPTION_TAB",
});
expect(DeviceListener.sharedInstance().recordKeyBackupDisabled).not.toHaveBeenCalled();
});
it("should open the confirm key storage off dialog when 'Dismiss' clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "recordKeyBackupDisabled");
// Given that as soon as the dialog opens, it closes and says "yes they clicked dismiss"
jest.spyOn(Modal, "createDialog").mockImplementation(() => {
return { finished: Promise.resolve([true]) } as any;
});
// When we show the toast, and click Dismiss
showToast(Kind.TURN_ON_KEY_STORAGE);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Dismiss" }));
// Then the dialog was opened
expect(Modal.createDialog).toHaveBeenCalledWith(
ConfirmKeyStorageOffDialog,
undefined,
"mx_ConfirmKeyStorageOffDialog",
);
// And the backup was disabled when the dialog's onFinished was called
expect(DeviceListener.sharedInstance().recordKeyBackupDisabled).toHaveBeenCalledTimes(1);
});
});
});