Delegate to new ResetIdentityDialog from SetupEncryptionBody (#29701)
This commit is contained in:
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
import { test, expect } from "../../element-web-test";
|
import { test, expect } from "../../element-web-test";
|
||||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||||
import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts";
|
import { createBot, logIntoElement } from "./utils.ts";
|
||||||
import { type Client } from "../../pages/client.ts";
|
import { type Client } from "../../pages/client.ts";
|
||||||
import { type ElementAppPage } from "../../pages/ElementAppPage.ts";
|
import { type ElementAppPage } from "../../pages/ElementAppPage.ts";
|
||||||
|
|
||||||
@@ -28,21 +28,27 @@ test.describe("Dehydration", () => {
|
|||||||
test.skip(isDendrite, "does not yet support dehydration v2");
|
test.skip(isDendrite, "does not yet support dehydration v2");
|
||||||
|
|
||||||
test("Verify device and reset creates dehydrated device", async ({ page, user, credentials, app }, workerInfo) => {
|
test("Verify device and reset creates dehydrated device", async ({ page, user, credentials, app }, workerInfo) => {
|
||||||
// Verify the device by resetting the key (which will create SSSS, and dehydrated device)
|
// Verify the device by resetting the identity key, and then set up recovery (which will create SSSS, and dehydrated device)
|
||||||
|
|
||||||
const securityTab = await app.settings.openUserSettings("Security & Privacy");
|
const securityTab = await app.settings.openUserSettings("Security & Privacy");
|
||||||
await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible();
|
await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible();
|
||||||
|
|
||||||
await app.closeDialog();
|
await app.closeDialog();
|
||||||
|
|
||||||
// Verify the device by resetting the key
|
// Reset the identity key
|
||||||
const settings = await app.settings.openUserSettings("Encryption");
|
const settings = await app.settings.openUserSettings("Encryption");
|
||||||
await settings.getByRole("button", { name: "Verify this device" }).click();
|
await settings.getByRole("button", { name: "Verify this device" }).click();
|
||||||
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||||
await page.getByRole("button", { name: "Continue" }).click();
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
await page.getByRole("button", { name: "Copy" }).click();
|
|
||||||
|
// Set up recovery
|
||||||
|
await page.getByRole("button", { name: "Set up recovery" }).click();
|
||||||
await page.getByRole("button", { name: "Continue" }).click();
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
await page.getByRole("button", { name: "Done" }).click();
|
const recoveryKey = await page.getByTestId("recoveryKey").innerText();
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
await page.getByRole("textbox").fill(recoveryKey);
|
||||||
|
await page.getByRole("button", { name: "Finish set up" }).click();
|
||||||
|
await page.getByRole("button", { name: "Close" }).click();
|
||||||
|
|
||||||
await expectDehydratedDeviceEnabled(app);
|
await expectDehydratedDeviceEnabled(app);
|
||||||
|
|
||||||
@@ -80,7 +86,7 @@ test.describe("Dehydration", () => {
|
|||||||
await expectDehydratedDeviceEnabled(app);
|
await expectDehydratedDeviceEnabled(app);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Reset recovery key during login re-creates dehydrated device", async ({
|
test("Reset identity during login and set up recovery re-creates dehydrated device", async ({
|
||||||
page,
|
page,
|
||||||
homeserver,
|
homeserver,
|
||||||
app,
|
app,
|
||||||
@@ -99,16 +105,26 @@ test.describe("Dehydration", () => {
|
|||||||
// Log in our client
|
// Log in our client
|
||||||
await logIntoElement(page, credentials);
|
await logIntoElement(page, credentials);
|
||||||
|
|
||||||
// Oh no, we forgot our recovery key
|
// Oh no, we forgot our recovery key - reset our identity
|
||||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click();
|
await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click();
|
||||||
await page.locator(".mx_AuthPage").getByRole("button", { name: "Proceed with reset" }).click();
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Are you sure you want to reset your identity?" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
|
await page.getByPlaceholder("Password").fill(credentials.password);
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
|
||||||
await completeCreateSecretStorageDialog(page, { accountPassword: credentials.password });
|
// And set up recovery
|
||||||
|
const settings = await app.settings.openUserSettings("Encryption");
|
||||||
|
await settings.getByRole("button", { name: "Set up recovery" }).click();
|
||||||
|
await settings.getByRole("button", { name: "Continue" }).click();
|
||||||
|
const recoveryKey = await settings.getByTestId("recoveryKey").innerText();
|
||||||
|
await settings.getByRole("button", { name: "Continue" }).click();
|
||||||
|
await settings.getByRole("textbox").fill(recoveryKey);
|
||||||
|
await settings.getByRole("button", { name: "Finish set up" }).click();
|
||||||
|
|
||||||
// There should be a brand new dehydrated device
|
// There should be a brand new dehydrated device
|
||||||
const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client);
|
await expectDehydratedDeviceEnabled(app);
|
||||||
expect(dehydratedDeviceIds.length).toBe(1);
|
|
||||||
expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => {
|
test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => {
|
||||||
|
|||||||
@@ -288,6 +288,43 @@ test.describe("Login", () => {
|
|||||||
await expect(h1).toBeVisible();
|
await expect(h1).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Can reset identity to become verified", async ({ page, homeserver, request, credentials }) => {
|
||||||
|
// Log in
|
||||||
|
const res = await request.post(`${homeserver.baseUrl}/_matrix/client/v3/keys/device_signing/upload`, {
|
||||||
|
headers: { Authorization: `Bearer ${credentials.accessToken}` },
|
||||||
|
data: DEVICE_SIGNING_KEYS_BODY,
|
||||||
|
});
|
||||||
|
if (!res.ok()) {
|
||||||
|
console.log(`Uploading dummy keys failed with HTTP status ${res.status}`, await res.json());
|
||||||
|
throw new Error("Uploading dummy keys failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await login(page, homeserver, credentials);
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
|
||||||
|
|
||||||
|
// Start the reset process
|
||||||
|
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||||
|
|
||||||
|
// First try cancelling and restarting
|
||||||
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||||
|
|
||||||
|
// Then click outside the dialog and restart
|
||||||
|
await page.getByRole("link", { name: "Powered by Matrix" }).click({ force: true });
|
||||||
|
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||||
|
|
||||||
|
// Finally we actually continue
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
await page.getByPlaceholder("Password").fill(credentials.password);
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
|
||||||
|
// We end up at the Home screen
|
||||||
|
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
|
||||||
|
await expect(page.getByRole("heading", { name: "Welcome Dave", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,126 +19,162 @@ import {
|
|||||||
test.describe("Encryption tab", () => {
|
test.describe("Encryption tab", () => {
|
||||||
test.use({ displayName: "Alice" });
|
test.use({ displayName: "Alice" });
|
||||||
|
|
||||||
let recoveryKey: GeneratedSecretStorageKey;
|
test.describe("when encryption is set up", () => {
|
||||||
let expectedBackupVersion: string;
|
let recoveryKey: GeneratedSecretStorageKey;
|
||||||
|
let expectedBackupVersion: string;
|
||||||
|
|
||||||
test.beforeEach(async ({ page, homeserver, credentials }) => {
|
test.beforeEach(async ({ page, homeserver, credentials }) => {
|
||||||
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
|
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
|
||||||
const res = await createBot(page, homeserver, credentials);
|
const res = await createBot(page, homeserver, credentials);
|
||||||
recoveryKey = res.recoveryKey;
|
recoveryKey = res.recoveryKey;
|
||||||
expectedBackupVersion = res.expectedBackupVersion;
|
expectedBackupVersion = res.expectedBackupVersion;
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"should show a 'Verify this device' button if the device is unverified",
|
"should show a 'Verify this device' button if the device is unverified",
|
||||||
{ tag: "@screenshot" },
|
{ tag: "@screenshot" },
|
||||||
async ({ page, app, util }) => {
|
async ({ page, app, util }) => {
|
||||||
const dialog = await util.openEncryptionTab();
|
const dialog = await util.openEncryptionTab();
|
||||||
const content = util.getEncryptionTabContent();
|
const content = util.getEncryptionTabContent();
|
||||||
|
|
||||||
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
|
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
|
||||||
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
|
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
|
||||||
await expect(verifyButton).toBeVisible();
|
await expect(verifyButton).toBeVisible();
|
||||||
await expect(content).toMatchScreenshot("verify-device-encryption-tab.png");
|
await expect(content).toMatchScreenshot("verify-device-encryption-tab.png");
|
||||||
await verifyButton.click();
|
await verifyButton.click();
|
||||||
|
|
||||||
await util.verifyDevice(recoveryKey);
|
await util.verifyDevice(recoveryKey);
|
||||||
|
|
||||||
await expect(content).toMatchScreenshot("default-tab.png", {
|
await expect(content).toMatchScreenshot("default-tab.png", {
|
||||||
mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")],
|
mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check that our device is now cross-signed
|
// Check that our device is now cross-signed
|
||||||
await checkDeviceIsCrossSigned(app);
|
await checkDeviceIsCrossSigned(app);
|
||||||
|
|
||||||
// Check that the current device is connected to key backup
|
// Check that the current device is connected to key backup
|
||||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
|
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
|
||||||
//
|
//
|
||||||
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
|
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
|
||||||
// We simulate this case by deleting the cached secrets in the indexedDB.
|
// We simulate this case by deleting the cached secrets in the indexedDB.
|
||||||
test(
|
test(
|
||||||
"should prompt to enter the recovery key when the secrets are not cached locally",
|
"should prompt to enter the recovery key when the secrets are not cached locally",
|
||||||
{ tag: "@screenshot" },
|
{ tag: "@screenshot" },
|
||||||
async ({ page, app, util }) => {
|
async ({ page, app, util }) => {
|
||||||
|
await verifySession(app, recoveryKey.encodedPrivateKey);
|
||||||
|
// We need to delete the cached secrets
|
||||||
|
await deleteCachedSecrets(page);
|
||||||
|
|
||||||
|
await util.openEncryptionTab();
|
||||||
|
// We ask the user to enter the recovery key
|
||||||
|
const dialog = util.getEncryptionTabContent();
|
||||||
|
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
|
||||||
|
await expect(enterKeyButton).toBeVisible();
|
||||||
|
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
|
||||||
|
await enterKeyButton.click();
|
||||||
|
|
||||||
|
// Fill the recovery key
|
||||||
|
await util.enterRecoveryKey(recoveryKey);
|
||||||
|
await expect(dialog).toMatchScreenshot("default-tab.png", {
|
||||||
|
mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that our device is now cross-signed
|
||||||
|
await checkDeviceIsCrossSigned(app);
|
||||||
|
|
||||||
|
// Check that the current device is connected to key backup
|
||||||
|
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||||
|
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("should display the reset identity panel when the user clicks on 'Forgot recovery key?'", async ({
|
||||||
|
page,
|
||||||
|
app,
|
||||||
|
util,
|
||||||
|
}) => {
|
||||||
await verifySession(app, recoveryKey.encodedPrivateKey);
|
await verifySession(app, recoveryKey.encodedPrivateKey);
|
||||||
// We need to delete the cached secrets
|
// We need to delete the cached secrets
|
||||||
await deleteCachedSecrets(page);
|
await deleteCachedSecrets(page);
|
||||||
|
|
||||||
|
// The "Key storage is out sync" section is displayed and the user click on the "Forgot recovery key?" button
|
||||||
await util.openEncryptionTab();
|
await util.openEncryptionTab();
|
||||||
// We ask the user to enter the recovery key
|
|
||||||
const dialog = util.getEncryptionTabContent();
|
const dialog = util.getEncryptionTabContent();
|
||||||
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
|
await dialog.getByRole("button", { name: "Forgot recovery key?" }).click();
|
||||||
await expect(enterKeyButton).toBeVisible();
|
|
||||||
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
|
|
||||||
await enterKeyButton.click();
|
|
||||||
|
|
||||||
// Fill the recovery key
|
// The user is prompted to reset their identity
|
||||||
await util.enterRecoveryKey(recoveryKey);
|
await expect(
|
||||||
await expect(dialog).toMatchScreenshot("default-tab.png", {
|
dialog.getByText("Forgot your recovery key? You’ll need to reset your identity."),
|
||||||
mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")],
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check that our device is now cross-signed
|
test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => {
|
||||||
await checkDeviceIsCrossSigned(app);
|
await verifySession(app, recoveryKey.encodedPrivateKey);
|
||||||
|
await util.openEncryptionTab();
|
||||||
|
|
||||||
// Check that the current device is connected to key backup
|
await page.getByRole("checkbox", { name: "Allow key storage" }).click();
|
||||||
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
|
||||||
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test("should display the reset identity panel when the user clicks on 'Forgot recovery key?'", async ({
|
await expect(
|
||||||
page,
|
page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }),
|
||||||
app,
|
).toBeVisible();
|
||||||
util,
|
|
||||||
}) => {
|
|
||||||
await verifySession(app, recoveryKey.encodedPrivateKey);
|
|
||||||
// We need to delete the cached secrets
|
|
||||||
await deleteCachedSecrets(page);
|
|
||||||
|
|
||||||
// The "Key storage is out sync" section is displayed and the user click on the "Forgot recovery key?" button
|
await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png");
|
||||||
await util.openEncryptionTab();
|
|
||||||
const dialog = util.getEncryptionTabContent();
|
|
||||||
await dialog.getByRole("button", { name: "Forgot recovery key?" }).click();
|
|
||||||
|
|
||||||
// The user is prompted to reset their identity
|
const deleteRequestPromises = [
|
||||||
await expect(dialog.getByText("Forgot your recovery key? You’ll need to reset your identity.")).toBeVisible();
|
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")),
|
||||||
|
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")),
|
||||||
|
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.user_signing")),
|
||||||
|
page.waitForRequest((req) => req.url().endsWith("/account_data/m.megolm_backup.v1")),
|
||||||
|
page.waitForRequest((req) => req.url().endsWith("/account_data/m.secret_storage.default_key")),
|
||||||
|
page.waitForRequest((req) => req.url().includes("/account_data/m.secret_storage.key.")),
|
||||||
|
];
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Delete key storage" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
|
||||||
|
|
||||||
|
for (const prom of deleteRequestPromises) {
|
||||||
|
const request = await prom;
|
||||||
|
expect(request.method()).toBe("PUT");
|
||||||
|
expect(request.postData()).toBe(JSON.stringify({}));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should warn before turning off key storage", { tag: "@screenshot" }, async ({ page, app, util }) => {
|
test.describe("when encryption is not set up", () => {
|
||||||
await verifySession(app, recoveryKey.encodedPrivateKey);
|
test("'Verify this device' allows us to become verified", async ({
|
||||||
await util.openEncryptionTab();
|
page,
|
||||||
|
user,
|
||||||
|
credentials,
|
||||||
|
app,
|
||||||
|
}, workerInfo) => {
|
||||||
|
const settings = await app.settings.openUserSettings("Encryption");
|
||||||
|
|
||||||
await page.getByRole("checkbox", { name: "Allow key storage" }).click();
|
// Initially, our device is not verified
|
||||||
|
await expect(settings.getByRole("heading", { name: "Device not verified" })).toBeVisible();
|
||||||
|
|
||||||
await expect(
|
// We will reset our identity
|
||||||
page.getByRole("heading", { name: "Are you sure you want to turn off key storage and delete it?" }),
|
await settings.getByRole("button", { name: "Verify this device" }).click();
|
||||||
).toBeVisible();
|
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||||
|
|
||||||
await expect(util.getEncryptionTabContent()).toMatchScreenshot("delete-key-storage-confirm.png");
|
// First try cancelling and restarting
|
||||||
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||||
|
|
||||||
const deleteRequestPromises = [
|
// Then click outside the dialog and restart
|
||||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.master")),
|
await page.locator("li").filter({ hasText: "Encryption" }).click({ force: true });
|
||||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.self_signing")),
|
await page.getByRole("button", { name: "Proceed with reset" }).click();
|
||||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.cross_signing.user_signing")),
|
|
||||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.megolm_backup.v1")),
|
|
||||||
page.waitForRequest((req) => req.url().endsWith("/account_data/m.secret_storage.default_key")),
|
|
||||||
page.waitForRequest((req) => req.url().includes("/account_data/m.secret_storage.key.")),
|
|
||||||
];
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Delete key storage" }).click();
|
// Finally we actually continue
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
|
||||||
await expect(page.getByRole("checkbox", { name: "Allow key storage" })).not.toBeChecked();
|
// Now we are verified, so we see the Key storage toggle
|
||||||
|
await expect(settings.getByRole("heading", { name: "Key storage" })).toBeVisible();
|
||||||
for (const prom of deleteRequestPromises) {
|
});
|
||||||
const request = await prom;
|
|
||||||
expect(request.method()).toBe("PUT");
|
|
||||||
expect(request.postData()).toBe(JSON.stringify({}));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,9 +78,6 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
|
|||||||
} else if (phase === Phase.Busy) {
|
} else if (phase === Phase.Busy) {
|
||||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||||
title = _t("encryption|verification|after_new_login|verify_this_device");
|
title = _t("encryption|verification|after_new_login|verify_this_device");
|
||||||
} else if (phase === Phase.ConfirmReset) {
|
|
||||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
|
||||||
title = _t("encryption|verification|after_new_login|reset_confirmation");
|
|
||||||
} else if (phase === Phase.Finished) {
|
} else if (phase === Phase.Finished) {
|
||||||
// SetupEncryptionBody will take care of calling onFinished, we don't need to do anything
|
// SetupEncryptionBody will take care of calling onFinished, we don't need to do anything
|
||||||
} else {
|
} else {
|
||||||
@@ -90,7 +87,7 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
|
|||||||
const forceVerification = SdkConfig.get("force_verification");
|
const forceVerification = SdkConfig.get("force_verification");
|
||||||
|
|
||||||
let skipButton;
|
let skipButton;
|
||||||
if (!forceVerification && (phase === Phase.Intro || phase === Phase.ConfirmReset)) {
|
if (!forceVerification && phase === Phase.Intro) {
|
||||||
skipButton = (
|
skipButton = (
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
onClick={this.onSkipClick}
|
onClick={this.onSkipClick}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2024 New Vector Ltd.
|
Copyright 2024, 2025 New Vector Ltd.
|
||||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
@@ -19,6 +19,7 @@ import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStor
|
|||||||
import EncryptionPanel from "../../views/right_panel/EncryptionPanel";
|
import EncryptionPanel from "../../views/right_panel/EncryptionPanel";
|
||||||
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
|
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||||
import Spinner from "../../views/elements/Spinner";
|
import Spinner from "../../views/elements/Spinner";
|
||||||
|
import { ResetIdentityDialog } from "../../views/dialogs/ResetIdentityDialog";
|
||||||
|
|
||||||
function keyHasPassphrase(keyInfo: SecretStorageKeyDescription): boolean {
|
function keyHasPassphrase(keyInfo: SecretStorageKeyDescription): boolean {
|
||||||
return Boolean(keyInfo.passphrase && keyInfo.passphrase.salt && keyInfo.passphrase.iterations);
|
return Boolean(keyInfo.passphrase && keyInfo.passphrase.salt && keyInfo.passphrase.iterations);
|
||||||
@@ -112,19 +113,15 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
|||||||
|
|
||||||
private onResetClick = (ev: ButtonEvent): void => {
|
private onResetClick = (ev: ButtonEvent): void => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const store = SetupEncryptionStore.sharedInstance();
|
Modal.createDialog(ResetIdentityDialog, {
|
||||||
store.reset();
|
onReset: () => {
|
||||||
};
|
// The user completed the reset process - close this dialog
|
||||||
|
this.props.onFinished();
|
||||||
private onResetConfirmClick = (): void => {
|
const store = SetupEncryptionStore.sharedInstance();
|
||||||
this.props.onFinished();
|
store.done();
|
||||||
const store = SetupEncryptionStore.sharedInstance();
|
},
|
||||||
store.resetConfirm();
|
variant: "confirm",
|
||||||
};
|
});
|
||||||
|
|
||||||
private onResetBackClick = (): void => {
|
|
||||||
const store = SetupEncryptionStore.sharedInstance();
|
|
||||||
store.returnAfterReset();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private onDoneClick = (): void => {
|
private onDoneClick = (): void => {
|
||||||
@@ -157,7 +154,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
|||||||
<p>{_t("encryption|verification|no_key_or_device")}</p>
|
<p>{_t("encryption|verification|no_key_or_device")}</p>
|
||||||
|
|
||||||
<div className="mx_CompleteSecurity_actionRow">
|
<div className="mx_CompleteSecurity_actionRow">
|
||||||
<AccessibleButton kind="primary" onClick={this.onResetConfirmClick}>
|
<AccessibleButton kind="primary" onClick={this.onResetClick}>
|
||||||
{_t("encryption|verification|reset_proceed_prompt")}
|
{_t("encryption|verification|reset_proceed_prompt")}
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,22 +243,6 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (phase === Phase.ConfirmReset) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>{_t("encryption|verification|verify_reset_warning_1")}</p>
|
|
||||||
<p>{_t("encryption|verification|verify_reset_warning_2")}</p>
|
|
||||||
|
|
||||||
<div className="mx_CompleteSecurity_actionRow">
|
|
||||||
<AccessibleButton kind="danger_outline" onClick={this.onResetConfirmClick}>
|
|
||||||
{_t("encryption|verification|reset_proceed_prompt")}
|
|
||||||
</AccessibleButton>
|
|
||||||
<AccessibleButton kind="primary" onClick={this.onResetBackClick}>
|
|
||||||
{_t("action|go_back")}
|
|
||||||
</AccessibleButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (phase === Phase.Busy || phase === Phase.Loading) {
|
} else if (phase === Phase.Busy || phase === Phase.Loading) {
|
||||||
return <Spinner />;
|
return <Spinner />;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
49
src/components/views/dialogs/ResetIdentityDialog.tsx
Normal file
49
src/components/views/dialogs/ResetIdentityDialog.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* 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, { type JSX } from "react";
|
||||||
|
|
||||||
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
|
import { ResetIdentityBody, type ResetIdentityBodyVariant } from "../settings/encryption/ResetIdentityBody";
|
||||||
|
|
||||||
|
interface ResetIdentityDialogProps {
|
||||||
|
/**
|
||||||
|
* Called when the dialog is complete.
|
||||||
|
*
|
||||||
|
* `ResetIdentityDialog` expects this to be provided by `Modal.createDialog`, and that it will close the dialog.
|
||||||
|
*/
|
||||||
|
onFinished: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the identity is reset (before onFinished is called).
|
||||||
|
*/
|
||||||
|
onReset: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which variant of this dialog to show.
|
||||||
|
*/
|
||||||
|
variant: ResetIdentityBodyVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dialog for resetting the identity of the current user.
|
||||||
|
*/
|
||||||
|
export function ResetIdentityDialog({ onFinished, onReset, variant }: ResetIdentityDialogProps): JSX.Element {
|
||||||
|
const matrixClient = MatrixClientPeg.safeGet();
|
||||||
|
|
||||||
|
const onResetWrapper: () => void = () => {
|
||||||
|
onReset();
|
||||||
|
// Close the dialog
|
||||||
|
onFinished();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<MatrixClientContext.Provider value={matrixClient}>
|
||||||
|
<ResetIdentityBody onReset={onResetWrapper} onCancelClick={onFinished} variant={variant} />
|
||||||
|
</MatrixClientContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { Button, InlineSpinner, VisualList, VisualListItem } from "@vector-im/co
|
|||||||
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
|
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
|
||||||
import InfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info";
|
import InfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info";
|
||||||
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
|
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
|
||||||
import React, { type JSX, useState, type MouseEventHandler } from "react";
|
import React, { type JSX, useState } from "react";
|
||||||
|
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
import { EncryptionCard } from "./EncryptionCard";
|
import { EncryptionCard } from "./EncryptionCard";
|
||||||
@@ -22,7 +22,8 @@ interface ResetIdentityBodyProps {
|
|||||||
/**
|
/**
|
||||||
* Called when the identity is reset.
|
* Called when the identity is reset.
|
||||||
*/
|
*/
|
||||||
onFinish: MouseEventHandler<HTMLButtonElement>;
|
onReset: () => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the cancel button is clicked.
|
* Called when the cancel button is clicked.
|
||||||
*/
|
*/
|
||||||
@@ -36,22 +37,26 @@ interface ResetIdentityBodyProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "compromised" is shown when the user chooses 'reset' explicitly in settings, usually because they believe their
|
* The variant of the panel to show. This affects the message displayed to the user.
|
||||||
* identity has been compromised.
|
*
|
||||||
|
* "compromised" is shown when the user chose 'Reset cryptographic identity' explicitly in settings, usually because
|
||||||
|
* they believe their identity has been compromised.
|
||||||
*
|
*
|
||||||
* "sync_failed" is shown when the user tried to recover their identity but the process failed, probably because
|
* "sync_failed" is shown when the user tried to recover their identity but the process failed, probably because
|
||||||
* the required information is missing from recovery.
|
* the required information is missing from recovery.
|
||||||
*
|
*
|
||||||
* "forgot" is shown when the user has just forgotten their passphrase.
|
* "forgot" is shown when the user chose 'Forgot recovery key?' during `SetupEncryptionToast`.
|
||||||
|
*
|
||||||
|
* "confirm" is shown when the user chose 'Reset all' during `SetupEncryptionBody`.
|
||||||
*/
|
*/
|
||||||
export type ResetIdentityBodyVariant = "compromised" | "forgot" | "sync_failed";
|
export type ResetIdentityBodyVariant = "compromised" | "forgot" | "sync_failed" | "confirm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User interface component allowing the user to reset their cryptographic identity.
|
* User interface component allowing the user to reset their cryptographic identity.
|
||||||
*
|
*
|
||||||
* Used by {@link ResetIdentityPanel}.
|
* Used by {@link ResetIdentityPanel}.
|
||||||
*/
|
*/
|
||||||
export function ResetIdentityBody({ onCancelClick, onFinish, variant }: ResetIdentityBodyProps): JSX.Element {
|
export function ResetIdentityBody({ onCancelClick, onReset, variant }: ResetIdentityBodyProps): JSX.Element {
|
||||||
const matrixClient = useMatrixClientContext();
|
const matrixClient = useMatrixClientContext();
|
||||||
|
|
||||||
// After the user clicks "Continue", we disable the button so it can't be
|
// After the user clicks "Continue", we disable the button so it can't be
|
||||||
@@ -78,12 +83,12 @@ export function ResetIdentityBody({ onCancelClick, onFinish, variant }: ResetIde
|
|||||||
<Button
|
<Button
|
||||||
destructive={true}
|
destructive={true}
|
||||||
disabled={inProgress}
|
disabled={inProgress}
|
||||||
onClick={async (evt) => {
|
onClick={async () => {
|
||||||
setInProgress(true);
|
setInProgress(true);
|
||||||
await matrixClient
|
await matrixClient
|
||||||
.getCrypto()
|
.getCrypto()
|
||||||
?.resetEncryption((makeRequest) => uiAuthCallback(matrixClient, makeRequest));
|
?.resetEncryption((makeRequest) => uiAuthCallback(matrixClient, makeRequest));
|
||||||
onFinish(evt);
|
onReset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{inProgress ? (
|
{inProgress ? (
|
||||||
@@ -113,11 +118,10 @@ export function ResetIdentityBody({ onCancelClick, onFinish, variant }: ResetIde
|
|||||||
function titleForVariant(variant: ResetIdentityBodyVariant): string {
|
function titleForVariant(variant: ResetIdentityBodyVariant): string {
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case "compromised":
|
case "compromised":
|
||||||
|
case "confirm":
|
||||||
return _t("settings|encryption|advanced|breadcrumb_title");
|
return _t("settings|encryption|advanced|breadcrumb_title");
|
||||||
case "sync_failed":
|
case "sync_failed":
|
||||||
return _t("settings|encryption|advanced|breadcrumb_title_sync_failed");
|
return _t("settings|encryption|advanced|breadcrumb_title_sync_failed");
|
||||||
|
|
||||||
default:
|
|
||||||
case "forgot":
|
case "forgot":
|
||||||
return _t("settings|encryption|advanced|breadcrumb_title_forgot");
|
return _t("settings|encryption|advanced|breadcrumb_title_forgot");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Breadcrumb } from "@vector-im/compound-web";
|
import { Breadcrumb } from "@vector-im/compound-web";
|
||||||
import React, { type JSX, type MouseEventHandler } from "react";
|
import React, { type JSX } from "react";
|
||||||
|
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
import { ResetIdentityBody, type ResetIdentityBodyVariant } from "./ResetIdentityBody";
|
import { ResetIdentityBody, type ResetIdentityBodyVariant } from "./ResetIdentityBody";
|
||||||
@@ -15,7 +15,8 @@ interface ResetIdentityPanelProps {
|
|||||||
/**
|
/**
|
||||||
* Called when the identity is reset.
|
* Called when the identity is reset.
|
||||||
*/
|
*/
|
||||||
onFinish: MouseEventHandler<HTMLButtonElement>;
|
onReset: () => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the cancel button is clicked or when we go back in the breadcrumbs.
|
* Called when the cancel button is clicked or when we go back in the breadcrumbs.
|
||||||
*/
|
*/
|
||||||
@@ -32,7 +33,7 @@ interface ResetIdentityPanelProps {
|
|||||||
*
|
*
|
||||||
* A thin wrapper around {@link ResetIdentityBody}, just adding breadcrumbs.
|
* A thin wrapper around {@link ResetIdentityBody}, just adding breadcrumbs.
|
||||||
*/
|
*/
|
||||||
export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetIdentityPanelProps): JSX.Element {
|
export function ResetIdentityPanel({ onCancelClick, onReset, variant }: ResetIdentityPanelProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
@@ -41,7 +42,7 @@ export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetId
|
|||||||
pages={[_t("settings|encryption|title"), _t("settings|encryption|advanced|breadcrumb_page")]}
|
pages={[_t("settings|encryption|title"), _t("settings|encryption|advanced|breadcrumb_page")]}
|
||||||
onPageClick={onCancelClick}
|
onPageClick={onCancelClick}
|
||||||
/>
|
/>
|
||||||
<ResetIdentityBody onFinish={onFinish} onCancelClick={onCancelClick} variant={variant} />
|
<ResetIdentityBody onReset={onReset} onCancelClick={onCancelClick} variant={variant} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Props):
|
|||||||
<ResetIdentityPanel
|
<ResetIdentityPanel
|
||||||
variant={findResetVariant(state)}
|
variant={findResetVariant(state)}
|
||||||
onCancelClick={checkEncryptionState}
|
onCancelClick={checkEncryptionState}
|
||||||
onFinish={checkEncryptionState}
|
onReset={checkEncryptionState}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -993,7 +993,6 @@
|
|||||||
"accepting": "Accepting…",
|
"accepting": "Accepting…",
|
||||||
"after_new_login": {
|
"after_new_login": {
|
||||||
"device_verified": "Device verified",
|
"device_verified": "Device verified",
|
||||||
"reset_confirmation": "Really reset verification keys?",
|
|
||||||
"skip_verification": "Skip verification for now",
|
"skip_verification": "Skip verification for now",
|
||||||
"unable_to_verify": "Unable to verify this device",
|
"unable_to_verify": "Unable to verify this device",
|
||||||
"verify_this_device": "Verify this device"
|
"verify_this_device": "Verify this device"
|
||||||
@@ -1064,8 +1063,6 @@
|
|||||||
"verify_emoji_prompt": "Verify by comparing unique emoji.",
|
"verify_emoji_prompt": "Verify by comparing unique emoji.",
|
||||||
"verify_emoji_prompt_qr": "If you can't scan the code above, verify by comparing unique emoji.",
|
"verify_emoji_prompt_qr": "If you can't scan the code above, verify by comparing unique emoji.",
|
||||||
"verify_later": "I'll verify later",
|
"verify_later": "I'll verify later",
|
||||||
"verify_reset_warning_1": "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.",
|
|
||||||
"verify_reset_warning_2": "Please only proceed if you're sure you've lost all of your other devices and your Recovery Key.",
|
|
||||||
"verify_using_device": "Verify with another device",
|
"verify_using_device": "Verify with another device",
|
||||||
"verify_using_key": "Verify with Recovery Key",
|
"verify_using_key": "Verify with Recovery Key",
|
||||||
"verify_using_key_or_phrase": "Verify with Recovery Key or Phrase",
|
"verify_using_key_or_phrase": "Verify with Recovery Key or Phrase",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export enum Phase {
|
|||||||
Done = 3, // final done stage, but still showing UX
|
Done = 3, // final done stage, but still showing UX
|
||||||
ConfirmSkip = 4,
|
ConfirmSkip = 4,
|
||||||
Finished = 5, // UX can be closed
|
Finished = 5, // UX can be closed
|
||||||
ConfirmReset = 6,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -220,38 +219,6 @@ export class SetupEncryptionStore extends EventEmitter {
|
|||||||
this.emit("update");
|
this.emit("update");
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
|
||||||
this.phase = Phase.ConfirmReset;
|
|
||||||
this.emit("update");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async resetConfirm(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// If we've gotten here, the user presumably lost their
|
|
||||||
// secret storage key if they had one. Start by resetting
|
|
||||||
// secret storage and setting up a new recovery key, then
|
|
||||||
// create new cross-signing keys once that succeeds.
|
|
||||||
await accessSecretStorage(
|
|
||||||
async (): Promise<void> => {
|
|
||||||
this.phase = Phase.Finished;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
forceReset: true,
|
|
||||||
resetCrossSigning: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Error resetting cross-signing", e);
|
|
||||||
this.phase = Phase.Intro;
|
|
||||||
}
|
|
||||||
this.emit("update");
|
|
||||||
}
|
|
||||||
|
|
||||||
public returnAfterReset(): void {
|
|
||||||
this.phase = Phase.Intro;
|
|
||||||
this.emit("update");
|
|
||||||
}
|
|
||||||
|
|
||||||
public done(): void {
|
public done(): void {
|
||||||
this.phase = Phase.Finished;
|
this.phase = Phase.Finished;
|
||||||
this.emit("update");
|
this.emit("update");
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
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 OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { render } from "jest-matrix-react";
|
||||||
|
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
import { type Mocked } from "jest-mock";
|
||||||
|
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import { getMockClientWithEventEmitter } from "../../../../test-utils";
|
||||||
|
import { ResetIdentityDialog } from "../../../../../src/components/views/dialogs/ResetIdentityDialog";
|
||||||
|
|
||||||
|
describe("ResetIdentityDialog", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onReset and onFinished when we click Continue", async () => {
|
||||||
|
const client = mockClient();
|
||||||
|
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
const onReset = jest.fn();
|
||||||
|
const dialog = render(<ResetIdentityDialog onFinished={onFinished} onReset={onReset} variant="compromised" />);
|
||||||
|
|
||||||
|
await act(async () => dialog.getByRole("button", { name: "Continue" }).click());
|
||||||
|
|
||||||
|
expect(onReset).toHaveBeenCalled();
|
||||||
|
expect(onFinished).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(client.getCrypto()?.resetEncryption).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onFinished when we click Cancel", async () => {
|
||||||
|
const client = mockClient();
|
||||||
|
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
const onReset = jest.fn();
|
||||||
|
const dialog = render(<ResetIdentityDialog onFinished={onFinished} onReset={onReset} variant="compromised" />);
|
||||||
|
|
||||||
|
await act(async () => dialog.getByRole("button", { name: "Cancel" }).click());
|
||||||
|
|
||||||
|
expect(onFinished).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(onReset).not.toHaveBeenCalled();
|
||||||
|
expect(client.getCrypto()?.resetEncryption).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockClient(): Mocked<MatrixClient> {
|
||||||
|
const mockCrypto = {
|
||||||
|
resetEncryption: jest.fn().mockResolvedValue(null),
|
||||||
|
} as unknown as Mocked<CryptoApi>;
|
||||||
|
|
||||||
|
return getMockClientWithEventEmitter({
|
||||||
|
getCrypto: jest.fn().mockReturnValue(mockCrypto),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
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 OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { render, screen } from "jest-matrix-react";
|
||||||
|
import { type Mocked } from "jest-mock";
|
||||||
|
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
|
||||||
|
import SetupEncryptionDialog from "../../../../../src/components/views/dialogs/security/SetupEncryptionDialog";
|
||||||
|
import { getMockClientWithEventEmitter } from "../../../../test-utils";
|
||||||
|
import { Phase, SetupEncryptionStore } from "../../../../../src/stores/SetupEncryptionStore";
|
||||||
|
import Modal from "../../../../../src/Modal";
|
||||||
|
|
||||||
|
describe("SetupEncryptionDialog", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should launch a dialog when I say Proceed, then be finished when I reset", async () => {
|
||||||
|
mockClient();
|
||||||
|
const store = new SetupEncryptionStore();
|
||||||
|
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store);
|
||||||
|
|
||||||
|
// Given when you open the reset dialog we immediately reset
|
||||||
|
jest.spyOn(Modal, "createDialog").mockImplementation((_, props) => {
|
||||||
|
// Simulate doing the reset in the dialog
|
||||||
|
props?.onReset();
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: jest.fn(),
|
||||||
|
finished: Promise.resolve([]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// When we launch the dialog and set it ready to start
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
render(<SetupEncryptionDialog onFinished={onFinished} />);
|
||||||
|
await act(async () => await store.fetchKeyInfo());
|
||||||
|
expect(store.phase).toBe(Phase.Intro);
|
||||||
|
|
||||||
|
// And we hit the Proceed with reset button.
|
||||||
|
// (The createDialog mock above simulates the user doing the reset)
|
||||||
|
await act(async () => screen.getByRole("button", { name: "Proceed with reset" }).click());
|
||||||
|
|
||||||
|
// Then the phase has been set to Finished
|
||||||
|
expect(store.phase).toBe(Phase.Finished);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockClient() {
|
||||||
|
const mockCrypto = {
|
||||||
|
getDeviceVerificationStatus: jest.fn().mockResolvedValue({
|
||||||
|
crossSigningVerified: false,
|
||||||
|
}),
|
||||||
|
getUserDeviceInfo: jest.fn().mockResolvedValue(new Map()),
|
||||||
|
isCrossSigningReady: jest.fn().mockResolvedValue(true),
|
||||||
|
isSecretStorageReady: jest.fn().mockResolvedValue(true),
|
||||||
|
userHasCrossSigningKeys: jest.fn(),
|
||||||
|
getActiveSessionBackupVersion: jest.fn(),
|
||||||
|
getCrossSigningStatus: jest.fn().mockReturnValue({
|
||||||
|
publicKeysOnDevice: true,
|
||||||
|
privateKeysInSecretStorage: true,
|
||||||
|
privateKeysCachedLocally: {
|
||||||
|
masterKey: true,
|
||||||
|
selfSigningKey: true,
|
||||||
|
userSigningKey: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getSessionBackupPrivateKey: jest.fn(),
|
||||||
|
isEncryptionEnabledInRoom: jest.fn(),
|
||||||
|
getKeyBackupInfo: jest.fn().mockResolvedValue(null),
|
||||||
|
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
|
||||||
|
} as unknown as Mocked<CryptoApi>;
|
||||||
|
|
||||||
|
const userId = "@user:server";
|
||||||
|
|
||||||
|
getMockClientWithEventEmitter({
|
||||||
|
getCrypto: jest.fn().mockReturnValue(mockCrypto),
|
||||||
|
getUserId: jest.fn().mockReturnValue(userId),
|
||||||
|
secretStorage: { isStored: jest.fn().mockReturnValue({}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen } from "jest-matrix-react";
|
import { act, render, screen } from "jest-matrix-react";
|
||||||
import { mocked } from "jest-mock";
|
import { mocked } from "jest-mock";
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
|
|
||||||
@@ -76,4 +76,20 @@ describe("CompleteSecurity", () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole("button", { name: "Skip verification for now" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Skip verification for now" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Renders a warning if user hits Reset", async () => {
|
||||||
|
// Given a store and a dialog based on it
|
||||||
|
const store = new SetupEncryptionStore();
|
||||||
|
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store);
|
||||||
|
const panel = await act(() => render(<CompleteSecurity onFinished={() => {}} />));
|
||||||
|
|
||||||
|
// When we hit reset
|
||||||
|
await act(async () => panel.getByRole("button", { name: "Proceed with reset" }).click());
|
||||||
|
|
||||||
|
// Then the reset identity dialog appears
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Are you sure you want to reset your identity?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(panel.getByRole("button", { name: "Continue" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ describe("<ResetIdentityPanel />", () => {
|
|||||||
it("should reset the encryption when the continue button is clicked", async () => {
|
it("should reset the encryption when the continue button is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const onFinish = jest.fn();
|
const onReset = jest.fn();
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
<ResetIdentityPanel variant="compromised" onFinish={onFinish} onCancelClick={jest.fn()} />,
|
<ResetIdentityPanel variant="compromised" onReset={onReset} onCancelClick={jest.fn()} />,
|
||||||
withClientContextRenderOptions(matrixClient),
|
withClientContextRenderOptions(matrixClient),
|
||||||
);
|
);
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
@@ -43,22 +43,22 @@ describe("<ResetIdentityPanel />", () => {
|
|||||||
await sleep(0);
|
await sleep(0);
|
||||||
|
|
||||||
expect(matrixClient.getCrypto()!.resetEncryption).toHaveBeenCalled();
|
expect(matrixClient.getCrypto()!.resetEncryption).toHaveBeenCalled();
|
||||||
expect(onFinish).toHaveBeenCalled();
|
expect(onReset).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display the 'forgot recovery key' variant correctly", async () => {
|
it("should display the 'forgot recovery key' variant correctly", async () => {
|
||||||
const onFinish = jest.fn();
|
const onReset = jest.fn();
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
<ResetIdentityPanel variant="forgot" onFinish={onFinish} onCancelClick={jest.fn()} />,
|
<ResetIdentityPanel variant="forgot" onReset={onReset} onCancelClick={jest.fn()} />,
|
||||||
withClientContextRenderOptions(matrixClient),
|
withClientContextRenderOptions(matrixClient),
|
||||||
);
|
);
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display the 'sync failed' variant correctly", async () => {
|
it("should display the 'sync failed' variant correctly", async () => {
|
||||||
const onFinish = jest.fn();
|
const onReset = jest.fn();
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
<ResetIdentityPanel variant="sync_failed" onFinish={onFinish} onCancelClick={jest.fn()} />,
|
<ResetIdentityPanel variant="sync_failed" onReset={onReset} onCancelClick={jest.fn()} />,
|
||||||
withClientContextRenderOptions(matrixClient),
|
withClientContextRenderOptions(matrixClient),
|
||||||
);
|
);
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
import { mocked, type Mocked } from "jest-mock";
|
import { mocked, type Mocked } from "jest-mock";
|
||||||
import { type MatrixClient, Device } from "matrix-js-sdk/src/matrix";
|
import { type MatrixClient, Device } from "matrix-js-sdk/src/matrix";
|
||||||
import { type SecretStorageKeyDescriptionAesV1, type ServerSideSecretStorage } from "matrix-js-sdk/src/secret-storage";
|
import { type SecretStorageKeyDescriptionAesV1, type ServerSideSecretStorage } from "matrix-js-sdk/src/secret-storage";
|
||||||
import { type BootstrapCrossSigningOpts, type CryptoApi, DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
import { type CryptoApi, DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
|
||||||
import { accessSecretStorage } from "../../../src/SecurityManager";
|
import { accessSecretStorage } from "../../../src/SecurityManager";
|
||||||
import { SetupEncryptionStore } from "../../../src/stores/SetupEncryptionStore";
|
import { SetupEncryptionStore } from "../../../src/stores/SetupEncryptionStore";
|
||||||
@@ -152,21 +152,4 @@ describe("SetupEncryptionStore", () => {
|
|||||||
await dehydrationPromise;
|
await dehydrationPromise;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resetConfirm should work with a cached account password", async () => {
|
|
||||||
const makeRequest = jest.fn();
|
|
||||||
mockCrypto.bootstrapCrossSigning.mockImplementation(async (opts: BootstrapCrossSigningOpts) => {
|
|
||||||
await opts?.authUploadDeviceSigningKeys?.(makeRequest);
|
|
||||||
});
|
|
||||||
mocked(accessSecretStorage).mockImplementation(async (func?: () => Promise<void>) => {
|
|
||||||
await func!();
|
|
||||||
});
|
|
||||||
|
|
||||||
await setupEncryptionStore.resetConfirm();
|
|
||||||
|
|
||||||
expect(mocked(accessSecretStorage)).toHaveBeenCalledWith(expect.any(Function), {
|
|
||||||
forceReset: true,
|
|
||||||
resetCrossSigning: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user