diff --git a/playwright/e2e/csAPI.ts b/playwright/e2e/csAPI.ts index d55816fb6a2..78362506ef2 100644 --- a/playwright/e2e/csAPI.ts +++ b/playwright/e2e/csAPI.ts @@ -9,24 +9,24 @@ import { APIRequestContext } from "playwright-core"; import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import { HomeserverInstance } from "../plugins/homeserver"; +import { ClientServerApi } from "../testcontainers/utils.ts"; /** * A small subset of the Client-Server API used to manipulate the state of the * account on the homeserver independently of the client under test. */ -export class TestClientServerAPI { +export class TestClientServerAPI extends ClientServerApi { public constructor( - private request: APIRequestContext, - private homeserver: HomeserverInstance, + request: APIRequestContext, + homeserver: HomeserverInstance, private accessToken: string, - ) {} + ) { + super(`${homeserver.baseUrl}/_matrix/client`); + this.setRequest(request); + } public async getCurrentBackupInfo(): Promise { - const res = await this.request.get(`${this.homeserver.baseUrl}/_matrix/client/v3/room_keys/version`, { - headers: { Authorization: `Bearer ${this.accessToken}` }, - }); - - return await res.json(); + return this.request("GET", `/v3/room_keys/version`, this.accessToken); } /** @@ -34,15 +34,6 @@ export class TestClientServerAPI { * @param version The version to delete */ public async deleteBackupVersion(version: string): Promise { - const res = await this.request.delete( - `${this.homeserver.baseUrl}/_matrix/client/v3/room_keys/version/${version}`, - { - headers: { Authorization: `Bearer ${this.accessToken}` }, - }, - ); - - if (!res.ok) { - throw new Error(`Failed to delete backup version: ${res.status}`); - } + await this.request("DELETE", `/v3/room_keys/version/${version}`, this.accessToken); } } diff --git a/playwright/e2e/login/login-consent.spec.ts b/playwright/e2e/login/login-consent.spec.ts index ab70e1d1869..d7d5861a02e 100644 --- a/playwright/e2e/login/login-consent.spec.ts +++ b/playwright/e2e/login/login-consent.spec.ts @@ -77,6 +77,9 @@ async function login(page: Page, homeserver: HomeserverInstance, credentials: Cr await page.getByRole("button", { name: "Sign in" }).click(); } +// This test suite uses the same userId for all tests in the suite +// due to DEVICE_SIGNING_KEYS_BODY being specific to that userId, +// so we restart the Synapse container to make it forget everything. test.use(consentHomeserver); test.use({ config: { @@ -97,6 +100,9 @@ test.use({ ...credentials, displayName, }); + + // Restart the homeserver to wipe its in-memory db so we can reuse the same user ID without cross-signing prompts + await homeserver.restart(); }, }); diff --git a/playwright/e2e/oidc/index.ts b/playwright/e2e/oidc/index.ts index 7e9b03ee6ac..bfd49b496a0 100644 --- a/playwright/e2e/oidc/index.ts +++ b/playwright/e2e/oidc/index.ts @@ -33,7 +33,7 @@ export async function registerAccountMas( expect(messages.items).toHaveLength(1); }).toPass(); expect(messages.items[0].to).toEqual(`${username} <${email}>`); - const [code] = messages.items[0].text.match(/(\d{6})/); + const [, code] = messages.items[0].text.match(/Your verification code to confirm this email address is: (\d{6})/); await page.getByRole("textbox", { name: "6-digit code" }).fill(code); await page.getByRole("button", { name: "Continue" }).click(); diff --git a/playwright/e2e/oidc/oidc-native.spec.ts b/playwright/e2e/oidc/oidc-native.spec.ts index eb268c5cccc..a50730ce747 100644 --- a/playwright/e2e/oidc/oidc-native.spec.ts +++ b/playwright/e2e/oidc/oidc-native.spec.ts @@ -24,6 +24,8 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { mailhogClient, mas, }, testInfo) => { + await page.clock.install(); + const tokenUri = `${mas.baseUrl}/oauth2/token`; const tokenApiPromise = page.waitForRequest( (request) => request.url() === tokenUri && request.postDataJSON()["grant_type"] === "authorization_code", @@ -31,11 +33,14 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => { await page.goto("/#/login"); await page.getByRole("button", { name: "Continue" }).click(); - await registerAccountMas(page, mailhogClient, `alice_${testInfo.testId}`, "alice@email.com", "Pa$sW0rD!"); + + const userId = `alice_${testInfo.testId}`; + await registerAccountMas(page, mailhogClient, userId, "alice@email.com", "Pa$sW0rD!"); // Eventually, we should end up at the home screen. await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 }); - await expect(page.getByRole("heading", { name: "Welcome alice", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: `Welcome ${userId}`, exact: true })).toBeVisible(); + await page.clock.runFor(20000); // run the timer so we see the token request const tokenApiRequest = await tokenApiPromise; expect(tokenApiRequest.postDataJSON()["grant_type"]).toBe("authorization_code"); diff --git a/playwright/e2e/sliding-sync/sliding-sync.spec.ts b/playwright/e2e/sliding-sync/sliding-sync.spec.ts index 29a612ccd32..1ab7909a478 100644 --- a/playwright/e2e/sliding-sync/sliding-sync.spec.ts +++ b/playwright/e2e/sliding-sync/sliding-sync.spec.ts @@ -108,7 +108,6 @@ test.describe("Sliding Sync", () => { await page.getByRole("menuitemradio", { name: "A-Z" }).dispatchEvent("click"); await expect(page.locator(".mx_StyledRadioButton_checked").getByText("A-Z")).toBeVisible(); - await page.pause(); await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page); }); diff --git a/playwright/e2e/spotlight/spotlight.spec.ts b/playwright/e2e/spotlight/spotlight.spec.ts index d4451b9b5c1..da35ca57b35 100644 --- a/playwright/e2e/spotlight/spotlight.spec.ts +++ b/playwright/e2e/spotlight/spotlight.spec.ts @@ -366,28 +366,28 @@ test.describe("Spotlight", () => { await spotlight.search("b"); let resultLocator = spotlight.results; - await expect(resultLocator.count()).resolves.toBeGreaterThan(2); + await expect(resultLocator).toHaveCount(2); await expect(resultLocator.first()).toHaveAttribute("aria-selected", "true"); await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false"); await spotlight.searchBox.press("ArrowDown"); resultLocator = spotlight.results; await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false"); - await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "true"); + await expect(resultLocator.last()).toHaveAttribute("aria-selected", "true"); await spotlight.searchBox.press("ArrowDown"); resultLocator = spotlight.results; await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false"); - await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "false"); + await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false"); await spotlight.searchBox.press("ArrowUp"); resultLocator = spotlight.results; await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false"); - await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "true"); + await expect(resultLocator.last()).toHaveAttribute("aria-selected", "true"); await spotlight.searchBox.press("ArrowUp"); resultLocator = spotlight.results; await expect(resultLocator.first()).toHaveAttribute("aria-selected", "true"); - await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "false"); + await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false"); }); }); diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 0c6392fdc21..12b86c1f69f 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -41,7 +41,7 @@ const CONFIG_JSON: Partial = { }, }; -interface CredentialsWithDisplayName extends Credentials { +export interface CredentialsWithDisplayName extends Credentials { displayName: string; } diff --git a/playwright/plugins/homeserver/index.ts b/playwright/plugins/homeserver/index.ts index 50dea472f78..9e54e0aa916 100644 --- a/playwright/plugins/homeserver/index.ts +++ b/playwright/plugins/homeserver/index.ts @@ -6,8 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { ClientServerApi } from "../../testcontainers/utils.ts"; + export interface HomeserverInstance { readonly baseUrl: string; + readonly csApi: ClientServerApi; /** * Register a user on the given Homeserver using the shared registration secret. diff --git a/playwright/services.ts b/playwright/services.ts index 4fe05b1282d..0d9ece05291 100644 --- a/playwright/services.ts +++ b/playwright/services.ts @@ -114,10 +114,9 @@ export const test = base.extend<{}, Services>({ .withNetworkAliases("homeserver") .withLogConsumer(logger.getConsumer("synapse")) .withConfig(synapseConfigOptions) + .withMatrixAuthenticationService(mas) .start(); - container.setMatrixAuthenticationService(mas); - await use(container); await container.stop(); }, diff --git a/playwright/testcontainers/HomeserverContainer.ts b/playwright/testcontainers/HomeserverContainer.ts index e825b6e5544..259ecb7fe0a 100644 --- a/playwright/testcontainers/HomeserverContainer.ts +++ b/playwright/testcontainers/HomeserverContainer.ts @@ -14,11 +14,11 @@ import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; export interface HomeserverContainer extends GenericContainer { withConfigField(key: string, value: any): this; withConfig(config: Partial): this; + withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this; start(): Promise; } export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance { setRequest(request: APIRequestContext): void; - setMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): void; onTestFinished(testInfo: TestInfo): Promise; } diff --git a/playwright/testcontainers/dendrite.ts b/playwright/testcontainers/dendrite.ts index ce786d15c18..c358ff15852 100644 --- a/playwright/testcontainers/dendrite.ts +++ b/playwright/testcontainers/dendrite.ts @@ -13,6 +13,7 @@ import { randB64Bytes } from "../plugins/utils/rand.ts"; import { StartedSynapseContainer } from "./synapse.ts"; import { deepCopy } from "../plugins/utils/object.ts"; import { HomeserverContainer } from "./HomeserverContainer.ts"; +import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; const DEFAULT_CONFIG = { version: 2, @@ -235,6 +236,10 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon return this; } + public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this { + throw new Error("Dendrite does not support MAS."); + } + public override async start(): Promise { this.withCopyContentToContainer([ { diff --git a/playwright/testcontainers/mas.ts b/playwright/testcontainers/mas.ts index e60cb652982..697ef374a18 100644 --- a/playwright/testcontainers/mas.ts +++ b/playwright/testcontainers/mas.ts @@ -5,14 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; +import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait, ExecResult } from "testcontainers"; import { StartedPostgreSqlContainer } from "@testcontainers/postgresql"; import * as YAML from "yaml"; import { getFreePort } from "../plugins/utils/port.ts"; import { deepCopy } from "../plugins/utils/object.ts"; import { Credentials } from "../plugins/homeserver"; -import { ClientServerApi } from "./utils.ts"; const DEFAULT_CONFIG = { http: { @@ -227,10 +226,9 @@ export class StartedMatrixAuthenticationServiceContainer extends AbstractStarted super(container); } - public async getAdminToken(csApi: ClientServerApi): Promise { + public async getAdminToken(): Promise { if (this.adminTokenPromise === undefined) { this.adminTokenPromise = this.registerUserInternal( - csApi, "admin", "totalyinsecureadminpassword", undefined, @@ -240,20 +238,24 @@ export class StartedMatrixAuthenticationServiceContainer extends AbstractStarted return this.adminTokenPromise; } - private async registerUserInternal( - csApi: ClientServerApi, + private async manage(cmd: string, ...args: string[]): Promise { + const result = await this.exec(["mas-cli", "manage", cmd, ...this.args, ...args]); + if (result.exitCode !== 0) { + throw new Error(`Failed mas-cli manage ${cmd}: ${result.output}`); + } + return result; + } + + private async manageRegisterUser( username: string, password: string, displayName?: string, admin = false, - ): Promise { + ): Promise { const args: string[] = []; if (admin) args.push("-a"); - await this.exec([ - "mas-cli", - "manage", + const result = await this.manage( "register-user", - ...this.args, ...args, "-y", "-p", @@ -261,18 +263,62 @@ export class StartedMatrixAuthenticationServiceContainer extends AbstractStarted "-d", displayName ?? "", username, - ]); + ); - return csApi.loginUser(username, password); + const registerLines = result.output.trim().split("\n"); + const userId = registerLines + .find((line) => line.includes("Matrix ID: ")) + ?.split(": ") + .pop(); + + if (!userId) { + throw new Error(`Failed to register user: ${result.output}`); + } + + return userId; } - public async registerUser( - csApi: ClientServerApi, + private async manageIssueCompatibilityToken( + username: string, + admin = false, + ): Promise<{ accessToken: string; deviceId: string }> { + const args: string[] = []; + if (admin) args.push("--yes-i-want-to-grant-synapse-admin-privileges"); + const result = await this.manage("issue-compatibility-token", ...args, username); + + const parts = result.output.trim().split(/\s+/); + const accessToken = parts.find((part) => part.startsWith("mct_")); + const deviceId = parts.find((part) => part.startsWith("compat_session.device="))?.split("=")[1]; + + if (!accessToken || !deviceId) { + throw new Error(`Failed to issue compatibility token: ${result.output}`); + } + + return { accessToken, deviceId }; + } + + private async registerUserInternal( username: string, password: string, displayName?: string, + admin = false, ): Promise { - return this.registerUserInternal(csApi, username, password, displayName, false); + const userId = await this.manageRegisterUser(username, password, displayName, admin); + const { deviceId, accessToken } = await this.manageIssueCompatibilityToken(username, admin); + + return { + userId, + accessToken, + deviceId, + homeServer: userId.slice(1).split(":").slice(1).join(":"), + displayName, + username, + password, + }; + } + + public async registerUser(username: string, password: string, displayName?: string): Promise { + return this.registerUserInternal(username, password, displayName, false); } public async setThreepid(username: string, medium: string, address: string): Promise { @@ -280,6 +326,6 @@ export class StartedMatrixAuthenticationServiceContainer extends AbstractStarted throw new Error("Only email threepids are supported by MAS"); } - await this.exec(["mas-cli", "manage", "add-email", ...this.args, username, address]); + await this.manage("add-email", username, address); } } diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 8c5fafdbd5d..74625ca3b98 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; +import { + AbstractStartedContainer, + GenericContainer, + ImageName, + RestartOptions, + StartedTestContainer, + Wait, +} from "testcontainers"; import { APIRequestContext, TestInfo } from "@playwright/test"; import crypto from "node:crypto"; import * as YAML from "yaml"; @@ -144,6 +151,7 @@ export type SynapseConfigOptions = Partial; export class SynapseContainer extends GenericContainer implements HomeserverContainer { private config: typeof DEFAULT_CONFIG; + private mas?: StartedMatrixAuthenticationServiceContainer; constructor() { super(`ghcr.io/element-hq/synapse:${TAG}`); @@ -203,6 +211,11 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont return this; } + public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this { + this.mas = mas; + return this; + } + public override async start(): Promise { // Synapse config public_baseurl needs to know what URL it'll be accessed from, so we have to map the port manually const port = await getFreePort(); @@ -221,20 +234,26 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont }, ]); - return new StartedSynapseContainer( - await super.start(), - `http://localhost:${port}`, - this.config.registration_shared_secret, - ); + const container = await super.start(); + const baseUrl = `http://localhost:${port}`; + if (this.mas) { + return new StartedSynapseWithMasContainer( + container, + baseUrl, + this.config.registration_shared_secret, + this.mas, + ); + } + + return new StartedSynapseContainer(container, baseUrl, this.config.registration_shared_secret); } } export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer { - private adminTokenPromise?: Promise; - private _mas?: StartedMatrixAuthenticationServiceContainer; + protected adminTokenPromise?: Promise; protected _request?: APIRequestContext; - protected csApi: ClientServerApi; - protected adminApi: Api; + protected readonly adminApi: Api; + public readonly csApi: ClientServerApi; constructor( container: StartedTestContainer, @@ -242,8 +261,13 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements private readonly registrationSharedSecret: string, ) { super(container); - this.csApi = new ClientServerApi(this.baseUrl); this.adminApi = new Api(`${this.baseUrl}/_synapse/admin`); + this.csApi = new ClientServerApi(this.baseUrl); + } + + public restart(options?: Partial): Promise { + this.adminTokenPromise = undefined; + return super.restart(options); } public setRequest(request: APIRequestContext): void { @@ -252,10 +276,6 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements this.adminApi.setRequest(request); } - public setMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): void { - this._mas = mas; - } - public async onTestFinished(testInfo: TestInfo): Promise { // Clean up the server to prevent rooms leaking between tests await this.deletePublicRooms(); @@ -313,10 +333,6 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements protected async getAdminToken(): Promise { if (this.adminTokenPromise === undefined) { - if (this._mas) { - return (this.adminTokenPromise = this._mas.getAdminToken(this.csApi)); - } - this.adminTokenPromise = this.registerUserInternal( "admin", "totalyinsecureadminpassword", @@ -335,9 +351,6 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements } public registerUser(username: string, password: string, displayName?: string): Promise { - if (this._mas) { - return this._mas.registerUser(this.csApi, username, password, displayName); - } return this.registerUserInternal(username, password, displayName, false); } @@ -346,9 +359,6 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements } public async setThreepid(userId: string, medium: string, address: string): Promise { - if (this._mas) { - return this._mas.setThreepid(userId, medium, address); - } await this.adminRequest("PUT", `/v2/users/${userId}`, { threepids: [ { @@ -359,3 +369,29 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements }); } } + +export class StartedSynapseWithMasContainer extends StartedSynapseContainer { + constructor( + container: StartedTestContainer, + baseUrl: string, + registrationSharedSecret: string, + private readonly mas: StartedMatrixAuthenticationServiceContainer, + ) { + super(container, baseUrl, registrationSharedSecret); + } + + protected async getAdminToken(): Promise { + if (this.adminTokenPromise === undefined) { + this.adminTokenPromise = this.mas.getAdminToken(); + } + return this.adminTokenPromise; + } + + public registerUser(username: string, password: string, displayName?: string): Promise { + return this.mas.registerUser(username, password, displayName); + } + + public async setThreepid(userId: string, medium: string, address: string): Promise { + return this.mas.setThreepid(userId, medium, address); + } +} diff --git a/playwright/testcontainers/utils.ts b/playwright/testcontainers/utils.ts index c48c655c2e5..f4fe7f6d318 100644 --- a/playwright/testcontainers/utils.ts +++ b/playwright/testcontainers/utils.ts @@ -70,7 +70,9 @@ export class Api { }); if (!res.ok()) { - throw await res.json(); + throw new Error( + `Request to ${url} failed with status ${res.status()}: ${JSON.stringify(await res.json())}`, + ); } return res.json();