mirror of
https://github.com/element-hq/element-web
synced 2024-11-22 17:25:50 +03:00
Expected UTDs: use a different message for UTDs sent before login (#12391)
* Use different messages for UTDs sent before login * Playwright test for historical events * Add some tests * Don't show "verify" message if backup is working * Apply suggestions from code review
This commit is contained in:
parent
700b3955a4
commit
bd7ce7cda9
5 changed files with 175 additions and 20 deletions
|
@ -15,14 +15,17 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
import { test, expect } from "../../element-web-test";
|
import { expect, test } from "../../element-web-test";
|
||||||
import {
|
import {
|
||||||
|
copyAndContinue,
|
||||||
|
createRoom,
|
||||||
createSharedRoomWithUser,
|
createSharedRoomWithUser,
|
||||||
doTwoWaySasVerification,
|
doTwoWaySasVerification,
|
||||||
copyAndContinue,
|
|
||||||
enableKeyBackup,
|
enableKeyBackup,
|
||||||
logIntoElement,
|
logIntoElement,
|
||||||
logOutOfElement,
|
logOutOfElement,
|
||||||
|
sendMessageInCurrentRoom,
|
||||||
|
verifySession,
|
||||||
waitForVerificationRequest,
|
waitForVerificationRequest,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import { Bot } from "../../pages/bot";
|
import { Bot } from "../../pages/bot";
|
||||||
|
@ -453,8 +456,8 @@ test.describe("Cryptography", function () {
|
||||||
// no e2e icon
|
// no e2e icon
|
||||||
await expect(lastTileE2eIcon).not.toBeVisible();
|
await expect(lastTileE2eIcon).not.toBeVisible();
|
||||||
|
|
||||||
// It can take up to 10 seconds for the key to be backed up. We don't really have much option other than
|
// Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for
|
||||||
// to wait :/
|
// the key to be backed up.
|
||||||
await page.waitForTimeout(10000);
|
await page.waitForTimeout(10000);
|
||||||
|
|
||||||
/* log out, and back in */
|
/* log out, and back in */
|
||||||
|
@ -532,4 +535,69 @@ test.describe("Cryptography", function () {
|
||||||
).not.toBeVisible();
|
).not.toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("decryption failure messages", () => {
|
||||||
|
test("should handle device-relative historical messages", async ({
|
||||||
|
homeserver,
|
||||||
|
page,
|
||||||
|
app,
|
||||||
|
credentials,
|
||||||
|
user,
|
||||||
|
cryptoBackend,
|
||||||
|
}) => {
|
||||||
|
test.skip(cryptoBackend === "legacy", "Not implemented for legacy crypto");
|
||||||
|
test.setTimeout(60000);
|
||||||
|
|
||||||
|
// Start with a logged-in session, without key backup, and send a message.
|
||||||
|
await createRoom(page, "Test room", true);
|
||||||
|
await sendMessageInCurrentRoom(page, "test test");
|
||||||
|
|
||||||
|
// Log out, discarding the key for the sent message.
|
||||||
|
await logOutOfElement(page, true);
|
||||||
|
|
||||||
|
// Log in again, and see how the message looks.
|
||||||
|
await logIntoElement(page, homeserver, credentials);
|
||||||
|
await app.viewRoomByName("Test room");
|
||||||
|
const lastTile = page.locator(".mx_EventTile").last();
|
||||||
|
await expect(lastTile).toContainText("Historical messages are not available on this device");
|
||||||
|
await expect(lastTile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
|
||||||
|
|
||||||
|
// Now, we set up key backup, and then send another message.
|
||||||
|
const secretStorageKey = await enableKeyBackup(app);
|
||||||
|
await app.viewRoomByName("Test room");
|
||||||
|
await sendMessageInCurrentRoom(page, "test2 test2");
|
||||||
|
|
||||||
|
// Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for
|
||||||
|
// the key to be backed up.
|
||||||
|
await page.waitForTimeout(10000);
|
||||||
|
|
||||||
|
// Finally, log out again, and back in, skipping verification for now, and see what we see.
|
||||||
|
await logOutOfElement(page);
|
||||||
|
await logIntoElement(page, homeserver, credentials);
|
||||||
|
await page.locator(".mx_AuthPage").getByRole("button", { name: "Skip verification for now" }).click();
|
||||||
|
await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click();
|
||||||
|
await app.viewRoomByName("Test room");
|
||||||
|
|
||||||
|
// There should be two historical events in the timeline
|
||||||
|
const tiles = await page.locator(".mx_EventTile").all();
|
||||||
|
expect(tiles.length).toBeGreaterThanOrEqual(2);
|
||||||
|
// look at the last two tiles only
|
||||||
|
for (const tile of tiles.slice(-2)) {
|
||||||
|
await expect(tile).toContainText("You need to verify this device for access to historical messages");
|
||||||
|
await expect(tile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now verify our device (setting up key backup), and check what happens
|
||||||
|
await verifySession(app, secretStorageKey);
|
||||||
|
const tilesAfterVerify = (await page.locator(".mx_EventTile").all()).slice(-2);
|
||||||
|
|
||||||
|
// The first message still cannot be decrypted, because it was never backed up. It's now a regular UTD though.
|
||||||
|
await expect(tilesAfterVerify[0]).toContainText("Unable to decrypt message");
|
||||||
|
await expect(tilesAfterVerify[0].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
|
||||||
|
|
||||||
|
// The second message should now be decrypted, with a grey shield
|
||||||
|
await expect(tilesAfterVerify[1]).toContainText("test2 test2");
|
||||||
|
await expect(tilesAfterVerify[1].locator(".mx_EventTile_e2eIcon_normal")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,15 +14,15 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Page, expect, JSHandle } from "@playwright/test";
|
import { expect, JSHandle, type Page } from "@playwright/test";
|
||||||
|
|
||||||
import type { CryptoEvent, ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
|
import type { CryptoEvent, ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
import type {
|
import type {
|
||||||
|
EmojiMapping,
|
||||||
|
ShowSasCallbacks,
|
||||||
VerificationRequest,
|
VerificationRequest,
|
||||||
Verifier,
|
Verifier,
|
||||||
EmojiMapping,
|
|
||||||
VerifierEvent,
|
VerifierEvent,
|
||||||
ShowSasCallbacks,
|
|
||||||
} from "matrix-js-sdk/src/crypto-api";
|
} from "matrix-js-sdk/src/crypto-api";
|
||||||
import { Credentials, HomeserverInstance } from "../../plugins/homeserver";
|
import { Credentials, HomeserverInstance } from "../../plugins/homeserver";
|
||||||
import { Client } from "../../pages/client";
|
import { Client } from "../../pages/client";
|
||||||
|
@ -148,7 +148,7 @@ export async function logIntoElement(
|
||||||
// select homeserver
|
// select homeserver
|
||||||
await page.getByRole("button", { name: "Edit" }).click();
|
await page.getByRole("button", { name: "Edit" }).click();
|
||||||
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl);
|
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl);
|
||||||
await page.getByRole("button", { name: "Continue" }).click();
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
|
|
||||||
// wait for the dialog to go away
|
// wait for the dialog to go away
|
||||||
await expect(page.locator(".mx_ServerPickerDialog")).not.toBeVisible();
|
await expect(page.locator(".mx_ServerPickerDialog")).not.toBeVisible();
|
||||||
|
@ -167,15 +167,40 @@ export async function logIntoElement(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logOutOfElement(page: Page) {
|
/**
|
||||||
|
* Click the "sign out" option in Element, and wait for the login page to load
|
||||||
|
*
|
||||||
|
* @param page - Playwright `Page` object.
|
||||||
|
* @param discardKeys - if true, expect a "You'll lose access to your encrypted messages" dialog, and dismiss it.
|
||||||
|
*/
|
||||||
|
export async function logOutOfElement(page: Page, discardKeys: boolean = false) {
|
||||||
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: "Sign out" }).click();
|
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
|
||||||
await page.locator(".mx_Dialog .mx_QuestionDialog").getByRole("button", { name: "Sign out" }).click();
|
if (discardKeys) {
|
||||||
|
await page.getByRole("button", { name: "I don't want my encrypted messages" }).click();
|
||||||
|
} else {
|
||||||
|
await page.locator(".mx_Dialog .mx_QuestionDialog").getByRole("button", { name: "Sign out" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for the login page to load
|
// Wait for the login page to load
|
||||||
await page.getByRole("heading", { name: "Sign in" }).click();
|
await page.getByRole("heading", { name: "Sign in" }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the security settings, and verify the current session using the security key.
|
||||||
|
*
|
||||||
|
* @param app - `ElementAppPage` wrapper for the playwright `Page`.
|
||||||
|
* @param securityKey - The security key (i.e., 4S key), set up during a previous session.
|
||||||
|
*/
|
||||||
|
export async function verifySession(app: ElementAppPage, securityKey: string) {
|
||||||
|
const settings = await app.settings.openUserSettings("Security & Privacy");
|
||||||
|
await settings.getByRole("button", { name: "Verify this session" }).click();
|
||||||
|
await app.page.getByRole("button", { name: "Verify with Security Key" }).click();
|
||||||
|
await app.page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
|
||||||
|
await app.page.getByRole("button", { name: "Continue", disabled: false }).click();
|
||||||
|
await app.page.getByRole("button", { name: "Done" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a SAS verifier for a bot client:
|
* Given a SAS verifier for a bot client:
|
||||||
* - wait for the bot to receive the emojis
|
* - wait for the bot to receive the emojis
|
||||||
|
@ -289,4 +314,9 @@ export async function createRoom(page: Page, roomName: string, isEncrypted: bool
|
||||||
}
|
}
|
||||||
|
|
||||||
await dialog.getByRole("button", { name: "Create room" }).click();
|
await dialog.getByRole("button", { name: "Create room" }).click();
|
||||||
|
|
||||||
|
// Wait for the client to process the encryption event before carrying on (and potentially sending events).
|
||||||
|
if (isEncrypted) {
|
||||||
|
await expect(page.getByText("Encryption enabled")).toBeVisible();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
Copyright 2022-2024 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -14,23 +14,38 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { forwardRef, ForwardRefExoticComponent } from "react";
|
import React, { forwardRef, ForwardRefExoticComponent, useContext } from "react";
|
||||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { IBodyProps } from "./IBodyProps";
|
import { IBodyProps } from "./IBodyProps";
|
||||||
|
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
|
||||||
|
|
||||||
function getErrorMessage(mxEvent?: MatrixEvent): string {
|
function getErrorMessage(mxEvent: MatrixEvent, isVerified: boolean | undefined): string {
|
||||||
return mxEvent?.isEncryptedDisabledForUnverifiedDevices
|
if (mxEvent.isEncryptedDisabledForUnverifiedDevices) return _t("timeline|decryption_failure|blocked");
|
||||||
? _t("timeline|decryption_failure|blocked")
|
switch (mxEvent.decryptionFailureReason) {
|
||||||
: _t("timeline|decryption_failure|unable_to_decrypt");
|
case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP:
|
||||||
|
return _t("timeline|decryption_failure|historical_event_no_key_backup");
|
||||||
|
|
||||||
|
case DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED:
|
||||||
|
if (isVerified === false) {
|
||||||
|
// The user seems to have a key backup, so prompt them to verify in the hope that doing so will
|
||||||
|
// mean we can restore from backup and we'll get the key for this message.
|
||||||
|
return _t("timeline|decryption_failure|historical_event_unverified_device");
|
||||||
|
}
|
||||||
|
// otherwise, use the default.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return _t("timeline|decryption_failure|unable_to_decrypt");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A placeholder element for messages that could not be decrypted
|
// A placeholder element for messages that could not be decrypted
|
||||||
export const DecryptionFailureBody = forwardRef<HTMLDivElement, IBodyProps>(({ mxEvent }, ref): JSX.Element => {
|
export const DecryptionFailureBody = forwardRef<HTMLDivElement, IBodyProps>(({ mxEvent }, ref): React.JSX.Element => {
|
||||||
|
const verificationState = useContext(LocalDeviceVerificationStateContext);
|
||||||
return (
|
return (
|
||||||
<div className="mx_DecryptionFailureBody mx_EventTile_content" ref={ref}>
|
<div className="mx_DecryptionFailureBody mx_EventTile_content" ref={ref}>
|
||||||
{getErrorMessage(mxEvent)}
|
{getErrorMessage(mxEvent, verificationState)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}) as ForwardRefExoticComponent<IBodyProps>;
|
}) as ForwardRefExoticComponent<IBodyProps>;
|
||||||
|
|
|
@ -3214,6 +3214,8 @@
|
||||||
"creation_summary_room": "%(creator)s created and configured the room.",
|
"creation_summary_room": "%(creator)s created and configured the room.",
|
||||||
"decryption_failure": {
|
"decryption_failure": {
|
||||||
"blocked": "The sender has blocked you from receiving this message",
|
"blocked": "The sender has blocked you from receiving this message",
|
||||||
|
"historical_event_no_key_backup": "Historical messages are not available on this device",
|
||||||
|
"historical_event_unverified_device": "You need to verify this device for access to historical messages",
|
||||||
"unable_to_decrypt": "Unable to decrypt message"
|
"unable_to_decrypt": "Unable to decrypt message"
|
||||||
},
|
},
|
||||||
"disambiguated_profile": "%(displayName)s (%(matrixId)s)",
|
"disambiguated_profile": "%(displayName)s (%(matrixId)s)",
|
||||||
|
|
|
@ -17,13 +17,20 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { mkDecryptionFailureMatrixEvent } from "matrix-js-sdk/src/testing";
|
||||||
|
import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
|
||||||
import { mkEvent } from "../../../test-utils";
|
import { mkEvent } from "../../../test-utils";
|
||||||
import { DecryptionFailureBody } from "../../../../src/components/views/messages/DecryptionFailureBody";
|
import { DecryptionFailureBody } from "../../../../src/components/views/messages/DecryptionFailureBody";
|
||||||
|
import { LocalDeviceVerificationStateContext } from "../../../../src/contexts/LocalDeviceVerificationStateContext";
|
||||||
|
|
||||||
describe("DecryptionFailureBody", () => {
|
describe("DecryptionFailureBody", () => {
|
||||||
function customRender(event: MatrixEvent) {
|
function customRender(event: MatrixEvent, localDeviceVerified: boolean = false) {
|
||||||
return render(<DecryptionFailureBody mxEvent={event} />);
|
return render(
|
||||||
|
<LocalDeviceVerificationStateContext.Provider value={localDeviceVerified}>
|
||||||
|
<DecryptionFailureBody mxEvent={event} />
|
||||||
|
</LocalDeviceVerificationStateContext.Provider>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
it(`Should display "Unable to decrypt message"`, () => {
|
it(`Should display "Unable to decrypt message"`, () => {
|
||||||
|
@ -60,4 +67,37 @@ describe("DecryptionFailureBody", () => {
|
||||||
// Then
|
// Then
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should handle historical messages with no key backup", async () => {
|
||||||
|
// When
|
||||||
|
const event = await mkDecryptionFailureMatrixEvent({
|
||||||
|
code: DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP,
|
||||||
|
msg: "No backup",
|
||||||
|
roomId: "fakeroom",
|
||||||
|
sender: "fakesender",
|
||||||
|
});
|
||||||
|
const { container } = customRender(event);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(container).toHaveTextContent("Historical messages are not available on this device");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])(
|
||||||
|
"should handle historical messages when there is a backup and device verification is %s",
|
||||||
|
async (verified) => {
|
||||||
|
// When
|
||||||
|
const event = await mkDecryptionFailureMatrixEvent({
|
||||||
|
code: DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
|
||||||
|
msg: "Failure",
|
||||||
|
roomId: "fakeroom",
|
||||||
|
sender: "fakesender",
|
||||||
|
});
|
||||||
|
const { container } = customRender(event, verified);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(container).toHaveTextContent(
|
||||||
|
verified ? "Unable to decrypt" : "You need to verify this device for access to historical messages",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue