diff --git a/playwright/e2e/spotlight/spotlight.spec.ts b/playwright/e2e/spotlight/spotlight.spec.ts index da35ca57b3..d4451b9b5c 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).toHaveCount(2); + await expect(resultLocator.count()).resolves.toBeGreaterThan(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.last()).toHaveAttribute("aria-selected", "true"); + await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "true"); await spotlight.searchBox.press("ArrowDown"); resultLocator = spotlight.results; await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false"); - await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false"); + await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "false"); await spotlight.searchBox.press("ArrowUp"); resultLocator = spotlight.results; await expect(resultLocator.first()).toHaveAttribute("aria-selected", "false"); - await expect(resultLocator.last()).toHaveAttribute("aria-selected", "true"); + await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "true"); await spotlight.searchBox.press("ArrowUp"); resultLocator = spotlight.results; await expect(resultLocator.first()).toHaveAttribute("aria-selected", "true"); - await expect(resultLocator.last()).toHaveAttribute("aria-selected", "false"); + await expect(resultLocator.nth(2)).toHaveAttribute("aria-selected", "false"); }); }); diff --git a/playwright/services.ts b/playwright/services.ts index a083304fa1..4fe05b1282 100644 --- a/playwright/services.ts +++ b/playwright/services.ts @@ -116,6 +116,8 @@ export const test = base.extend<{}, Services>({ .withConfig(synapseConfigOptions) .start(); + container.setMatrixAuthenticationService(mas); + await use(container); await container.stop(); }, diff --git a/playwright/testcontainers/HomeserverContainer.ts b/playwright/testcontainers/HomeserverContainer.ts index 73c9882418..e825b6e554 100644 --- a/playwright/testcontainers/HomeserverContainer.ts +++ b/playwright/testcontainers/HomeserverContainer.ts @@ -9,6 +9,7 @@ import { AbstractStartedContainer, GenericContainer } from "testcontainers"; import { APIRequestContext, TestInfo } from "@playwright/test"; import { HomeserverInstance } from "../plugins/homeserver"; +import { StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; export interface HomeserverContainer extends GenericContainer { withConfigField(key: string, value: any): this; @@ -18,5 +19,6 @@ export interface HomeserverContainer extends GenericContainer { 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 517b0f7a64..ce786d15c1 100644 --- a/playwright/testcontainers/dendrite.ts +++ b/playwright/testcontainers/dendrite.ts @@ -258,5 +258,5 @@ export class PineconeContainer extends DendriteContainer { } } -// Surprisingly, Dendrite implements the same register user Admin API Synapse, so we can just extend it +// 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 d15f619dbc..e60cb65298 100644 --- a/playwright/testcontainers/mas.ts +++ b/playwright/testcontainers/mas.ts @@ -11,6 +11,8 @@ 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: { @@ -18,18 +20,11 @@ const DEFAULT_CONFIG = { { name: "web", resources: [ - { - name: "discovery", - }, - { - name: "human", - }, - { - name: "oauth", - }, - { - name: "compat", - }, + { name: "discovery" }, + { name: "human" }, + { name: "oauth" }, + { name: "compat" }, + { name: "adminapi" }, { name: "graphql", playground: true, @@ -172,9 +167,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(); @@ -182,7 +180,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 { @@ -210,15 +208,78 @@ 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(csApi: ClientServerApi): Promise { + if (this.adminTokenPromise === undefined) { + this.adminTokenPromise = this.registerUserInternal( + csApi, + "admin", + "totalyinsecureadminpassword", + undefined, + true, + ).then((res) => res.accessToken); + } + return this.adminTokenPromise; + } + + private async registerUserInternal( + csApi: ClientServerApi, + username: string, + password: string, + displayName?: string, + admin = false, + ): Promise { + const args: string[] = []; + if (admin) args.push("-a"); + await this.exec([ + "mas-cli", + "manage", + "register-user", + ...this.args, + ...args, + "-y", + "-p", + password, + "-d", + displayName ?? "", + username, + ]); + + return csApi.loginUser(username, password); + } + + public async registerUser( + csApi: ClientServerApi, + username: string, + password: string, + displayName?: string, + ): Promise { + return this.registerUserInternal(csApi, 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.exec(["mas-cli", "manage", "add-email", ...this.args, username, address]); + } } diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 27249b7595..05b5702328 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -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 "./utils.ts"; const TAG = "develop@sha256:b69222d98abe9625d46f5d3cb01683d5dc173ae339215297138392cfeec935d9"; @@ -138,8 +140,6 @@ const DEFAULT_CONFIG = { }, }; -type Verb = "GET" | "POST" | "PUT" | "DELETE"; - export type SynapseConfigOptions = Partial; export class SynapseContainer extends GenericContainer implements HomeserverContainer { @@ -231,7 +231,10 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer { private adminTokenPromise?: Promise; + private _mas?: StartedMatrixAuthenticationServiceContainer; protected _request?: APIRequestContext; + protected csApi: ClientServerApi; + protected adminApi: Api; constructor( container: StartedTestContainer, @@ -239,10 +242,17 @@ 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/`); } public setRequest(request: APIRequestContext): void { this._request = request; + this.csApi.setRequest(request); + } + + public setMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): void { + this._mas = mas; } public async onTestFinished(testInfo: TestInfo): Promise { @@ -251,13 +261,14 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements } 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.request<{ + const { chunk: rooms } = await this.csApi.request<{ chunk: { room_id: string }[]; - }>("GET", "v3/publicRooms", {}); + }>("GET", "v3/publicRooms", token, {}); await Promise.all( rooms.map((room) => - this.request("PUT", `v3/directory/list/room/${room.room_id}`, { visibility: "private" }), + this.csApi.request("PUT", `v3/directory/list/room/${room.room_id}`, token, { visibility: "private" }), ), ); } @@ -268,13 +279,18 @@ 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, { + const data = await this.adminApi.request<{ + home_server: string; + access_token: string; + user_id: string; + device_id: string; + }>("POST", path, undefined, { data: { nonce, username, @@ -285,11 +301,6 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements }, }); - if (!res.ok()) { - throw await res.json(); - } - - const data = await res.json(); return { homeServer: data.home_server, accessToken: data.access_token, @@ -303,6 +314,10 @@ 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", @@ -317,72 +332,24 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements 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(); - const url = `${this.baseUrl}/_synapse/admin/${path}`; - const res = await this._request.fetch(url, { - data, - method: verb, - headers: { - Authorization: `Bearer ${adminToken}`, - }, - }); - - if (!res.ok()) { - throw await res.json(); - } - - return res.json(); - } - - public async request(verb: "GET", path: string, data?: never): Promise; - public async request(verb: Verb, path: string, data?: object): Promise; - public async request(verb: Verb, path: string, data?: object): Promise { - const token = await this.getAdminToken(); - const url = `${this.baseUrl}/_matrix/client/${path}`; - const res = await this._request.fetch(url, { - data, - method: verb, - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!res.ok()) { - throw await res.json(); - } - - return res.json(); + return this.adminApi.request(verb, path, adminToken, data); } 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); } 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", { - 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, - username: userId.slice(1).split(":")[0], - }; + return this.csApi.loginUser(userId, password); } 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: [ { diff --git a/playwright/testcontainers/utils.ts b/playwright/testcontainers/utils.ts index 1339e9c2fc..487562f8af 100644 --- a/playwright/testcontainers/utils.ts +++ b/playwright/testcontainers/utils.ts @@ -5,10 +5,12 @@ 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 { TestInfo } from "@playwright/test"; +import { APIRequestContext, TestInfo } from "@playwright/test"; import { Readable } from "stream"; import stripAnsi from "strip-ansi"; +import { Credentials } from "../plugins/homeserver"; + export class ContainerLogger { private logs: Record = {}; @@ -41,3 +43,67 @@ export class ContainerLogger { } } } + +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}/_matrix/client/${path}`; + const res = await this._request.fetch(url, { + data, + method: verb, + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + }); + + if (!res.ok()) { + throw 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, + username: userId.slice(1).split(":")[0], + }; + } +}