From 3fc58865b546e038c1e21eb4f42eefe9e63426f2 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Tue, 5 Sep 2023 12:11:10 +0100 Subject: [PATCH] Cypress tests for event shields (#11525) * Factor downloadKey out to `utils.ts` * Add a new `describe` block for event shields * create a beforeEach block * Cypress tests for event shields --- cypress/e2e/crypto/crypto.spec.ts | 296 ++++++++++++++++++++++-------- cypress/e2e/crypto/utils.ts | 65 ++++++- 2 files changed, 285 insertions(+), 76 deletions(-) diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts index 3c815ab4088..8a8fa042e8a 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -19,7 +19,14 @@ import type { VerificationRequest } from "matrix-js-sdk/src/crypto-api"; import type { CypressBot } from "../../support/bot"; import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { UserCredentials } from "../../support/login"; -import { doTwoWaySasVerification, waitForVerificationRequest } from "./utils"; +import { + doTwoWaySasVerification, + downloadKey, + enableKeyBackup, + logIntoElement, + logOutOfElement, + waitForVerificationRequest, +} from "./utils"; import { skipIfRustCrypto } from "../../support/util"; interface CryptoTestContext extends Mocha.Context { @@ -129,19 +136,26 @@ const verify = function (this: CryptoTestContext) { describe("Cryptography", function () { let aliceCredentials: UserCredentials; + let homeserver: HomeserverInstance; + let bob: CypressBot; beforeEach(function () { cy.startHomeserver("default") .as("homeserver") - .then((homeserver: HomeserverInstance) => { + .then((data) => { + homeserver = data; cy.initTestUser(homeserver, "Alice", undefined, "alice_").then((credentials) => { aliceCredentials = credentials; }); - cy.getBot(homeserver, { + return cy.getBot(homeserver, { displayName: "Bob", autoAcceptInvites: false, userIdPrefix: "bob_", - }).as("bob"); + }); + }) + .as("bob") + .then((data) => { + bob = data; }); }); @@ -169,15 +183,6 @@ describe("Cryptography", function () { }); } - /** - * Click on download button and continue - */ - function downloadKey() { - // Clicking download instead of Copy because of https://github.com/cypress-io/cypress/issues/2851 - cy.findByRole("button", { name: "Download" }).click(); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); - } - it("by recovery code", () => { skipIfRustCrypto(); @@ -294,53 +299,217 @@ describe("Cryptography", function () { verify.call(this); }); - it("should show the correct shield on edited e2e events", function (this: CryptoTestContext) { - skipIfRustCrypto(); - cy.bootstrapCrossSigning(aliceCredentials); + describe("event shields", () => { + let testRoomId: string; - // bob has a second, not cross-signed, device - cy.loginBot(this.homeserver, this.bob.getUserId(), this.bob.__cypress_password, {}).as("bobSecondDevice"); + beforeEach(() => { + cy.bootstrapCrossSigning(aliceCredentials); + autoJoin(bob); - autoJoin(this.bob); + // create an encrypted room + cy.createRoom({ name: "TestRoom", invite: [bob.getUserId()] }) + .as("testRoomId") + .then((roomId) => { + testRoomId = roomId; + cy.log(`Created test room ${roomId}`); + cy.visit(`/#/room/${roomId}`); - // first create the room, so that we can open the verification panel - cy.createRoom({ name: "TestRoom", invite: [this.bob.getUserId()] }) - .as("testRoomId") - .then((roomId) => { - cy.log(`Created test room ${roomId}`); - cy.visit(`/#/room/${roomId}`); + // enable encryption + cy.getClient().then((cli) => { + cli.sendStateEvent(roomId, "m.room.encryption", { algorithm: "m.megolm.v1.aes-sha2" }); + }); - // enable encryption - cy.getClient().then((cli) => { - cli.sendStateEvent(roomId, "m.room.encryption", { algorithm: "m.megolm.v1.aes-sha2" }); + // wait for Bob to join the room, otherwise our attempt to open his user details may race + // with his join. + cy.findByText("Bob joined the room").should("exist"); }); + }); + + it("should show the correct shield on e2e events", function (this: CryptoTestContext) { + skipIfRustCrypto(); - // wait for Bob to join the room, otherwise our attempt to open his user details may race - // with his join. - cy.findByText("Bob joined the room").should("exist"); + // Bob has a second, not cross-signed, device + let bobSecondDevice: MatrixClient; + cy.loginBot(homeserver, bob.getUserId(), bob.__cypress_password, {}).then(async (data) => { + bobSecondDevice = data; }); - verify.call(this); + /* Should show an error for a decryption failure */ + cy.wrap(0).then(() => + bob.sendEvent(testRoomId, "m.room.encrypted", { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "the bird is in the hand", + }), + ); + + cy.get(".mx_EventTile_last") + .should("contain", "Unable to decrypt message") + .find(".mx_EventTile_e2eIcon") + .should("have.class", "mx_EventTile_e2eIcon_decryption_failure") + .should("have.attr", "aria-label", "This message could not be decrypted"); + + /* Should show a red padlock for an unencrypted message in an e2e room */ + cy.wrap(0) + .then(() => + bob.http.authedRequest( + // @ts-ignore-next this wants a Method instance, but that is hard to get to here + "PUT", + `/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`, + undefined, + { + msgtype: "m.text", + body: "test unencrypted", + }, + ), + ) + .then((resp) => cy.log(`Bob sent unencrypted event with event id ${resp.event_id}`)); - cy.get("@testRoomId").then((roomId) => { + cy.get(".mx_EventTile_last") + .should("contain", "test unencrypted") + .find(".mx_EventTile_e2eIcon") + .should("have.class", "mx_EventTile_e2eIcon_warning") + .should("have.attr", "aria-label", "Unencrypted"); + + /* Should show no padlock for an unverified user */ // bob sends a valid event - cy.wrap(this.bob.sendTextMessage(roomId, "Hoo!")).as("testEvent"); - - // the message should appear, decrypted, with no warning - cy.get(".mx_EventTile_last .mx_EventTile_body") - .within(() => { - cy.findByText("Hoo!"); - }) - .closest(".mx_EventTile") - .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); - - // bob sends an edit to the first message with his unverified device - cy.get("@bobSecondDevice").then((bobSecondDevice) => { + cy.wrap(0) + .then(() => bob.sendTextMessage(testRoomId, "test encrypted 1")) + .then((resp) => cy.log(`Bob sent message from primary device with event id ${resp.event_id}`)); + + // the message should appear, decrypted, with no warning, but also no "verified" + cy.get(".mx_EventTile_last") + .should("contain", "test encrypted 1") + // no e2e icon + .should("not.have.descendants", ".mx_EventTile_e2eIcon"); + + /* Now verify Bob */ + verify.call(this); + + /* Existing message should be updated when user is verified. */ + cy.get(".mx_EventTile_last") + .should("contain", "test encrypted 1") + // still no e2e icon + .should("not.have.descendants", ".mx_EventTile_e2eIcon"); + + /* should show no padlock, and be verified, for a message from a verified device */ + cy.wrap(0) + .then(() => bob.sendTextMessage(testRoomId, "test encrypted 2")) + .then((resp) => cy.log(`Bob sent second message from primary device with event id ${resp.event_id}`)); + + cy.get(".mx_EventTile_last") + .should("contain", "test encrypted 2") + // no e2e icon + .should("not.have.descendants", ".mx_EventTile_e2eIcon"); + + /* should show red padlock for a message from an unverified device */ + cy.wrap(0) + .then(() => bobSecondDevice.sendTextMessage(testRoomId, "test encrypted from unverified")) + .then((resp) => cy.log(`Bob sent message from unverified device with event id ${resp.event_id}`)); + + cy.get(".mx_EventTile_last") + .should("contain", "test encrypted from unverified") + .find(".mx_EventTile_e2eIcon", { timeout: 100000 }) + .should("have.class", "mx_EventTile_e2eIcon_warning") + .should("have.attr", "aria-label", "Encrypted by an unverified session"); + + /* Should show a grey padlock for a message from an unknown device */ + + // bob deletes his second device, making the encrypted event from the unverified device "unknown". + cy.wrap(0) + .then(() => bobSecondDevice.logout(true)) + .then(() => cy.log(`Bob logged out second device`)); + + cy.get(".mx_EventTile_last") + .should("contain", "test encrypted from unverified") + .find(".mx_EventTile_e2eIcon") + .should("have.class", "mx_EventTile_e2eIcon_normal") + .should("have.attr", "aria-label", "Encrypted by a deleted session"); + }); + + it("Should show a grey padlock for a key restored from backup", () => { + skipIfRustCrypto(); + + enableKeyBackup(); + + // bob sends a valid event + cy.wrap(0) + .then(() => bob.sendTextMessage(testRoomId, "test encrypted 1")) + .then((resp) => cy.log(`Bob sent message from primary device with event id ${resp.event_id}`)); + + cy.get(".mx_EventTile_last") + .should("contain", "test encrypted 1") + // no e2e icon + .should("not.have.descendants", ".mx_EventTile_e2eIcon"); + + /* log out, and back i */ + logOutOfElement(); + cy.get("@securityKey").then((securityKey) => { + logIntoElement(homeserver.baseUrl, aliceCredentials.username, aliceCredentials.password, securityKey); + }); + + /* go back to the test room and find Bob's message again */ + cy.viewRoomById(testRoomId); + cy.get(".mx_EventTile_last") + .should("contain", "test encrypted 1") + .find(".mx_EventTile_e2eIcon") + .should("have.class", "mx_EventTile_e2eIcon_normal") + .should( + "have.attr", + "aria-label", + "The authenticity of this encrypted message can't be guaranteed on this device.", + ); + }); + + it("should show the correct shield on edited e2e events", function (this: CryptoTestContext) { + skipIfRustCrypto(); + + // bob has a second, not cross-signed, device + cy.loginBot(this.homeserver, this.bob.getUserId(), this.bob.__cypress_password, {}).as("bobSecondDevice"); + + // verify Bob + verify.call(this); + + cy.get("@testRoomId").then((roomId) => { + // bob sends a valid event + cy.wrap(this.bob.sendTextMessage(roomId, "Hoo!")).as("testEvent"); + + // the message should appear, decrypted, with no warning + cy.get(".mx_EventTile_last .mx_EventTile_body") + .within(() => { + cy.findByText("Hoo!"); + }) + .closest(".mx_EventTile") + .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); + + // bob sends an edit to the first message with his unverified device + cy.get("@bobSecondDevice").then((bobSecondDevice) => { + cy.get("@testEvent").then((testEvent) => { + bobSecondDevice.sendMessage(roomId, { + "m.new_content": { + msgtype: "m.text", + body: "Haa!", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: testEvent.event_id, + }, + }); + }); + }); + + // the edit should have a warning + cy.contains(".mx_EventTile_body", "Haa!") + .closest(".mx_EventTile") + .within(() => { + cy.get(".mx_EventTile_e2eIcon_warning").should("exist"); + }); + + // a second edit from the verified device should be ok cy.get("@testEvent").then((testEvent) => { - bobSecondDevice.sendMessage(roomId, { + this.bob.sendMessage(roomId, { "m.new_content": { msgtype: "m.text", - body: "Haa!", + body: "Hee!", }, "m.relates_to": { rel_type: "m.replace", @@ -348,35 +517,14 @@ describe("Cryptography", function () { }, }); }); - }); - - // the edit should have a warning - cy.contains(".mx_EventTile_body", "Haa!") - .closest(".mx_EventTile") - .within(() => { - cy.get(".mx_EventTile_e2eIcon_warning").should("exist"); - }); - // a second edit from the verified device should be ok - cy.get("@testEvent").then((testEvent) => { - this.bob.sendMessage(roomId, { - "m.new_content": { - msgtype: "m.text", - body: "Hee!", - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: testEvent.event_id, - }, - }); + cy.get(".mx_EventTile_last .mx_EventTile_body") + .within(() => { + cy.findByText("Hee!"); + }) + .closest(".mx_EventTile") + .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); }); - - cy.get(".mx_EventTile_last .mx_EventTile_body") - .within(() => { - cy.findByText("Hee!"); - }) - .closest(".mx_EventTile") - .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); }); }); }); diff --git a/cypress/e2e/crypto/utils.ts b/cypress/e2e/crypto/utils.ts index 5de56cde9d3..a3c8078a809 100644 --- a/cypress/e2e/crypto/utils.ts +++ b/cypress/e2e/crypto/utils.ts @@ -98,9 +98,11 @@ export function checkDeviceIsCrossSigned(): void { } /** - * Fill in the login form in element with the given creds + * Fill in the login form in element with the given creds. + * + * If a `securityKey` is given, verifies the new device using the key. */ -export function logIntoElement(homeserverUrl: string, username: string, password: string) { +export function logIntoElement(homeserverUrl: string, username: string, password: string, securityKey?: string) { cy.visit("/#/login"); // select homeserver @@ -114,6 +116,32 @@ export function logIntoElement(homeserverUrl: string, username: string, password cy.findByRole("textbox", { name: "Username" }).type(username); cy.findByPlaceholderText("Password").type(password); cy.findByRole("button", { name: "Sign in" }).click(); + + // if a securityKey was given, verify the new device + if (securityKey !== undefined) { + cy.get(".mx_AuthPage").within(() => { + cy.findByRole("button", { name: "Verify with Security Key" }).click(); + }); + cy.get(".mx_Dialog").within(() => { + // Fill in the security key + cy.get('input[type="password"]').type(securityKey); + }); + cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); + cy.findByRole("button", { name: "Done" }).click(); + } +} + +/** + * Queue up Cypress commands to log out of Element + */ +export function logOutOfElement() { + cy.findByRole("button", { name: "User menu" }).click(); + cy.get(".mx_UserMenu_contextMenu").within(() => { + cy.findByRole("menuitem", { name: "Sign out" }).click(); + }); + cy.get(".mx_Dialog .mx_QuestionDialog").within(() => { + cy.findByRole("button", { name: "Sign out" }).click(); + }); } /** @@ -139,3 +167,36 @@ export function doTwoWaySasVerification(verifier: Verifier): void { }); }); } + +/** + * Queue up cypress commands to open the security settings and enable secure key backup. + * + * Assumes that the current device has been cross-signed (which means that we skip a step where we set it up). + * + * Stores the security key in `@securityKey`. + */ +export function enableKeyBackup() { + cy.openUserSettings("Security & Privacy"); + cy.findByRole("button", { name: "Set up Secure Backup" }).click(); + cy.get(".mx_Dialog").within(() => { + // Recovery key is selected by default + cy.findByRole("button", { name: "Continue", timeout: 60000 }).click(); + + // copy the text ourselves + cy.get(".mx_CreateSecretStorageDialog_recoveryKey code").invoke("text").as("securityKey", { type: "static" }); + downloadKey(); + + cy.findByText("Secure Backup successful").should("exist"); + cy.findByRole("button", { name: "Done" }).click(); + cy.findByText("Secure Backup successful").should("not.exist"); + }); +} + +/** + * Queue up cypress commands to click on download button and continue + */ +export function downloadKey() { + // Clicking download instead of Copy because of https://github.com/cypress-io/cypress/issues/2851 + cy.findByRole("button", { name: "Download" }).click(); + cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); +}