diff --git a/playwright/e2e/csAPI.ts b/playwright/e2e/csAPI.ts index d55816fb6a..4153d09199 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 "../plugins/utils/api.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); + 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/spotlight/spotlight.spec.ts b/playwright/e2e/spotlight/spotlight.spec.ts index d1bb3dec25..5ee80d6ea7 100644 --- a/playwright/e2e/spotlight/spotlight.spec.ts +++ b/playwright/e2e/spotlight/spotlight.spec.ts @@ -6,8 +6,8 @@ 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 type { AccountDataEvents } from "matrix-js-sdk/src/matrix"; -import { test, expect } from "../../element-web-test"; +import type { AccountDataEvents, Visibility } from "matrix-js-sdk/src/matrix"; +import { test as base, expect } from "../../element-web-test"; import { Filter } from "../../pages/Spotlight"; import { Bot } from "../../pages/bot"; import type { Locator, Page } from "@playwright/test"; @@ -38,41 +38,37 @@ async function startDM(app: ElementAppPage, page: Page, name: string): Promise { - const bot1Name = "BotBob"; - let bot1: Bot; - - const bot2Name = "ByteBot"; - let bot2: Bot; - - const room1Name = "247"; - let room1Id: string; - - const room2Name = "Lounge"; - let room2Id: string; - - const room3Name = "Public"; - let room3Id: string; - - test.use({ - displayName: "Jim", - }); - - test.beforeEach(async ({ page, homeserver, app, user }) => { - bot1 = new Bot(page, homeserver, { displayName: bot1Name, autoAcceptInvites: true }); - bot2 = new Bot(page, homeserver, { displayName: bot2Name, autoAcceptInvites: true }); - const Visibility = await page.evaluate(() => (window as any).matrixcs.Visibility); - - room1Id = await app.client.createRoom({ name: room1Name, visibility: Visibility.Public }); - - await bot1.joinRoom(room1Id); - const bot1UserId = await bot1.evaluate((client) => client.getUserId()); - room2Id = await bot2.createRoom({ name: room2Name, visibility: Visibility.Public }); - await bot2.inviteUser(room2Id, bot1UserId); - - room3Id = await bot2.createRoom({ - name: room3Name, - visibility: Visibility.Public, +type RoomRef = { name: string; roomId: string }; +const test = base.extend<{ + bot1: Bot; + bot2: Bot; + room1: RoomRef; + room2: RoomRef; + room3: RoomRef; +}>({ + bot1: async ({ page, homeserver }, use, testInfo) => { + const bot = new Bot(page, homeserver, { displayName: `BotBob_${testInfo.testId}`, autoAcceptInvites: true }); + await use(bot); + }, + bot2: async ({ page, homeserver }, use, testInfo) => { + const bot = new Bot(page, homeserver, { displayName: `ByteBot_${testInfo.testId}`, autoAcceptInvites: true }); + await use(bot); + }, + room1: async ({ app }, use) => { + const name = "247"; + const roomId = await app.client.createRoom({ name, visibility: "public" as Visibility }); + await use({ name, roomId }); + }, + room2: async ({ bot2 }, use) => { + const name = "Lounge"; + const roomId = await bot2.createRoom({ name, visibility: "public" as Visibility }); + await use({ name, roomId }); + }, + room3: async ({ bot2 }, use) => { + const name = "Public"; + const roomId = await bot2.createRoom({ + name, + visibility: "public" as Visibility, initial_state: [ { type: "m.room.history_visibility", @@ -83,9 +79,26 @@ test.describe("Spotlight", () => { }, ], }); - await bot2.inviteUser(room3Id, bot1UserId); + await use({ name, roomId }); + }, + context: async ({ context, homeserver }, use) => { + // Restart the homeserver to wipe its in-memory db so we can reuse the same user ID without cross-signing prompts + await homeserver.restart(); + await use(context); + }, +}); - await page.goto("/#/room/" + room1Id); +test.describe("Spotlight", () => { + test.use({ + displayName: "Jim", + }); + + test.beforeEach(async ({ page, user, bot1, bot2, room1, room2, room3 }) => { + await bot1.joinRoom(room1.roomId); + await bot2.inviteUser(room2.roomId, bot1.credentials.userId); + await bot2.inviteUser(room3.roomId, bot1.credentials.userId); + + await page.goto(`/#/room/${room1.roomId}`); await expect(page.locator(".mx_RoomSublist_skeletonUI")).not.toBeAttached(); }); @@ -117,69 +130,69 @@ test.describe("Spotlight", () => { await expect(spotlight.dialog.locator(".mx_SpotlightDialog_filter")).not.toBeAttached(); }); - test("should find joined rooms", async ({ page, app }) => { + test("should find joined rooms", async ({ page, app, room1 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle - await spotlight.search(room1Name); + await spotlight.search(room1.name); const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(room1Name); + await expect(resultLocator.first()).toContainText(room1.name); await resultLocator.first().click(); - await expect(page).toHaveURL(new RegExp(`#/room/${room1Id}`)); - await expect(roomHeaderName(page)).toContainText(room1Name); + await expect(page).toHaveURL(new RegExp(`#/room/${room1.roomId}`)); + await expect(roomHeaderName(page)).toContainText(room1.name); }); - test("should find known public rooms", async ({ page, app }) => { + test("should find known public rooms", async ({ page, app, room1 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.PublicRooms); - await spotlight.search(room1Name); + await spotlight.search(room1.name); const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(room1Name); + await expect(resultLocator.first()).toContainText(room1.name); await expect(resultLocator.first()).toContainText("View"); await resultLocator.first().click(); - await expect(page).toHaveURL(new RegExp(`#/room/${room1Id}`)); - await expect(roomHeaderName(page)).toContainText(room1Name); + await expect(page).toHaveURL(new RegExp(`#/room/${room1.roomId}`)); + await expect(roomHeaderName(page)).toContainText(room1.name); }); - test("should find unknown public rooms", async ({ page, app }) => { + test("should find unknown public rooms", async ({ page, app, room2 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.PublicRooms); - await spotlight.search(room2Name); + await spotlight.search(room2.name); const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(room2Name); + await expect(resultLocator.first()).toContainText(room2.name); await expect(resultLocator.first()).toContainText("Join"); await resultLocator.first().click(); - await expect(page).toHaveURL(new RegExp(`#/room/${room2Id}`)); + await expect(page).toHaveURL(new RegExp(`#/room/${room2.roomId}`)); await expect(page.locator(".mx_RoomView_MessageList")).toHaveCount(1); - await expect(roomHeaderName(page)).toContainText(room2Name); + await expect(roomHeaderName(page)).toContainText(room2.name); }); - test("should find unknown public world readable rooms", async ({ page, app }) => { + test("should find unknown public world readable rooms", async ({ page, app, room3 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.PublicRooms); - await spotlight.search(room3Name); + await spotlight.search(room3.name); const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(room3Name); + await expect(resultLocator.first()).toContainText(room3.name); await expect(resultLocator.first()).toContainText("View"); await resultLocator.first().click(); - await expect(page).toHaveURL(new RegExp(`#/room/${room3Id}`)); + await expect(page).toHaveURL(new RegExp(`#/room/${room3.roomId}`)); await page.getByRole("button", { name: "Join the discussion" }).click(); - await expect(roomHeaderName(page)).toHaveText(room3Name); + await expect(roomHeaderName(page)).toHaveText(room3.name); }); // TODO: We currently can’t test finding rooms on other homeservers/other protocols // We obviously don’t have federation or bridges in local e2e tests - test.skip("should find unknown public rooms on other homeservers", async ({ page, app }) => { + test.skip("should find unknown public rooms on other homeservers", async ({ page, app, room3 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.PublicRooms); - await spotlight.search(room3Name); + await spotlight.search(room3.name); await page.locator("[aria-haspopup=true][role=button]").click(); await page @@ -194,20 +207,20 @@ test.describe("Spotlight", () => { const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(room3Name); - await expect(resultLocator.first()).toContainText(room3Id); + await expect(resultLocator.first()).toContainText(room3.name); + await expect(resultLocator.first()).toContainText(room3.roomId); }); - test("should find known people", async ({ page, app }) => { + test("should find known people", async ({ page, app, bot1 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.People); - await spotlight.search(bot1Name); + await spotlight.search(bot1.credentials.displayName); const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(bot1Name); + await expect(resultLocator.first()).toContainText(bot1.credentials.displayName); await resultLocator.first().click(); - await expect(roomHeaderName(page)).toHaveText(bot1Name); + await expect(roomHeaderName(page)).toHaveText(bot1.credentials.displayName); }); /** @@ -217,42 +230,41 @@ test.describe("Spotlight", () => { * * https://github.com/matrix-org/synapse/issues/16472 */ - test("should find unknown people", async ({ page, app }) => { + test("should find unknown people", async ({ page, app, bot2 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.People); - await spotlight.search(bot2Name); + await spotlight.search(bot2.credentials.displayName); const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(bot2Name); + await expect(resultLocator.first()).toContainText(bot2.credentials.displayName); await resultLocator.first().click(); - await expect(roomHeaderName(page)).toHaveText(bot2Name); + await expect(roomHeaderName(page)).toHaveText(bot2.credentials.displayName); }); - test("should find group DMs by usernames or user ids", async ({ page, app }) => { + test("should find group DMs by usernames or user ids", async ({ page, app, bot1, bot2, room1 }) => { // First we want to share a room with both bots to ensure we’ve got their usernames cached - const bot2UserId = await bot2.evaluate((client) => client.getUserId()); - await app.client.inviteUser(room1Id, bot2UserId); + await app.client.inviteUser(room1.roomId, bot2.credentials.userId); // Starting a DM with ByteBot (will be turned into a group dm later) let spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.People); - await spotlight.search(bot2Name); + await spotlight.search(bot2.credentials.displayName); let resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(bot2Name); + await expect(resultLocator.first()).toContainText(bot2.credentials.displayName); await resultLocator.first().click(); // Send first message to actually start DM - await expect(roomHeaderName(page)).toHaveText(bot2Name); + await expect(roomHeaderName(page)).toHaveText(bot2.credentials.displayName); const locator = page.getByRole("textbox", { name: "Send a message…" }); await locator.fill("Hey!"); await locator.press("Enter"); // Assert DM exists by checking for the first message and the room being in the room list await expect(page.locator(".mx_EventTile_body").filter({ hasText: "Hey!" })).toBeAttached({ timeout: 3000 }); - await expect(page.getByRole("group", { name: "People" })).toContainText(bot2Name); + await expect(page.getByRole("group", { name: "People" })).toContainText(bot2.credentials.displayName); // Invite BotBob into existing DM with ByteBot const dmRooms = await app.client.evaluate((client, userId) => { @@ -260,18 +272,17 @@ test.describe("Spotlight", () => { .getAccountData("m.direct" as keyof AccountDataEvents) ?.getContent>(); return map[userId] ?? []; - }, bot2UserId); + }, bot2.credentials.userId); expect(dmRooms).toHaveLength(1); const groupDmName = await app.client.evaluate((client, id) => client.getRoom(id).name, dmRooms[0]); - const bot1UserId = await bot1.evaluate((client) => client.getUserId()); - await app.client.inviteUser(dmRooms[0], bot1UserId); + await app.client.inviteUser(dmRooms[0], bot1.credentials.userId); await expect(roomHeaderName(page).first()).toContainText(groupDmName); await expect(page.getByRole("group", { name: "People" }).first()).toContainText(groupDmName); // Search for BotBob by id, should return group DM and user spotlight = await app.openSpotlight(); await spotlight.filter(Filter.People); - await spotlight.search(bot1UserId); + await spotlight.search(bot1.credentials.userId); await page.waitForTimeout(1000); // wait for the dialog to settle resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(2); @@ -284,7 +295,7 @@ test.describe("Spotlight", () => { // Search for ByteBot by id, should return group DM and user spotlight = await app.openSpotlight(); await spotlight.filter(Filter.People); - await spotlight.search(bot2UserId); + await spotlight.search(bot2.credentials.userId); await page.waitForTimeout(1000); // wait for the dialog to settle resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(2); @@ -297,11 +308,10 @@ test.describe("Spotlight", () => { }); // Test against https://github.com/vector-im/element-web/issues/22851 - test("should show each person result only once", async ({ page, app }) => { + test("should show each person result only once", async ({ page, app, bot1 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.People); - const bot1UserId = await bot1.evaluate((client) => client.getUserId()); // 2 rounds of search to simulate the bug conditions. Specifically, the first search // should have 1 result (not 2) and the second search should also have 1 result (instead @@ -310,24 +320,24 @@ test.describe("Spotlight", () => { // We search for user ID to trigger the profile lookup within the dialog. for (let i = 0; i < 2; i++) { console.log("Iteration: " + i); - await spotlight.search(bot1UserId); + await spotlight.search(bot1.credentials.userId); await page.waitForTimeout(1000); // wait for the dialog to settle const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(bot1UserId); + await expect(resultLocator.first()).toContainText(bot1.credentials.userId); } }); - test("should allow opening group chat dialog", async ({ page, app }) => { + test("should allow opening group chat dialog", async ({ page, app, bot2 }) => { const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.People); - await spotlight.search(bot2Name); + await spotlight.search(bot2.credentials.displayName); await page.waitForTimeout(3000); // wait for the dialog to settle const resultLocator = spotlight.results; await expect(resultLocator).toHaveCount(1); - await expect(resultLocator.first()).toContainText(bot2Name); + await expect(resultLocator.first()).toContainText(bot2.credentials.displayName); await expect(spotlight.dialog.locator(".mx_SpotlightDialog_startGroupChat")).toContainText( "Start a group chat", @@ -336,18 +346,18 @@ test.describe("Spotlight", () => { await expect(page.getByRole("dialog")).toContainText("Direct Messages"); }); - test("should close spotlight after starting a DM", async ({ page, app }) => { - await startDM(app, page, bot1Name); + test("should close spotlight after starting a DM", async ({ page, app, bot1 }) => { + await startDM(app, page, bot1.credentials.displayName); await expect(page.locator(".mx_SpotlightDialog")).toHaveCount(0); }); - test("should show the same user only once", async ({ page, app }) => { - await startDM(app, page, bot1Name); + test("should show the same user only once", async ({ page, app, bot1 }) => { + await startDM(app, page, bot1.credentials.displayName); await page.goto("/#/home"); const spotlight = await app.openSpotlight(); await page.waitForTimeout(500); // wait for the dialog to settle await spotlight.filter(Filter.People); - await spotlight.search(bot1Name); + await spotlight.search(bot1.credentials.displayName); await page.waitForTimeout(3000); // wait for the dialog to settle await expect(spotlight.dialog.locator(".mx_Spinner")).not.toBeAttached(); const resultLocator = spotlight.results; diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 1a43820341..9468ddeec3 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -49,7 +49,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 50dea472f7..b6956a8267 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 "../utils/api.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/plugins/utils/api.ts b/playwright/plugins/utils/api.ts new file mode 100644 index 0000000000..40dade1302 --- /dev/null +++ b/playwright/plugins/utils/api.ts @@ -0,0 +1,76 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { APIRequestContext } from "@playwright/test"; + +import { Credentials } from "../homeserver"; + +export type Verb = "GET" | "POST" | "PUT" | "DELETE"; + +export class Api { + private _request?: APIRequestContext; + + constructor(private readonly baseUrl: string) {} + + public setRequest(request: APIRequestContext): void { + this._request = request; + } + + public async request(verb: "GET", path: string, token?: string, data?: never): Promise; + public async request(verb: Verb, path: string, token?: string, data?: object): Promise; + public async request(verb: Verb, path: string, token?: string, data?: object): Promise { + const url = `${this.baseUrl}${path}`; + const res = await this._request.fetch(url, { + data, + method: verb, + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + }); + + if (!res.ok()) { + throw new Error( + `Request to ${url} failed with status ${res.status()}: ${JSON.stringify(await res.json())}`, + ); + } + + return res.json(); + } +} + +export class ClientServerApi extends Api { + constructor(baseUrl: string) { + super(`${baseUrl}/_matrix/client`); + } + + public async loginUser(userId: string, password: string): Promise { + const json = await this.request<{ + access_token: string; + user_id: string; + device_id: string; + home_server: string; + }>("POST", "/v3/login", undefined, { + type: "m.login.password", + identifier: { + type: "m.id.user", + user: userId, + }, + password: password, + }); + + return { + password, + accessToken: json.access_token, + userId: json.user_id, + deviceId: json.device_id, + homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"), + username: userId.slice(1).split(":")[0], + }; + } +} diff --git a/playwright/services.ts b/playwright/services.ts index c5d1e278d1..231940938d 100644 --- a/playwright/services.ts +++ b/playwright/services.ts @@ -119,6 +119,7 @@ export const test = base.extend({ .withNetworkAliases("homeserver") .withLogConsumer(logger.getConsumer("synapse")) .withConfig(synapseConfig) + .withMatrixAuthenticationService(mas) .start(); await use(container); @@ -141,5 +142,6 @@ export const test = base.extend({ await logger.onTestStarted(context); await use(context); await logger.onTestFinished(testInfo); + await homeserver.onTestFinished(testInfo); }, }); diff --git a/playwright/testcontainers/HomeserverContainer.ts b/playwright/testcontainers/HomeserverContainer.ts index 09eea7da77..259ecb7fe0 100644 --- a/playwright/testcontainers/HomeserverContainer.ts +++ b/playwright/testcontainers/HomeserverContainer.ts @@ -6,17 +6,19 @@ Please see LICENSE files in the repository root for full details. */ import { AbstractStartedContainer, GenericContainer } from "testcontainers"; -import { APIRequestContext } from "@playwright/test"; +import { APIRequestContext, TestInfo } from "@playwright/test"; -import { StartedSynapseContainer } from "./synapse.ts"; import { HomeserverInstance } from "../plugins/homeserver"; +import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; export interface HomeserverContainer extends GenericContainer { withConfigField(key: string, value: any): this; withConfig(config: Partial): this; - start(): Promise; + withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this; + start(): Promise; } export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance { setRequest(request: APIRequestContext): void; + onTestFinished(testInfo: TestInfo): Promise; } diff --git a/playwright/testcontainers/dendrite.ts b/playwright/testcontainers/dendrite.ts index 629ea70c65..c358ff1585 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,7 +236,11 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon return this; } - public override async start(): Promise { + public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this { + throw new Error("Dendrite does not support MAS."); + } + + public override async start(): Promise { this.withCopyContentToContainer([ { target: "/etc/dendrite/dendrite.yaml", @@ -244,8 +249,7 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon ]); const container = await super.start(); - // Surprisingly, Dendrite implements the same register user Admin API Synapse, so we can just extend it - return new StartedSynapseContainer( + return new StartedDendriteContainer( container, `http://${container.getHost()}:${container.getMappedPort(8008)}`, this.config.client_api.registration_shared_secret, @@ -258,3 +262,6 @@ export class PineconeContainer extends DendriteContainer { super("matrixdotorg/dendrite-demo-pinecone:main", "/usr/bin/dendrite-demo-pinecone"); } } + +// Surprisingly, Dendrite implements the same register user Synapse Admin API, so we can just extend it +export class StartedDendriteContainer extends StartedSynapseContainer {} diff --git a/playwright/testcontainers/mas.ts b/playwright/testcontainers/mas.ts index 2c795c2c47..9b05b521ba 100644 --- a/playwright/testcontainers/mas.ts +++ b/playwright/testcontainers/mas.ts @@ -5,12 +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"; const DEFAULT_CONFIG = { http: { @@ -18,18 +19,10 @@ const DEFAULT_CONFIG = { { name: "web", resources: [ - { - name: "discovery", - }, - { - name: "human", - }, - { - name: "oauth", - }, - { - name: "compat", - }, + { name: "discovery" }, + { name: "human" }, + { name: "oauth" }, + { name: "compat" }, { name: "graphql", playground: true, @@ -182,9 +175,12 @@ const DEFAULT_CONFIG = { export class MatrixAuthenticationServiceContainer extends GenericContainer { private config: typeof DEFAULT_CONFIG; + private readonly args = ["-c", "/config/config.yaml"]; constructor(db: StartedPostgreSqlContainer) { - super("ghcr.io/element-hq/matrix-authentication-service:0.12.0"); + // We rely on `mas-cli manage add-email` which isn't in a release yet + // https://github.com/element-hq/matrix-authentication-service/pull/3235 + super("ghcr.io/element-hq/matrix-authentication-service:sha-0b90c33"); this.config = deepCopy(DEFAULT_CONFIG); this.config.database.username = db.getUsername(); @@ -192,7 +188,7 @@ export class MatrixAuthenticationServiceContainer extends GenericContainer { this.withExposedPorts(8080, 8081) .withWaitStrategy(Wait.forHttp("/health", 8081)) - .withCommand(["server", "--config", "/config/config.yaml"]); + .withCommand(["server", ...this.args]); } public withConfig(config: object): this { @@ -220,15 +216,125 @@ export class MatrixAuthenticationServiceContainer extends GenericContainer { }, ]); - return new StartedMatrixAuthenticationServiceContainer(await super.start(), `http://localhost:${port}`); + return new StartedMatrixAuthenticationServiceContainer( + await super.start(), + `http://localhost:${port}`, + this.args, + ); } } export class StartedMatrixAuthenticationServiceContainer extends AbstractStartedContainer { + private adminTokenPromise?: Promise; + constructor( container: StartedTestContainer, public readonly baseUrl: string, + private readonly args: string[], ) { super(container); } + + public async getAdminToken(): Promise { + if (this.adminTokenPromise === undefined) { + this.adminTokenPromise = this.registerUserInternal( + "admin", + "totalyinsecureadminpassword", + undefined, + true, + ).then((res) => res.accessToken); + } + return this.adminTokenPromise; + } + + 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 { + const args: string[] = []; + if (admin) args.push("-a"); + const result = await this.manage( + "register-user", + ...args, + "-y", + "-p", + password, + "-d", + displayName ?? "", + username, + ); + + 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; + } + + 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 { + 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 { + if (medium !== "email") { + throw new Error("Only email threepids are supported by MAS"); + } + + await this.manage("add-email", username, address); + } } diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 3469082d10..87017498f4 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import { AbstractStartedContainer, GenericContainer, RestartOptions, StartedTestContainer, Wait } from "testcontainers"; -import { APIRequestContext } from "@playwright/test"; +import { APIRequestContext, TestInfo } from "@playwright/test"; import crypto from "node:crypto"; import * as YAML from "yaml"; import { set } from "lodash"; @@ -16,6 +16,8 @@ import { randB64Bytes } from "../plugins/utils/rand.ts"; import { Credentials } from "../plugins/homeserver"; import { deepCopy } from "../plugins/utils/object.ts"; import { HomeserverContainer, StartedHomeserverContainer } from "./HomeserverContainer.ts"; +import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; +import { Api, ClientServerApi, Verb } from "../plugins/utils/api.ts"; const TAG = "develop@sha256:7be2e00da62dfbb2bad071c6d408fecb1fabf740a538d08768b9b3e0a8c45350"; @@ -142,6 +144,7 @@ export type SynapseConfig = Partial; export class SynapseContainer extends GenericContainer implements HomeserverContainer { private config: typeof DEFAULT_CONFIG; + private mas?: StartedMatrixAuthenticationServiceContainer; constructor() { super(`ghcr.io/element-hq/synapse:${TAG}`); @@ -201,6 +204,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(); @@ -219,17 +227,25 @@ 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 adminToken?: string; - private request?: APIRequestContext; + protected adminTokenPromise?: Promise; + protected readonly adminApi: Api; + public readonly csApi: ClientServerApi; constructor( container: StartedTestContainer, @@ -237,15 +253,36 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements private readonly registrationSharedSecret: string, ) { super(container); + this.adminApi = new Api(`${this.baseUrl}/_synapse/admin`); + this.csApi = new ClientServerApi(this.baseUrl); } public restart(options?: Partial): Promise { - this.adminToken = undefined; + this.adminTokenPromise = undefined; return super.restart(options); } public setRequest(request: APIRequestContext): void { - this.request = request; + this.csApi.setRequest(request); + this.adminApi.setRequest(request); + } + + public async onTestFinished(testInfo: TestInfo): Promise { + // Clean up the server to prevent rooms leaking between tests + await this.deletePublicRooms(); + } + + protected async deletePublicRooms(): Promise { + const token = await this.getAdminToken(); + // We hide the rooms from the room directory to save time between tests and for portability between homeservers + const { chunk: rooms } = await this.csApi.request<{ + chunk: { room_id: string }[]; + }>("GET", "/v3/publicRooms", token, {}); + await Promise.all( + rooms.map((room) => + this.csApi.request("PUT", `/v3/directory/list/room/${room.room_id}`, token, { visibility: "private" }), + ), + ); } private async registerUserInternal( @@ -254,28 +291,26 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements displayName?: string, admin = false, ): Promise { - const url = `${this.baseUrl}/_synapse/admin/v1/register`; - const { nonce } = await this.request.get(url).then((r) => r.json()); + const path = "/v1/register"; + const { nonce } = await this.adminApi.request<{ nonce: string }>("GET", path, undefined, {}); const mac = crypto .createHmac("sha1", this.registrationSharedSecret) .update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`) .digest("hex"); - const res = await this.request.post(url, { - data: { - nonce, - username, - password, - mac, - admin, - displayname: displayName, - }, + const data = await this.adminApi.request<{ + home_server: string; + access_token: string; + user_id: string; + device_id: string; + }>("POST", path, undefined, { + nonce, + username, + password, + mac, + admin, + displayname: displayName, }); - if (!res.ok()) { - throw await res.json(); - } - - const data = await res.json(); return { homeServer: data.home_server || data.user_id.split(":").slice(1).join(":"), accessToken: data.access_token, @@ -287,57 +322,67 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements }; } + protected async getAdminToken(): Promise { + if (this.adminTokenPromise === undefined) { + this.adminTokenPromise = this.registerUserInternal( + "admin", + "totalyinsecureadminpassword", + undefined, + true, + ).then((res) => res.accessToken); + } + return this.adminTokenPromise; + } + + private async adminRequest(verb: "GET", path: string, data?: never): Promise; + private async adminRequest(verb: Verb, path: string, data?: object): Promise; + private async adminRequest(verb: Verb, path: string, data?: object): Promise { + const adminToken = await this.getAdminToken(); + return this.adminApi.request(verb, path, adminToken, data); + } + 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.baseUrl}/_matrix/client/v3/login`; - const res = await this.request.post(url, { - data: { - type: "m.login.password", - identifier: { - type: "m.id.user", - user: userId, - }, - password: password, - }, - }); - const json = await res.json(); - - return { - password, - accessToken: json.access_token, - userId: json.user_id, - deviceId: json.device_id, - homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"), - username: userId.slice(1).split(":")[0], - }; + return this.csApi.loginUser(userId, password); } 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.baseUrl}/_synapse/admin/v2/users/${userId}`; - const res = await this.request.put(url, { - data: { - threepids: [ - { - medium, - address, - }, - ], - }, - headers: { - Authorization: `Bearer ${this.adminToken}`, - }, + await this.adminRequest("PUT", `/v2/users/${userId}`, { + threepids: [ + { + medium, + address, + }, + ], }); - - if (!res.ok()) { - throw await res.json(); - } + } +} + +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); } }