diff --git a/playwright/e2e/forgot-password/forgot-password.spec.ts b/playwright/e2e/forgot-password/forgot-password.spec.ts new file mode 100644 index 0000000000..260242ebc6 --- /dev/null +++ b/playwright/e2e/forgot-password/forgot-password.spec.ts @@ -0,0 +1,77 @@ +/* +Copyright 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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { expect, test } from "../../element-web-test"; +import { selectHomeserver } from "../utils"; + +const username = "user1234"; +// this has to be password-like enough to please zxcvbn. Needless to say it's just from pwgen. +const password = "oETo7MPf0o"; +const email = "user@nowhere.dummy"; + +test.describe("Forgot Password", () => { + test.use({ + startHomeserverOpts: ({ mailhog }, use) => + use({ + template: "email", + variables: { + SMTP_HOST: "host.containers.internal", + SMTP_PORT: mailhog.instance.smtpPort, + }, + }), + }); + + test("renders properly", async ({ page, homeserver }) => { + await page.goto("/"); + + await page.getByRole("link", { name: "Sign in" }).click(); + + // need to select a homeserver at this stage, before entering the forgot password flow + await selectHomeserver(page, homeserver.config.baseUrl); + + await page.getByRole("button", { name: "Forgot password?" }).click(); + + await expect(page.getByRole("main")).toMatchScreenshot("forgot-password.png"); + }); + + test("renders email verification dialog properly", async ({ page, homeserver }) => { + const user = await homeserver.registerUser(username, password); + + await homeserver.setThreepid(user.userId, "email", email); + + await page.goto("/"); + + await page.getByRole("link", { name: "Sign in" }).click(); + await selectHomeserver(page, homeserver.config.baseUrl); + + await page.getByRole("button", { name: "Forgot password?" }).click(); + + await page.getByRole("textbox", { name: "Email address" }).fill(email); + + await page.getByRole("button", { name: "Send email" }).click(); + + await page.getByRole("button", { name: "Next" }).click(); + + await page.getByRole("textbox", { name: "New Password", exact: true }).fill(password); + await page.getByRole("textbox", { name: "Confirm new password", exact: true }).fill(password); + + await page.getByRole("button", { name: "Reset password" }).click(); + + await expect(page.getByRole("button", { name: "Resend" })).toBeInViewport(); + + await expect(page.locator(".mx_Dialog")).toMatchScreenshot("forgot-password-verify-email.png"); + }); +}); diff --git a/playwright/e2e/login/login.spec.ts b/playwright/e2e/login/login.spec.ts index b1b02c0a9a..fc8de6499e 100644 --- a/playwright/e2e/login/login.spec.ts +++ b/playwright/e2e/login/login.spec.ts @@ -14,11 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Page } from "@playwright/test"; - import { expect, test } from "../../element-web-test"; import { doTokenRegistration } from "./utils"; import { isDendrite } from "../../plugins/homeserver/dendrite"; +import { selectHomeserver } from "../utils"; test.describe("Login", () => { test.describe("Password login", () => { @@ -85,17 +84,6 @@ test.describe("Login", () => { await expect(page).toHaveURL(/\/#\/room\/!room:id$/); await expect(page.getByRole("button", { name: "Join the discussion" })).toBeVisible(); }); - - async function selectHomeserver(page: Page, homeserverUrl: string) { - await page.getByRole("button", { name: "Edit" }).click(); - await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserverUrl); - await page.getByRole("button", { name: "Continue", exact: true }).click(); - // wait for the dialog to go away - await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0); - - await expect(page.locator(".mx_Spinner")).toHaveCount(0); - await expect(page.locator(".mx_ServerPicker_server")).toHaveText(homeserverUrl); - } }); // tests for old-style SSO login, in which we exchange tokens with Synapse, and Synapse talks to an auth server diff --git a/playwright/e2e/utils.ts b/playwright/e2e/utils.ts index 30aff64dd8..e7587c7dfb 100644 --- a/playwright/e2e/utils.ts +++ b/playwright/e2e/utils.ts @@ -17,8 +17,8 @@ limitations under the License. */ import { uniqueId } from "lodash"; +import { expect, type Page } from "@playwright/test"; -import type { Page } from "@playwright/test"; import type { ClientEvent, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { Client } from "../pages/client"; @@ -63,4 +63,15 @@ export async function waitForRoom( ); } +export async function selectHomeserver(page: Page, homeserverUrl: string) { + await page.getByRole("button", { name: "Edit" }).click(); + await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserverUrl); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + // wait for the dialog to go away + await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0); + + await expect(page.locator(".mx_Spinner")).toHaveCount(0); + await expect(page.locator(".mx_ServerPicker_server")).toHaveText(homeserverUrl); +} + export const CommandOrControl = process.platform === "darwin" ? "Meta" : "Control"; diff --git a/playwright/plugins/homeserver/index.ts b/playwright/plugins/homeserver/index.ts index 1e0cfb3b39..b14ba70082 100644 --- a/playwright/plugins/homeserver/index.ts +++ b/playwright/plugins/homeserver/index.ts @@ -39,6 +39,15 @@ export interface HomeserverInstance { * @param password login password */ loginUser(userId: string, password: string): Promise; + + /** + * Sets a third party identifier for the given user. This only supports setting a single 3pid and will + * replace any others. + * @param userId The full ID of the user to edit (as returned from registerUser) + * @param medium The medium of the 3pid to set + * @param address The address of the 3pid to set + */ + setThreepid(userId: string, medium: string, address: string): Promise; } export interface StartHomeserverOpts { diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index d94453c017..23634c2b60 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -94,6 +94,8 @@ export class Synapse implements Homeserver, HomeserverInstance { protected docker: Docker = new Docker(); public config: HomeserverConfig & { serverId: string }; + private adminToken?: string; + public constructor(private readonly request: APIRequestContext) {} /** @@ -152,12 +154,17 @@ export class Synapse implements Homeserver, HomeserverInstance { return [path.join(synapseLogsPath, "stdout.log"), path.join(synapseLogsPath, "stderr.log")]; } - public async registerUser(username: string, password: string, displayName?: string): Promise { + private async registerUserInternal( + username: string, + password: string, + displayName?: string, + admin = false, + ): Promise { const url = `${this.config.baseUrl}/_synapse/admin/v1/register`; const { nonce } = await this.request.get(url).then((r) => r.json()); const mac = crypto .createHmac("sha1", this.config.registrationSecret) - .update(`${nonce}\0${username}\0${password}\0notadmin`) + .update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`) .digest("hex"); const res = await this.request.post(url, { data: { @@ -165,7 +172,7 @@ export class Synapse implements Homeserver, HomeserverInstance { username, password, mac, - admin: false, + admin, displayname: displayName, }, }); @@ -185,6 +192,10 @@ export class Synapse implements Homeserver, HomeserverInstance { }; } + public registerUser(username: string, password: string, displayName?: string): Promise { + return this.registerUserInternal(username, password, displayName, false); + } + public async loginUser(userId: string, password: string): Promise { const url = `${this.config.baseUrl}/_matrix/client/v3/login`; const res = await this.request.post(url, { @@ -207,4 +218,30 @@ export class Synapse implements Homeserver, HomeserverInstance { homeServer: json.home_server, }; } + + public async setThreepid(userId: string, medium: string, address: string): Promise { + if (this.adminToken === undefined) { + const result = await this.registerUserInternal("admin", "totalyinsecureadminpassword", undefined, true); + this.adminToken = result.accessToken; + } + + const url = `${this.config.baseUrl}/_synapse/admin/v2/users/${userId}`; + const res = await this.request.put(url, { + data: { + threepids: [ + { + medium, + address, + }, + ], + }, + headers: { + Authorization: `Bearer ${this.adminToken}`, + }, + }); + + if (!res.ok()) { + throw await res.json(); + } + } } diff --git a/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png new file mode 100644 index 0000000000..891f024bf8 Binary files /dev/null and b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-linux.png differ diff --git a/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-verify-email-linux.png b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-verify-email-linux.png new file mode 100644 index 0000000000..22b4e109c8 Binary files /dev/null and b/playwright/snapshots/forgot-password/forgot-password.spec.ts/forgot-password-verify-email-linux.png differ diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 54dedf9199..1d9fc87575 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -522,6 +522,8 @@ legend { content: ""; width: 28px; height: 28px; + left: 0; + top: 0; position: absolute; mask-image: url("@vector-im/compound-design-tokens/icons/close.svg"); mask-repeat: no-repeat;