/* Copyright 2024 New Vector Ltd. Copyright 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ 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, VerifierEvent, } from "matrix-js-sdk/src/crypto-api"; import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; import { Client } from "../../pages/client"; import { ElementAppPage } from "../../pages/ElementAppPage"; import { Bot } from "../../pages/bot"; /** * wait for the given client to receive an incoming verification request, and automatically accept it * * @param client - matrix client handle we expect to receive a request */ export async function waitForVerificationRequest(client: Client): Promise> { return client.evaluateHandle((cli) => { return new Promise((resolve) => { const onVerificationRequestEvent = async (request: VerificationRequest) => { await request.accept(); resolve(request); }; cli.once( "crypto.verificationRequestReceived" as CryptoEvent.VerificationRequestReceived, onVerificationRequestEvent, ); }); }); } /** * Automatically handle a SAS verification * * Given a verifier which has already been started, wait for the emojis to be received, blindly confirm they * match, and return them * * @param verifier - verifier * @returns A promise that resolves, with the emoji list, once we confirm the emojis */ export function handleSasVerification(verifier: JSHandle): Promise { return verifier.evaluate((verifier) => { const event = verifier.getShowSasCallbacks(); if (event) return event.sas.emoji; return new Promise((resolve) => { const onShowSas = (event: ShowSasCallbacks) => { verifier.off("show_sas" as VerifierEvent, onShowSas); event.confirm(); resolve(event.sas.emoji); }; verifier.on("show_sas" as VerifierEvent, onShowSas); }); }); } /** * Check that the user has published cross-signing keys, and that the user's device has been cross-signed. */ export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise { const { userId, deviceId, keys } = await app.client.evaluate(async (cli: MatrixClient) => { const deviceId = cli.getDeviceId(); const userId = cli.getUserId(); const keys = await cli.downloadKeysForUsers([userId]); return { userId, deviceId, keys }; }); // there should be three cross-signing keys expect(keys.master_keys[userId]).toHaveProperty("keys"); expect(keys.self_signing_keys[userId]).toHaveProperty("keys"); expect(keys.user_signing_keys[userId]).toHaveProperty("keys"); // and the device should be signed by the self-signing key const selfSigningKeyId = Object.keys(keys.self_signing_keys[userId].keys)[0]; expect(keys.device_keys[userId][deviceId]).toBeDefined(); const myDeviceSignatures = keys.device_keys[userId][deviceId].signatures[userId]; expect(myDeviceSignatures[selfSigningKeyId]).toBeDefined(); } /** * 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, expectedBackupVersion: string, checkBackupKeyInCache: boolean, ): Promise { // Sanity check the given backup version: if it's null, something went wrong earlier in the test. if (!expectedBackupVersion) { throw new Error( `Invalid backup version passed to \`checkDeviceIsConnectedKeyBackup\`: ${expectedBackupVersion}`, ); } await page.getByRole("button", { name: "User menu" }).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(); // 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); } /** * Fill in the login form in element with the given creds. * * If a `securityKey` is given, verifies the new device using the key. */ export async function logIntoElement( page: Page, homeserver: HomeserverInstance, credentials: Credentials, securityKey?: string, ) { await page.goto("/#/login"); // 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", exact: true }).click(); // wait for the dialog to go away await expect(page.locator(".mx_ServerPickerDialog")).not.toBeVisible(); await page.getByRole("textbox", { name: "Username" }).fill(credentials.userId); await page.getByPlaceholder("Password").fill(credentials.password); await page.getByRole("button", { name: "Sign in" }).click(); // if a securityKey was given, verify the new device if (securityKey !== undefined) { await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click(); // Fill in the security key await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey); await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); await page.getByRole("button", { name: "Done" }).click(); } } /** * 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(); 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 * - check that the bot sees the same emoji as the application * * @param verifier - a verifier in a bot client */ export async function doTwoWaySasVerification(page: Page, verifier: JSHandle): Promise { // on the bot side, wait for the emojis, confirm they match, and return them const emojis = await handleSasVerification(verifier); const emojiBlocks = page.locator(".mx_VerificationShowSas_emojiSas_block"); await expect(emojiBlocks).toHaveCount(emojis.length); // then, check that our application shows an emoji panel with the same emojis. for (let i = 0; i < emojis.length; i++) { const emoji = emojis[i]; const emojiBlock = emojiBlocks.nth(i); const textContent = await emojiBlock.textContent(); // VerificationShowSas munges the case of the emoji descriptions returned by the js-sdk before // displaying them. Once we drop support for legacy crypto, that code can go away, and so can the // case-munging here. expect(textContent.toLowerCase()).toEqual(emoji[0] + emoji[1].toLowerCase()); } } /** * 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). * * Returns the security key */ export async function enableKeyBackup(app: ElementAppPage): Promise { await app.settings.openUserSettings("Security & Privacy"); await app.page.getByRole("button", { name: "Set up Secure Backup" }).click(); const dialog = app.page.locator(".mx_Dialog"); // Recovery key is selected by default await dialog.getByRole("button", { name: "Continue" }).click({ timeout: 60000 }); // copy the text ourselves const securityKey = await dialog.locator(".mx_CreateSecretStorageDialog_recoveryKey code").textContent(); await copyAndContinue(app.page); await expect(dialog.getByText("Secure Backup successful")).toBeVisible(); await dialog.getByRole("button", { name: "Done" }).click(); await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible(); return securityKey; } /** * Click on copy and continue buttons to dismiss the security key dialog */ export async function copyAndContinue(page: Page) { await page.getByRole("button", { name: "Copy" }).click(); await page.getByRole("button", { name: "Continue" }).click(); } /** * Create a shared, unencrypted room with the given user, and wait for them to join * * @param other - UserID of the other user * @param opts - other options for the createRoom call * * @returns a promise which resolves to the room ID */ export async function createSharedRoomWithUser( app: ElementAppPage, other: string, opts: Omit = { name: "TestRoom" }, ): Promise { const roomId = await app.client.createRoom({ ...opts, invite: [other] }); await app.viewRoomById(roomId); // wait for the other user to join the room, otherwise our attempt to open his user details may race // with his join. await expect(app.page.getByText(" joined the room", { exact: false })).toBeVisible(); return roomId; } /** * Send a message in the current room * @param page * @param message - The message text to send */ export async function sendMessageInCurrentRoom(page: Page, message: string): Promise { await page.locator(".mx_MessageComposer").getByRole("textbox").fill(message); await page.getByTestId("sendmessagebtn").click(); } /** * Create a room with the given name and encryption status using the room creation dialog. * * @param roomName - The name of the room to create * @param isEncrypted - Whether the room should be encrypted */ export async function createRoom(page: Page, roomName: string, isEncrypted: boolean): Promise { await page.getByRole("button", { name: "Add room" }).click(); await page.locator(".mx_IconizedContextMenu").getByRole("menuitem", { name: "New room" }).click(); const dialog = page.locator(".mx_Dialog"); await dialog.getByLabel("Name").fill(roomName); if (!isEncrypted) { // it's enabled by default await page.getByLabel("Enable end-to-end encryption").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(); } } /** * Configure the given MatrixClient to auto-accept any invites * @param client - the client to configure */ export async function autoJoin(client: Client) { await client.evaluate((cli) => { cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => { if (member.membership === "invite" && member.userId === cli.getUserId()) { cli.joinRoom(member.roomId); } }); }); } /** * Verify a user by emoji * @param page - the page to use * @param bob - the user to verify */ export const verify = async (app: ElementAppPage, bob: Bot) => { const page = app.page; const bobsVerificationRequestPromise = waitForVerificationRequest(bob); const roomInfo = await app.toggleRoomInfoPanel(); await page.locator(".mx_RightPanelTabs").getByText("People").click(); await roomInfo.getByText("Bob").click(); await roomInfo.getByRole("button", { name: "Verify" }).click(); await roomInfo.getByRole("button", { name: "Start Verification" }).click(); // this requires creating a DM, so can take a while. Give it a longer timeout. await roomInfo.getByRole("button", { name: "Verify by emoji" }).click({ timeout: 30000 }); const request = await bobsVerificationRequestPromise; // the bot user races with the Element user to hit the "verify by emoji" button const verifier = await request.evaluateHandle((request) => request.startVerification("m.sas.v1")); await doTwoWaySasVerification(page, verifier); await roomInfo.getByRole("button", { name: "They match" }).click(); await expect(roomInfo.getByText("You've successfully verified Bob!")).toBeVisible(); await roomInfo.getByRole("button", { name: "Got it" }).click(); }; /** * Wait for a verifier to exist for a VerificationRequest * * @param botVerificationRequest */ export async function awaitVerifier( botVerificationRequest: JSHandle, ): Promise> { return botVerificationRequest.evaluateHandle(async (verificationRequest) => { while (!verificationRequest.verifier) { await new Promise((r) => verificationRequest.once("change" as any, r)); } return verificationRequest.verifier; }); }