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:
Richard van der Hoff 2024-04-25 09:11:41 +01:00 committed by GitHub
parent 700b3955a4
commit bd7ce7cda9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 175 additions and 20 deletions

View file

@ -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();
});
});
}); });

View file

@ -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();
}
} }

View file

@ -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>;

View file

@ -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)",

View file

@ -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",
);
},
);
}); });