Fix a fresh login creating a new key backup (#12106)
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Co-authored-by: Valere <valeref@matrix.org> fix repeated requests to enter 4S key during cross-signing reset (#12059)
This commit is contained in:
parent
951c0d8483
commit
1a469f41cd
15 changed files with 302 additions and 348 deletions
|
@ -1,57 +0,0 @@
|
||||||
/*
|
|
||||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from "../../element-web-test";
|
|
||||||
|
|
||||||
test.describe("Backups", () => {
|
|
||||||
test.use({
|
|
||||||
displayName: "Hanako",
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Create, delete and recreate a keys backup", async ({ page, user, app }, workerInfo) => {
|
|
||||||
// skipIfLegacyCrypto
|
|
||||||
test.skip(
|
|
||||||
workerInfo.project.name === "Legacy Crypto",
|
|
||||||
"This test only works with Rust crypto. Deleting the backup seems to fail with legacy crypto.",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create a backup
|
|
||||||
const tab = await app.settings.openUserSettings("Security & Privacy");
|
|
||||||
await expect(tab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
|
|
||||||
await tab.getByRole("button", { name: "Set up", exact: true }).click();
|
|
||||||
const dialog = await app.getDialogByTitle("Set up Secure Backup", 60000);
|
|
||||||
await dialog.getByRole("button", { name: "Continue", exact: true }).click();
|
|
||||||
await expect(dialog.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
|
|
||||||
await dialog.getByRole("button", { name: "Copy", exact: true }).click();
|
|
||||||
const securityKey = await app.getClipboard();
|
|
||||||
await dialog.getByRole("button", { name: "Continue", exact: true }).click();
|
|
||||||
await expect(dialog.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
|
|
||||||
await dialog.getByRole("button", { name: "Done", exact: true }).click();
|
|
||||||
|
|
||||||
// Delete it
|
|
||||||
await app.settings.openUserSettings("Security & Privacy");
|
|
||||||
await expect(tab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
|
|
||||||
await tab.getByRole("button", { name: "Delete Backup", exact: true }).click();
|
|
||||||
await dialog.getByTestId("dialog-primary-button").click(); // Click "Delete Backup"
|
|
||||||
|
|
||||||
// Create another
|
|
||||||
await tab.getByRole("button", { name: "Set up", exact: true }).click();
|
|
||||||
dialog.getByLabel("Security Key").fill(securityKey);
|
|
||||||
await dialog.getByRole("button", { name: "Continue", exact: true }).click();
|
|
||||||
await expect(dialog.getByRole("heading", { name: "Success!" })).toBeVisible();
|
|
||||||
await dialog.getByRole("button", { name: "OK", exact: true }).click();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -212,6 +212,43 @@ test.describe("Cryptography", function () {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("Can reset cross-signing keys", async ({ page, app, user: aliceCredentials }) => {
|
||||||
|
const secretStorageKey = await enableKeyBackup(app);
|
||||||
|
|
||||||
|
// Fetch the current cross-signing keys
|
||||||
|
async function fetchMasterKey() {
|
||||||
|
return await test.step("Fetch master key from server", async () => {
|
||||||
|
const k = await app.client.evaluate(async (cli) => {
|
||||||
|
const userId = cli.getUserId();
|
||||||
|
const keys = await cli.downloadKeysForUsers([userId]);
|
||||||
|
return Object.values(keys.master_keys[userId].keys)[0];
|
||||||
|
});
|
||||||
|
console.log(`fetchMasterKey: ${k}`);
|
||||||
|
return k;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const masterKey1 = await fetchMasterKey();
|
||||||
|
|
||||||
|
// Find the "reset cross signing" button, and click it
|
||||||
|
await app.settings.openUserSettings("Security & Privacy");
|
||||||
|
await page.locator("div.mx_CrossSigningPanel_buttonRow").getByRole("button", { name: "Reset" }).click();
|
||||||
|
|
||||||
|
// Confirm
|
||||||
|
await page.getByRole("button", { name: "Clear cross-signing keys" }).click();
|
||||||
|
|
||||||
|
// Enter the 4S key
|
||||||
|
await page.getByPlaceholder("Security Key").fill(secretStorageKey);
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const masterKey2 = await fetchMasterKey();
|
||||||
|
expect(masterKey1).not.toEqual(masterKey2);
|
||||||
|
}).toPass();
|
||||||
|
|
||||||
|
// The dialog should have gone away
|
||||||
|
await expect(page.locator(".mx_Dialog")).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
test("creating a DM should work, being e2e-encrypted / user verification", async ({
|
test("creating a DM should work, being e2e-encrypted / user verification", async ({
|
||||||
page,
|
page,
|
||||||
app,
|
app,
|
||||||
|
|
|
@ -103,12 +103,35 @@ export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise<voi
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check that the current device is connected to the key backup.
|
* Check that the current device is connected to the expected key backup.
|
||||||
|
* Also checks that the decryption key is known and cached locally.
|
||||||
|
*
|
||||||
|
* @param page - the page to check
|
||||||
|
* @param expectedBackupVersion - the version of the backup we expect to be connected to.
|
||||||
|
* @param checkBackupKeyInCache - whether to check that the backup key is cached locally.
|
||||||
*/
|
*/
|
||||||
export async function checkDeviceIsConnectedKeyBackup(page: Page) {
|
export async function checkDeviceIsConnectedKeyBackup(
|
||||||
|
page: Page,
|
||||||
|
expectedBackupVersion: string,
|
||||||
|
checkBackupKeyInCache: boolean,
|
||||||
|
): Promise<void> {
|
||||||
await page.getByRole("button", { name: "User menu" }).click();
|
await page.getByRole("button", { name: "User menu" }).click();
|
||||||
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Security & Privacy" }).click();
|
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Security & Privacy" }).click();
|
||||||
await expect(page.locator(".mx_Dialog").getByRole("button", { name: "Restore from Backup" })).toBeVisible();
|
await expect(page.locator(".mx_Dialog").getByRole("button", { name: "Restore from Backup" })).toBeVisible();
|
||||||
|
|
||||||
|
// expand the advanced section to see the active version in the reports
|
||||||
|
await page.locator(".mx_SecureBackupPanel_advanced").locator("..").click();
|
||||||
|
|
||||||
|
if (checkBackupKeyInCache) {
|
||||||
|
const cacheDecryptionKeyStatusElement = page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(2) td");
|
||||||
|
await expect(cacheDecryptionKeyStatusElement).toHaveText("cached locally, well formed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
|
||||||
|
expectedBackupVersion + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(expectedBackupVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -33,6 +33,9 @@ import { Bot } from "../../pages/bot";
|
||||||
test.describe("Device verification", () => {
|
test.describe("Device verification", () => {
|
||||||
let aliceBotClient: Bot;
|
let aliceBotClient: Bot;
|
||||||
|
|
||||||
|
/** The backup version that was set up by the bot client. */
|
||||||
|
let expectedBackupVersion: string;
|
||||||
|
|
||||||
test.beforeEach(async ({ page, homeserver, credentials }) => {
|
test.beforeEach(async ({ page, homeserver, credentials }) => {
|
||||||
// Visit the login page of the app, to load the matrix sdk
|
// Visit the login page of the app, to load the matrix sdk
|
||||||
await page.goto("/#/login");
|
await page.goto("/#/login");
|
||||||
|
@ -49,9 +52,13 @@ test.describe("Device verification", () => {
|
||||||
bootstrapSecretStorage: true,
|
bootstrapSecretStorage: true,
|
||||||
});
|
});
|
||||||
aliceBotClient.setCredentials(credentials);
|
aliceBotClient.setCredentials(credentials);
|
||||||
await aliceBotClient.prepareClient();
|
const mxClientHandle = await aliceBotClient.prepareClient();
|
||||||
|
|
||||||
await page.waitForTimeout(20000);
|
await page.waitForTimeout(20000);
|
||||||
|
|
||||||
|
expectedBackupVersion = await mxClientHandle.evaluate(async (mxClient) => {
|
||||||
|
return await mxClient.getCrypto()!.getActiveSessionBackupVersion();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click the "Verify with another device" button, and have the bot client auto-accept it.
|
// Click the "Verify with another device" button, and have the bot client auto-accept it.
|
||||||
|
@ -87,7 +94,9 @@ test.describe("Device verification", () => {
|
||||||
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
|
||||||
await checkDeviceIsConnectedKeyBackup(page);
|
// For now we don't check that the backup key is in cache because it's a bit flaky,
|
||||||
|
// as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically.
|
||||||
|
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {
|
test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {
|
||||||
|
@ -130,7 +139,9 @@ test.describe("Device verification", () => {
|
||||||
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
|
||||||
await checkDeviceIsConnectedKeyBackup(page);
|
// For now we don't check that the backup key is in cache because it's a bit flaky,
|
||||||
|
// as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically.
|
||||||
|
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => {
|
test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => {
|
||||||
|
@ -150,7 +161,8 @@ test.describe("Device verification", () => {
|
||||||
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
|
||||||
await checkDeviceIsConnectedKeyBackup(page);
|
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||||
|
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Verify device with Security Key during login", async ({ page, app, credentials, homeserver }) => {
|
test("Verify device with Security Key during login", async ({ page, app, credentials, homeserver }) => {
|
||||||
|
@ -172,7 +184,8 @@ test.describe("Device verification", () => {
|
||||||
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
|
||||||
await checkDeviceIsConnectedKeyBackup(page);
|
// The backup decryption key should be in cache also, as we got it directly from the 4S
|
||||||
|
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => {
|
test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => {
|
||||||
|
|
|
@ -260,7 +260,3 @@ export const expect = baseExpect.extend({
|
||||||
return { pass: true, message: () => "", name: "toMatchScreenshot" };
|
return { pass: true, message: () => "", name: "toMatchScreenshot" };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
test.use({
|
|
||||||
permissions: ["clipboard-read"],
|
|
||||||
});
|
|
||||||
|
|
|
@ -51,19 +51,6 @@ export class ElementAppPage {
|
||||||
return this.settings.closeDialog();
|
return this.settings.closeDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getClipboard(): Promise<string> {
|
|
||||||
return await this.page.evaluate(() => navigator.clipboard.readText());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find an open dialog by its title
|
|
||||||
*/
|
|
||||||
public async getDialogByTitle(title: string, timeout = 5000): Promise<Locator> {
|
|
||||||
const dialog = this.page.locator(".mx_Dialog");
|
|
||||||
await dialog.getByRole("heading", { name: title }).waitFor({ timeout });
|
|
||||||
return dialog;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the given room by name. The room must be visible in the
|
* Opens the given room by name. The room must be visible in the
|
||||||
* room list, but the room list may be folded horizontally, and the
|
* room list, but the room list may be folded horizontally, and the
|
||||||
|
|
|
@ -299,6 +299,28 @@ export async function promptForBackupPassphrase(): Promise<Uint8Array> {
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carry out an operation that may require multiple accesses to secret storage, caching the key.
|
||||||
|
*
|
||||||
|
* Use this helper to wrap an operation that may require multiple accesses to secret storage; the user will be prompted
|
||||||
|
* to enter the 4S key or passphrase on the first access, and the key will be cached for the rest of the operation.
|
||||||
|
*
|
||||||
|
* @param func - The operation to be wrapped.
|
||||||
|
*/
|
||||||
|
export async function withSecretStorageKeyCache<T>(func: () => Promise<T>): Promise<T> {
|
||||||
|
secretStorageBeingAccessed = true;
|
||||||
|
try {
|
||||||
|
return await func();
|
||||||
|
} finally {
|
||||||
|
// Clear secret storage key cache now that work is complete
|
||||||
|
secretStorageBeingAccessed = false;
|
||||||
|
if (!isCachingAllowed()) {
|
||||||
|
secretStorageKeys = {};
|
||||||
|
secretStorageKeyInfo = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This helper should be used whenever you need to access secret storage. It
|
* This helper should be used whenever you need to access secret storage. It
|
||||||
* ensures that secret storage (and also cross-signing since they each depend on
|
* ensures that secret storage (and also cross-signing since they each depend on
|
||||||
|
@ -319,14 +341,13 @@ export async function promptForBackupPassphrase(): Promise<Uint8Array> {
|
||||||
* @param {Function} [func] An operation to perform once secret storage has been
|
* @param {Function} [func] An operation to perform once secret storage has been
|
||||||
* bootstrapped. Optional.
|
* bootstrapped. Optional.
|
||||||
* @param {bool} [forceReset] Reset secret storage even if it's already set up
|
* @param {bool} [forceReset] Reset secret storage even if it's already set up
|
||||||
* @param {bool} [setupNewKeyBackup] Reset secret storage even if it's already set up
|
|
||||||
*/
|
*/
|
||||||
export async function accessSecretStorage(
|
export async function accessSecretStorage(func = async (): Promise<void> => {}, forceReset = false): Promise<void> {
|
||||||
func = async (): Promise<void> => {},
|
await withSecretStorageKeyCache(() => doAccessSecretStorage(func, forceReset));
|
||||||
forceReset = false,
|
}
|
||||||
setupNewKeyBackup = true,
|
|
||||||
): Promise<void> {
|
/** Helper for {@link #accessSecretStorage} */
|
||||||
secretStorageBeingAccessed = true;
|
export async function doAccessSecretStorage(func = async (): Promise<void> => {}, forceReset = false): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cli = MatrixClientPeg.safeGet();
|
const cli = MatrixClientPeg.safeGet();
|
||||||
if (!(await cli.hasSecretStorageKey()) || forceReset) {
|
if (!(await cli.hasSecretStorageKey()) || forceReset) {
|
||||||
|
@ -357,12 +378,7 @@ export async function accessSecretStorage(
|
||||||
throw new Error("Secret storage creation canceled");
|
throw new Error("Secret storage creation canceled");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const crypto = cli.getCrypto();
|
await cli.bootstrapCrossSigning({
|
||||||
if (!crypto) {
|
|
||||||
throw new Error("End-to-end encryption is disabled - unable to access secret storage.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await crypto.bootstrapCrossSigning({
|
|
||||||
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
|
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
|
||||||
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
|
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
|
||||||
title: _t("encryption|bootstrap_title"),
|
title: _t("encryption|bootstrap_title"),
|
||||||
|
@ -375,9 +391,8 @@ export async function accessSecretStorage(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await crypto.bootstrapSecretStorage({
|
await cli.bootstrapSecretStorage({
|
||||||
getKeyBackupPassphrase: promptForBackupPassphrase,
|
getKeyBackupPassphrase: promptForBackupPassphrase,
|
||||||
setupNewKeyBackup,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const keyId = Object.keys(secretStorageKeys)[0];
|
const keyId = Object.keys(secretStorageKeys)[0];
|
||||||
|
@ -403,13 +418,6 @@ export async function accessSecretStorage(
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
// Re-throw so that higher level logic can abort as needed
|
// Re-throw so that higher level logic can abort as needed
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
|
||||||
// Clear secret storage key cache now that work is complete
|
|
||||||
secretStorageBeingAccessed = false;
|
|
||||||
if (!isCachingAllowed()) {
|
|
||||||
secretStorageKeys = {};
|
|
||||||
secretStorageKeyInfo = {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
|
||||||
|
|
||||||
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
|
@ -74,25 +75,24 @@ export default class CreateKeyBackupDialog extends React.PureComponent<IProps, I
|
||||||
this.setState({
|
this.setState({
|
||||||
error: undefined,
|
error: undefined,
|
||||||
});
|
});
|
||||||
|
let info: IKeyBackupInfo | undefined;
|
||||||
const cli = MatrixClientPeg.safeGet();
|
const cli = MatrixClientPeg.safeGet();
|
||||||
try {
|
try {
|
||||||
// We don't want accessSecretStorage to create a backup for us - we
|
await accessSecretStorage(async (): Promise<void> => {
|
||||||
// will create one ourselves in the closure we pass in by calling
|
// `accessSecretStorage` will have bootstrapped secret storage if necessary, so we can now
|
||||||
// resetKeyBackup.
|
// set up key backup.
|
||||||
const setupNewKeyBackup = false;
|
//
|
||||||
const forceReset = false;
|
// XXX: `bootstrapSecretStorage` also sets up key backup as a side effect, so there is a 90% chance
|
||||||
|
// this is actually redundant.
|
||||||
await accessSecretStorage(
|
//
|
||||||
async (): Promise<void> => {
|
// The only time it would *not* be redundant would be if, for some reason, we had working 4S but no
|
||||||
const crypto = cli.getCrypto();
|
// working key backup. (For example, if the user clicked "Delete Backup".)
|
||||||
if (!crypto) {
|
info = await cli.prepareKeyBackupVersion(null /* random key */, {
|
||||||
throw new Error("End-to-end encryption is disabled - unable to create backup.");
|
secureSecretStorage: true,
|
||||||
}
|
});
|
||||||
await crypto.resetKeyBackup();
|
info = await cli.createKeyBackupVersion(info);
|
||||||
},
|
});
|
||||||
forceReset,
|
await cli.scheduleAllGroupSessionsForBackup();
|
||||||
setupNewKeyBackup,
|
|
||||||
);
|
|
||||||
this.setState({
|
this.setState({
|
||||||
phase: Phase.Done,
|
phase: Phase.Done,
|
||||||
});
|
});
|
||||||
|
@ -102,6 +102,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent<IProps, I
|
||||||
// delete the version, disable backup, or do nothing? If we just
|
// delete the version, disable backup, or do nothing? If we just
|
||||||
// disable without deleting, we'll enable on next app reload since
|
// disable without deleting, we'll enable on next app reload since
|
||||||
// it is trusted.
|
// it is trusted.
|
||||||
|
if (info?.version) {
|
||||||
|
cli.deleteKeyBackupVersion(info.version);
|
||||||
|
}
|
||||||
this.setState({
|
this.setState({
|
||||||
error: true,
|
error: true,
|
||||||
});
|
});
|
||||||
|
|
|
@ -26,7 +26,7 @@ import Spinner from "../elements/Spinner";
|
||||||
import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog";
|
import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog";
|
||||||
import ConfirmDestroyCrossSigningDialog from "../dialogs/security/ConfirmDestroyCrossSigningDialog";
|
import ConfirmDestroyCrossSigningDialog from "../dialogs/security/ConfirmDestroyCrossSigningDialog";
|
||||||
import SetupEncryptionDialog from "../dialogs/security/SetupEncryptionDialog";
|
import SetupEncryptionDialog from "../dialogs/security/SetupEncryptionDialog";
|
||||||
import { accessSecretStorage } from "../../../SecurityManager";
|
import { accessSecretStorage, withSecretStorageKeyCache } from "../../../SecurityManager";
|
||||||
import AccessibleButton from "../elements/AccessibleButton";
|
import AccessibleButton from "../elements/AccessibleButton";
|
||||||
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
|
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
|
||||||
|
|
||||||
|
@ -118,31 +118,27 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstrapping cross-signing take one of these paths:
|
* Reset the user's cross-signing keys.
|
||||||
* 1. Create cross-signing keys locally and store in secret storage (if it
|
|
||||||
* already exists on the account).
|
|
||||||
* 2. Access existing secret storage by requesting passphrase and accessing
|
|
||||||
* cross-signing keys as needed.
|
|
||||||
* 3. All keys are loaded and there's nothing to do.
|
|
||||||
* @param {bool} [forceReset] Bootstrap again even if keys already present
|
|
||||||
*/
|
*/
|
||||||
private bootstrapCrossSigning = async ({ forceReset = false }): Promise<void> => {
|
private async resetCrossSigning(): Promise<void> {
|
||||||
this.setState({ error: false });
|
this.setState({ error: false });
|
||||||
try {
|
try {
|
||||||
const cli = MatrixClientPeg.safeGet();
|
const cli = MatrixClientPeg.safeGet();
|
||||||
await cli.bootstrapCrossSigning({
|
await withSecretStorageKeyCache(async () => {
|
||||||
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
|
await cli.getCrypto()!.bootstrapCrossSigning({
|
||||||
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
|
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
|
||||||
title: _t("encryption|bootstrap_title"),
|
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
|
||||||
matrixClient: cli,
|
title: _t("encryption|bootstrap_title"),
|
||||||
makeRequest,
|
matrixClient: cli,
|
||||||
});
|
makeRequest,
|
||||||
const [confirmed] = await finished;
|
});
|
||||||
if (!confirmed) {
|
const [confirmed] = await finished;
|
||||||
throw new Error("Cross-signing key upload auth canceled");
|
if (!confirmed) {
|
||||||
}
|
throw new Error("Cross-signing key upload auth canceled");
|
||||||
},
|
}
|
||||||
setupNewCrossSigning: forceReset,
|
},
|
||||||
|
setupNewCrossSigning: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.setState({ error: true });
|
this.setState({ error: true });
|
||||||
|
@ -150,13 +146,18 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
|
||||||
}
|
}
|
||||||
if (this.unmounted) return;
|
if (this.unmounted) return;
|
||||||
this.getUpdatedStatus();
|
this.getUpdatedStatus();
|
||||||
};
|
}
|
||||||
|
|
||||||
private resetCrossSigning = (): void => {
|
/**
|
||||||
|
* Callback for when the user clicks the "reset cross signing" button.
|
||||||
|
*
|
||||||
|
* Shows a confirmation dialog, and then does the reset if confirmed.
|
||||||
|
*/
|
||||||
|
private onResetCrossSigningClick = (): void => {
|
||||||
Modal.createDialog(ConfirmDestroyCrossSigningDialog, {
|
Modal.createDialog(ConfirmDestroyCrossSigningDialog, {
|
||||||
onFinished: (act) => {
|
onFinished: async (act) => {
|
||||||
if (!act) return;
|
if (!act) return;
|
||||||
this.bootstrapCrossSigning({ forceReset: true });
|
this.resetCrossSigning();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -243,7 +244,7 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
|
||||||
|
|
||||||
if (keysExistAnywhere) {
|
if (keysExistAnywhere) {
|
||||||
actions.push(
|
actions.push(
|
||||||
<AccessibleButton key="reset" kind="danger" onClick={this.resetCrossSigning}>
|
<AccessibleButton key="reset" kind="danger" onClick={this.onResetCrossSigningClick}>
|
||||||
{_t("action|reset")}
|
{_t("action|reset")}
|
||||||
</AccessibleButton>,
|
</AccessibleButton>,
|
||||||
);
|
);
|
||||||
|
@ -260,54 +261,56 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
|
||||||
<details>
|
<details>
|
||||||
<summary className="mx_CrossSigningPanel_advanced">{_t("common|advanced")}</summary>
|
<summary className="mx_CrossSigningPanel_advanced">{_t("common|advanced")}</summary>
|
||||||
<table className="mx_CrossSigningPanel_statusList">
|
<table className="mx_CrossSigningPanel_statusList">
|
||||||
<tr>
|
<tbody>
|
||||||
<th scope="row">{_t("settings|security|cross_signing_public_keys")}</th>
|
<tr>
|
||||||
<td>
|
<th scope="row">{_t("settings|security|cross_signing_public_keys")}</th>
|
||||||
{crossSigningPublicKeysOnDevice
|
<td>
|
||||||
? _t("settings|security|cross_signing_in_memory")
|
{crossSigningPublicKeysOnDevice
|
||||||
: _t("settings|security|cross_signing_not_found")}
|
? _t("settings|security|cross_signing_in_memory")
|
||||||
</td>
|
: _t("settings|security|cross_signing_not_found")}
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th scope="row">{_t("settings|security|cross_signing_private_keys")}</th>
|
<tr>
|
||||||
<td>
|
<th scope="row">{_t("settings|security|cross_signing_private_keys")}</th>
|
||||||
{crossSigningPrivateKeysInStorage
|
<td>
|
||||||
? _t("settings|security|cross_signing_in_4s")
|
{crossSigningPrivateKeysInStorage
|
||||||
: _t("settings|security|cross_signing_not_in_4s")}
|
? _t("settings|security|cross_signing_in_4s")
|
||||||
</td>
|
: _t("settings|security|cross_signing_not_in_4s")}
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th scope="row">{_t("settings|security|cross_signing_master_private_Key")}</th>
|
<tr>
|
||||||
<td>
|
<th scope="row">{_t("settings|security|cross_signing_master_private_Key")}</th>
|
||||||
{masterPrivateKeyCached
|
<td>
|
||||||
? _t("settings|security|cross_signing_cached")
|
{masterPrivateKeyCached
|
||||||
: _t("settings|security|cross_signing_not_cached")}
|
? _t("settings|security|cross_signing_cached")
|
||||||
</td>
|
: _t("settings|security|cross_signing_not_cached")}
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th scope="row">{_t("settings|security|cross_signing_self_signing_private_key")}</th>
|
<tr>
|
||||||
<td>
|
<th scope="row">{_t("settings|security|cross_signing_self_signing_private_key")}</th>
|
||||||
{selfSigningPrivateKeyCached
|
<td>
|
||||||
? _t("settings|security|cross_signing_cached")
|
{selfSigningPrivateKeyCached
|
||||||
: _t("settings|security|cross_signing_not_cached")}
|
? _t("settings|security|cross_signing_cached")
|
||||||
</td>
|
: _t("settings|security|cross_signing_not_cached")}
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th scope="row">{_t("settings|security|cross_signing_user_signing_private_key")}</th>
|
<tr>
|
||||||
<td>
|
<th scope="row">{_t("settings|security|cross_signing_user_signing_private_key")}</th>
|
||||||
{userSigningPrivateKeyCached
|
<td>
|
||||||
? _t("settings|security|cross_signing_cached")
|
{userSigningPrivateKeyCached
|
||||||
: _t("settings|security|cross_signing_not_cached")}
|
? _t("settings|security|cross_signing_cached")
|
||||||
</td>
|
: _t("settings|security|cross_signing_not_cached")}
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th scope="row">{_t("settings|security|cross_signing_homeserver_support")}</th>
|
<tr>
|
||||||
<td>
|
<th scope="row">{_t("settings|security|cross_signing_homeserver_support")}</th>
|
||||||
{homeserverSupportsCrossSigning
|
<td>
|
||||||
? _t("settings|security|cross_signing_homeserver_support_exists")
|
{homeserverSupportsCrossSigning
|
||||||
: _t("settings|security|cross_signing_not_found")}
|
? _t("settings|security|cross_signing_homeserver_support_exists")
|
||||||
</td>
|
: _t("settings|security|cross_signing_not_found")}
|
||||||
</tr>
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</details>
|
</details>
|
||||||
{errorSection}
|
{errorSection}
|
||||||
|
|
|
@ -1,62 +0,0 @@
|
||||||
/*
|
|
||||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mocked } from "jest-mock";
|
|
||||||
import { CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
|
||||||
|
|
||||||
import { accessSecretStorage } from "../src/SecurityManager";
|
|
||||||
import { filterConsole, stubClient } from "./test-utils";
|
|
||||||
|
|
||||||
describe("SecurityManager", () => {
|
|
||||||
describe("accessSecretStorage", () => {
|
|
||||||
filterConsole("Not setting dehydration key: no SSSS key found");
|
|
||||||
|
|
||||||
it("runs the function passed in", async () => {
|
|
||||||
// Given a client
|
|
||||||
const crypto = {
|
|
||||||
bootstrapCrossSigning: () => {},
|
|
||||||
bootstrapSecretStorage: () => {},
|
|
||||||
} as unknown as CryptoApi;
|
|
||||||
const client = stubClient();
|
|
||||||
mocked(client.hasSecretStorageKey).mockResolvedValue(true);
|
|
||||||
mocked(client.getCrypto).mockReturnValue(crypto);
|
|
||||||
|
|
||||||
// When I run accessSecretStorage
|
|
||||||
const func = jest.fn();
|
|
||||||
await accessSecretStorage(func);
|
|
||||||
|
|
||||||
// Then we call the passed-in function
|
|
||||||
expect(func).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("expecting errors", () => {
|
|
||||||
filterConsole("End-to-end encryption is disabled - unable to access secret storage");
|
|
||||||
|
|
||||||
it("throws if crypto is unavailable", async () => {
|
|
||||||
// Given a client with no crypto
|
|
||||||
const client = stubClient();
|
|
||||||
mocked(client.hasSecretStorageKey).mockResolvedValue(true);
|
|
||||||
mocked(client.getCrypto).mockReturnValue(undefined);
|
|
||||||
|
|
||||||
// When I run accessSecretStorage
|
|
||||||
// Then we throw an error
|
|
||||||
await expect(async () => {
|
|
||||||
await accessSecretStorage(jest.fn());
|
|
||||||
}).rejects.toThrow("End-to-end encryption is disabled - unable to access secret storage");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -19,13 +19,11 @@ import React from "react";
|
||||||
import { mocked } from "jest-mock";
|
import { mocked } from "jest-mock";
|
||||||
|
|
||||||
import CreateKeyBackupDialog from "../../../../../src/async-components/views/dialogs/security/CreateKeyBackupDialog";
|
import CreateKeyBackupDialog from "../../../../../src/async-components/views/dialogs/security/CreateKeyBackupDialog";
|
||||||
import { createTestClient, filterConsole } from "../../../../test-utils";
|
import { createTestClient } from "../../../../test-utils";
|
||||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||||
|
|
||||||
jest.mock("../../../../../src/SecurityManager", () => ({
|
jest.mock("../../../../../src/SecurityManager", () => ({
|
||||||
accessSecretStorage: async (func = async () => Promise<void>) => {
|
accessSecretStorage: jest.fn().mockResolvedValue(undefined),
|
||||||
await func();
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("CreateKeyBackupDialog", () => {
|
describe("CreateKeyBackupDialog", () => {
|
||||||
|
@ -41,33 +39,16 @@ describe("CreateKeyBackupDialog", () => {
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("expecting failure", () => {
|
it("should display the error message when backup creation failed", async () => {
|
||||||
filterConsole("Error creating key backup");
|
const matrixClient = createTestClient();
|
||||||
|
mocked(matrixClient.scheduleAllGroupSessionsForBackup).mockRejectedValue("my error");
|
||||||
|
MatrixClientPeg.safeGet = MatrixClientPeg.get = () => matrixClient;
|
||||||
|
|
||||||
it("should display an error message when backup creation failed", async () => {
|
const { asFragment } = render(<CreateKeyBackupDialog onFinished={jest.fn()} />);
|
||||||
const matrixClient = createTestClient();
|
|
||||||
mocked(matrixClient.getCrypto()!.resetKeyBackup).mockImplementation(() => {
|
|
||||||
throw new Error("failed");
|
|
||||||
});
|
|
||||||
MatrixClientPeg.safeGet = MatrixClientPeg.get = () => matrixClient;
|
|
||||||
|
|
||||||
const { asFragment } = render(<CreateKeyBackupDialog onFinished={jest.fn()} />);
|
// Check if the error message is displayed
|
||||||
|
await waitFor(() => expect(screen.getByText("Unable to create key backup")).toBeDefined());
|
||||||
// Check if the error message is displayed
|
expect(asFragment()).toMatchSnapshot();
|
||||||
await waitFor(() => expect(screen.getByText("Unable to create key backup")).toBeDefined());
|
|
||||||
expect(asFragment()).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display an error message when there is no Crypto available", async () => {
|
|
||||||
const matrixClient = createTestClient();
|
|
||||||
mocked(matrixClient.getCrypto).mockReturnValue(undefined);
|
|
||||||
MatrixClientPeg.safeGet = MatrixClientPeg.get = () => matrixClient;
|
|
||||||
|
|
||||||
render(<CreateKeyBackupDialog onFinished={jest.fn()} />);
|
|
||||||
|
|
||||||
// Check if the error message is displayed
|
|
||||||
await waitFor(() => expect(screen.getByText("Unable to create key backup")).toBeDefined());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display the success dialog when the key backup is finished", async () => {
|
it("should display the success dialog when the key backup is finished", async () => {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`CreateKeyBackupDialog expecting failure should display an error message when backup creation failed 1`] = `
|
exports[`CreateKeyBackupDialog should display the error message when backup creation failed 1`] = `
|
||||||
<DocumentFragment>
|
<DocumentFragment>
|
||||||
<div
|
<div
|
||||||
data-focus-guard="true"
|
data-focus-guard="true"
|
||||||
|
|
|
@ -26,6 +26,8 @@ import {
|
||||||
mockClientMethodsCrypto,
|
mockClientMethodsCrypto,
|
||||||
mockClientMethodsUser,
|
mockClientMethodsUser,
|
||||||
} from "../../../test-utils";
|
} from "../../../test-utils";
|
||||||
|
import Modal from "../../../../src/Modal";
|
||||||
|
import ConfirmDestroyCrossSigningDialog from "../../../../src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog";
|
||||||
|
|
||||||
describe("<CrossSigningPanel />", () => {
|
describe("<CrossSigningPanel />", () => {
|
||||||
const userId = "@alice:server.org";
|
const userId = "@alice:server.org";
|
||||||
|
@ -43,6 +45,10 @@ describe("<CrossSigningPanel />", () => {
|
||||||
mockClient.isCrossSigningReady.mockResolvedValue(false);
|
mockClient.isCrossSigningReady.mockResolvedValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("should render a spinner while loading", () => {
|
it("should render a spinner while loading", () => {
|
||||||
getComponent();
|
getComponent();
|
||||||
|
|
||||||
|
@ -85,6 +91,21 @@ describe("<CrossSigningPanel />", () => {
|
||||||
expect(screen.getByTestId("summarised-status").innerHTML).toEqual("✅ Cross-signing is ready for use.");
|
expect(screen.getByTestId("summarised-status").innerHTML).toEqual("✅ Cross-signing is ready for use.");
|
||||||
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
|
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should allow reset of cross-signing", async () => {
|
||||||
|
mockClient.getCrypto()!.bootstrapCrossSigning = jest.fn().mockResolvedValue(undefined);
|
||||||
|
getComponent();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const modalSpy = jest.spyOn(Modal, "createDialog");
|
||||||
|
|
||||||
|
screen.getByRole("button", { name: "Reset" }).click();
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(ConfirmDestroyCrossSigningDialog, expect.any(Object));
|
||||||
|
modalSpy.mock.lastCall![1]!.onFinished(true);
|
||||||
|
expect(mockClient.getCrypto()!.bootstrapCrossSigning).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ setupNewCrossSigning: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when cross signing is not ready", () => {
|
describe("when cross signing is not ready", () => {
|
||||||
|
|
|
@ -175,66 +175,68 @@ exports[`<SecurityUserSettingsTab /> renders security section 1`] = `
|
||||||
<table
|
<table
|
||||||
class="mx_CrossSigningPanel_statusList"
|
class="mx_CrossSigningPanel_statusList"
|
||||||
>
|
>
|
||||||
<tr>
|
<tbody>
|
||||||
<th
|
<tr>
|
||||||
scope="row"
|
<th
|
||||||
>
|
scope="row"
|
||||||
Cross-signing public keys:
|
>
|
||||||
</th>
|
Cross-signing public keys:
|
||||||
<td>
|
</th>
|
||||||
not found
|
<td>
|
||||||
</td>
|
not found
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th
|
<tr>
|
||||||
scope="row"
|
<th
|
||||||
>
|
scope="row"
|
||||||
Cross-signing private keys:
|
>
|
||||||
</th>
|
Cross-signing private keys:
|
||||||
<td>
|
</th>
|
||||||
not found in storage
|
<td>
|
||||||
</td>
|
not found in storage
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th
|
<tr>
|
||||||
scope="row"
|
<th
|
||||||
>
|
scope="row"
|
||||||
Master private key:
|
>
|
||||||
</th>
|
Master private key:
|
||||||
<td>
|
</th>
|
||||||
not found locally
|
<td>
|
||||||
</td>
|
not found locally
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th
|
<tr>
|
||||||
scope="row"
|
<th
|
||||||
>
|
scope="row"
|
||||||
Self signing private key:
|
>
|
||||||
</th>
|
Self signing private key:
|
||||||
<td>
|
</th>
|
||||||
not found locally
|
<td>
|
||||||
</td>
|
not found locally
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th
|
<tr>
|
||||||
scope="row"
|
<th
|
||||||
>
|
scope="row"
|
||||||
User signing private key:
|
>
|
||||||
</th>
|
User signing private key:
|
||||||
<td>
|
</th>
|
||||||
not found locally
|
<td>
|
||||||
</td>
|
not found locally
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<th
|
<tr>
|
||||||
scope="row"
|
<th
|
||||||
>
|
scope="row"
|
||||||
Homeserver feature support:
|
>
|
||||||
</th>
|
Homeserver feature support:
|
||||||
<td>
|
</th>
|
||||||
not found
|
<td>
|
||||||
</td>
|
not found
|
||||||
</tr>
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -132,7 +132,6 @@ export function createTestClient(): MatrixClient {
|
||||||
getUserDeviceInfo: jest.fn(),
|
getUserDeviceInfo: jest.fn(),
|
||||||
getUserVerificationStatus: jest.fn(),
|
getUserVerificationStatus: jest.fn(),
|
||||||
getDeviceVerificationStatus: jest.fn(),
|
getDeviceVerificationStatus: jest.fn(),
|
||||||
resetKeyBackup: jest.fn(),
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getPushActionsForEvent: jest.fn(),
|
getPushActionsForEvent: jest.fn(),
|
||||||
|
|
Loading…
Reference in a new issue