Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Expected UTDs: use a different message for UTDs sent before login (#1…
Browse files Browse the repository at this point in the history
…2391)

* 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
  • Loading branch information
richvdh authored Apr 25, 2024
1 parent 700b395 commit bd7ce7c
Show file tree
Hide file tree
Showing 5 changed files with 175 additions and 20 deletions.
76 changes: 72 additions & 4 deletions playwright/e2e/crypto/crypto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@ limitations under the License.
*/

import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { expect, test } from "../../element-web-test";
import {
copyAndContinue,
createRoom,
createSharedRoomWithUser,
doTwoWaySasVerification,
copyAndContinue,
enableKeyBackup,
logIntoElement,
logOutOfElement,
sendMessageInCurrentRoom,
verifySession,
waitForVerificationRequest,
} from "./utils";
import { Bot } from "../../pages/bot";
Expand Down Expand Up @@ -453,8 +456,8 @@ test.describe("Cryptography", function () {
// no e2e icon
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
// to wait :/
// 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);

/* log out, and back in */
Expand Down Expand Up @@ -532,4 +535,69 @@ test.describe("Cryptography", function () {
).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();
});
});
});
42 changes: 36 additions & 6 deletions playwright/e2e/crypto/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and
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 {
EmojiMapping,
ShowSasCallbacks,
VerificationRequest,
Verifier,
EmojiMapping,
VerifierEvent,
ShowSasCallbacks,
} from "matrix-js-sdk/src/crypto-api";
import { Credentials, HomeserverInstance } from "../../plugins/homeserver";
import { Client } from "../../pages/client";
Expand Down Expand Up @@ -148,7 +148,7 @@ export async function logIntoElement(
// select homeserver
await page.getByRole("button", { name: "Edit" }).click();
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
await expect(page.locator(".mx_ServerPickerDialog")).not.toBeVisible();
Expand All @@ -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.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
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:
* - wait for the bot to receive the emojis
Expand Down Expand Up @@ -289,4 +314,9 @@ export async function createRoom(page: Page, roomName: string, isEncrypted: bool
}

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();
}
}
31 changes: 23 additions & 8 deletions src/components/views/messages/DecryptionFailureBody.tsx
Original file line number Diff line number Diff line change
@@ -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");
you may not use this file except in compliance with the License.
Expand All @@ -14,23 +14,38 @@ See the License for the specific language governing permissions and
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 { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api";

import { _t } from "../../../languageHandler";
import { IBodyProps } from "./IBodyProps";
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";

function getErrorMessage(mxEvent?: MatrixEvent): string {
return mxEvent?.isEncryptedDisabledForUnverifiedDevices
? _t("timeline|decryption_failure|blocked")
: _t("timeline|decryption_failure|unable_to_decrypt");
function getErrorMessage(mxEvent: MatrixEvent, isVerified: boolean | undefined): string {
if (mxEvent.isEncryptedDisabledForUnverifiedDevices) return _t("timeline|decryption_failure|blocked");
switch (mxEvent.decryptionFailureReason) {
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
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 (
<div className="mx_DecryptionFailureBody mx_EventTile_content" ref={ref}>
{getErrorMessage(mxEvent)}
{getErrorMessage(mxEvent, verificationState)}
</div>
);
}) as ForwardRefExoticComponent<IBodyProps>;
2 changes: 2 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -3214,6 +3214,8 @@
"creation_summary_room": "%(creator)s created and configured the room.",
"decryption_failure": {
"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"
},
"disambiguated_profile": "%(displayName)s (%(matrixId)s)",
Expand Down
44 changes: 42 additions & 2 deletions test/components/views/messages/DecryptionFailureBody-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,20 @@
import React from "react";
import { render } from "@testing-library/react";
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 { DecryptionFailureBody } from "../../../../src/components/views/messages/DecryptionFailureBody";
import { LocalDeviceVerificationStateContext } from "../../../../src/contexts/LocalDeviceVerificationStateContext";

describe("DecryptionFailureBody", () => {
function customRender(event: MatrixEvent) {
return render(<DecryptionFailureBody mxEvent={event} />);
function customRender(event: MatrixEvent, localDeviceVerified: boolean = false) {
return render(
<LocalDeviceVerificationStateContext.Provider value={localDeviceVerified}>
<DecryptionFailureBody mxEvent={event} />
</LocalDeviceVerificationStateContext.Provider>,
);
}

it(`Should display "Unable to decrypt message"`, () => {
Expand Down Expand Up @@ -60,4 +67,37 @@ describe("DecryptionFailureBody", () => {
// Then
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",
);
},
);
});

0 comments on commit bd7ce7c

Please sign in to comment.