diff --git a/modules/restricted-guests/element-web/e2e/restricted-guests.spec.ts b/modules/restricted-guests/element-web/e2e/restricted-guests.spec.ts index 6550f1f3..47ac837b 100644 --- a/modules/restricted-guests/element-web/e2e/restricted-guests.spec.ts +++ b/modules/restricted-guests/element-web/e2e/restricted-guests.spec.ts @@ -6,24 +6,23 @@ Please see LICENSE files in the repository root for full details. */ import { - MatrixAuthenticationServiceContainer, type MasConfig, type StartedMatrixAuthenticationServiceContainer, type StartedSynapseContainer, - type SynapseConfig, + type SynapseContainer, } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js"; -import type { Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api"; -import type { Fixtures } from "@playwright/test"; +import { type Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api"; +import { makePostgres } from "@element-hq/element-web-playwright-common/lib/testcontainers/postgres.js"; +import { makeMas } from "@element-hq/element-web-playwright-common/lib/testcontainers/mas.js"; -import { test as base, expect } from "../../../../playwright/element-web-test"; import { RestrictedGuestsSynapseContainer, RestrictedGuestsSynapseWithMasContainer } from "./services"; +import { test as subBase, expect } from "../../../../playwright/element-web-test"; const MAS_CLIENT_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; const MAS_CLIENT_SECRET = "restricted-guests-secret"; const MAS_SHARED_SECRET = "restricted-guests-shared-secret"; -const MAS_INTERNAL_URL = "http://mas:8080"; +const MAS_INTERNAL_URL = "http://guest-mas:8080"; const GUEST_HOMESERVER_NAME = "guest-homeserver"; -const GUEST_HOMESERVER_INTERNAL_URL = "http://guest-homeserver:8008"; const MAS_HTTP_LISTENERS: NonNullable["listeners"] = [ { @@ -60,17 +59,11 @@ const MAS_HTTP_LISTENERS: NonNullable["listeners"] = [ }, ]; -const MAS_CONFIG: Partial = { +const BASE_MAS_CONFIG: Partial = { http: { listeners: MAS_HTTP_LISTENERS, public_base: "", }, - matrix: { - kind: "synapse", - homeserver: GUEST_HOMESERVER_NAME, - endpoint: GUEST_HOMESERVER_INTERNAL_URL, - secret: MAS_SHARED_SECRET, - }, policy: { data: { admin_clients: [MAS_CLIENT_ID], @@ -88,17 +81,27 @@ const MAS_CONFIG: Partial = { ], }; -const applySharedTestConfig = (testInstance: typeof base) => { - testInstance.use({ - displayName: "Tommy", - synapseConfig: { - allow_guest_access: true, - }, - labsFlags: ["feature_ask_to_join"], - }); -}; +declare module "@element-hq/element-web-module-api" { + export interface Config { + embedded_pages?: { + login_for_welcome?: boolean; + }; + } +} + +// We do some wacky things here in order to run the test suite against multiple homeserver configurations +const base = subBase.extend< + { + testRoomId: string; + }, + { + auth: "mas" | "legacy"; -const sharedFixtures: Fixtures<{ testRoomId: string }, { bot: Credentials }, any, any> = { + bot: Credentials; + guestMas?: StartedMatrixAuthenticationServiceContainer; + guestHomeserver: StartedSynapseContainer; + } +>({ testRoomId: [ async ({ homeserver, bot }, use) => { const { room_id: roomId } = (await homeserver.csApi.request("POST", "/v3/createRoom", bot.accessToken, { @@ -125,158 +128,178 @@ const sharedFixtures: Fixtures<{ testRoomId: string }, { bot: Credentials }, any }, { scope: "worker" }, ], -}; - -const test = base.extend< - { - testRoomId: string; - }, - { - guestHomeserver: StartedSynapseContainer; - bot: Credentials; - } ->({ - ...sharedFixtures, - guestHomeserver: [ - async ({ logger, synapseConfig, network }, use) => { - const container = await new RestrictedGuestsSynapseContainer() - .withConfig(synapseConfig) - .withConfig({ server_name: GUEST_HOMESERVER_NAME }) - .withNetwork(network) - .withNetworkAliases(GUEST_HOMESERVER_NAME) - .withLogConsumer(logger.getConsumer("guest_homeserver")) - .start(); + auth: ["mas", { scope: "worker" }], + // Optional MAS on the default homeserver, enabled only when we are testing the non-guest login UX + mas: [ + async ({ logger, network, postgres, auth, synapseConfig }, use) => { + if (auth !== "mas" || synapseConfig.allow_guest_access !== false) { + return use(undefined); + } + const container = await makeMas( + postgres, + network, + logger, + { + ...BASE_MAS_CONFIG, + matrix: { + kind: "synapse", + homeserver: "homeserver", + endpoint: "http://homeserver:8008", + secret: MAS_SHARED_SECRET, + }, + }, + "mas", + ); await use(container); await container.stop(); }, { scope: "worker" }, ], -}); - -const masTest = base.extend< - { - testRoomId: string; - }, - { - guestHomeserver: StartedSynapseContainer; - guestMas: StartedMatrixAuthenticationServiceContainer; - bot: Credentials; - } ->({ - ...sharedFixtures, + // Optional MAS on the module homeserver guestMas: [ - async ({ logger, network, postgres }, use) => { - const container = await new MatrixAuthenticationServiceContainer(postgres) - .withNetwork(network) - .withNetworkAliases("mas") - .withLogConsumer(logger.getConsumer("guest_mas")) - .withConfig(MAS_CONFIG) - .start(); + async ({ logger, network, auth }, use) => { + if (auth !== "mas") { + return use(undefined); + } + + // We need a separate postgres so it doesn't fight with the default MAS + const postgres = await makePostgres(network, logger, "guest-mas-postgres"); + const container = await makeMas( + postgres, + network, + logger, + { + ...BASE_MAS_CONFIG, + matrix: { + kind: "synapse", + homeserver: GUEST_HOMESERVER_NAME, + endpoint: "http://guest-homeserver:8008", + secret: MAS_SHARED_SECRET, + }, + }, + "guest-mas", + ); await use(container); await container.stop(); + await postgres.stop(); }, { scope: "worker" }, ], + // Module homeserver guestHomeserver: [ async ({ logger, synapseConfig, network, guestMas }, use) => { - const container = await new RestrictedGuestsSynapseWithMasContainer({ - adminApiBaseUrl: MAS_INTERNAL_URL, - oauthBaseUrl: MAS_INTERNAL_URL, - clientId: MAS_CLIENT_ID, - clientSecret: MAS_CLIENT_SECRET, - }) + let container: SynapseContainer; + if (guestMas) { + container = new RestrictedGuestsSynapseWithMasContainer({ + adminApiBaseUrl: MAS_INTERNAL_URL, + oauthBaseUrl: MAS_INTERNAL_URL, + clientId: MAS_CLIENT_ID, + clientSecret: MAS_CLIENT_SECRET, + }).withMatrixAuthenticationService(guestMas); + } else { + container = new RestrictedGuestsSynapseContainer(); + } + + const startedContainer = await container .withConfig(synapseConfig) .withConfig({ server_name: GUEST_HOMESERVER_NAME, - matrix_authentication_service: { - enabled: true, - endpoint: `${MAS_INTERNAL_URL}/`, - secret: MAS_SHARED_SECRET, - }, - // Must be disabled when using MAS. - password_config: { - enabled: false, - }, - // Must be disabled when using MAS. - enable_registration: false, - } as Partial) - .withMatrixAuthenticationService(guestMas) + }) .withNetwork(network) .withNetworkAliases(GUEST_HOMESERVER_NAME) .withLogConsumer(logger.getConsumer("guest_homeserver")) .start(); - await use(container); - await container.stop(); + await use(startedContainer); + await startedContainer.stop(); }, { scope: "worker" }, ], + displayName: "Tommy", + labsFlags: ["feature_ask_to_join"], + config: { + embedded_pages: { + login_for_welcome: true, + }, + }, }); -type RestrictedGuestsTestInstance = typeof test; - -const defineRestrictedGuestsTests = (testInstance: RestrictedGuestsTestInstance, suiteName: string) => { - applySharedTestConfig(testInstance); - - testInstance.describe(suiteName, () => { - testInstance.use({ - page: async ({ page }, use) => { - await page.goto("/"); - await use(page); +base.slow(); +for (const auth of ["mas", "legacy"] as const) { + for (const guestsEnabled of [true, false]) { + const test = base.extend({ + auth, + synapseConfig: { + allow_guest_access: guestsEnabled, }, }); - testInstance("should error if config is missing", async ({ page }) => { - await expect(page.getByText("Your Element is misconfigured")).toBeVisible(); - await expect(page.getByText("Errors in module configuration")).toBeVisible(); - }); - - testInstance.describe("with config", () => { - testInstance.beforeEach(({ config, guestHomeserver }) => { - config["io.element.element-web-modules.restricted-guests"] = { - guest_user_homeserver_url: guestHomeserver.baseUrl, - }; + test.describe(`Restricted guests auth=${auth} guests=${guestsEnabled}`, () => { + test("should error if config is missing", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Your Element is misconfigured")).toBeVisible(); + await expect(page.getByText("Errors in module configuration")).toBeVisible(); }); - testInstance( - "should show the default room preview bar for logged in users", - { tag: ["@screenshot"] }, - async ({ page, user, testRoomId }) => { + test.describe("with config", () => { + test.beforeEach(async ({ config, guestHomeserver, page, testRoomId }) => { + config["io.element.element-web-modules.restricted-guests"] = { + guest_user_homeserver_url: guestHomeserver.baseUrl, + }; // Go to a room we are not a member of await page.goto(`/#/room/${testRoomId}`); + }); - const button = page.getByRole("button", { name: "Join the discussion" }); - await expect(button).toBeVisible(); - }, - ); + if (guestsEnabled) { + // The screenshots between the two auth type tests for guests should be identical. + test( + "should show the default room preview bar for logged in users", + { tag: ["@screenshot"] }, + async ({ page, user, testRoomId }) => { + // Go to a room we are not a member of + await page.goto(`/#/room/${testRoomId}`); + const button = page.getByRole("button", { name: "Join the discussion" }); + await expect(button).toBeVisible(); + }, + ); - testInstance( - "should show the module's room preview bar for guests", - { tag: ["@screenshot"] }, - async ({ page, testRoomId }) => { - // Go to a room we are not a member of - await page.goto(`/#/room/${testRoomId}`); + test( + "should show the module's room preview bar for guests", + { tag: ["@screenshot"] }, + async ({ page }) => { + const button = page.getByRole("button", { name: "Join as guest", exact: true }); + await expect(button).toBeVisible(); + await expect(page.locator(".mx_RoomPreviewBar")).toMatchScreenshot(`preview-bar.png`); - const button = page.getByRole("button", { name: "Join", exact: true }); - await expect(button).toBeVisible(); - await expect(page.locator(".mx_RoomPreviewBar")).toMatchScreenshot(`preview-bar.png`); + await button.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toMatchScreenshot(`dialog.png`); - await button.click(); - const dialog = page.getByRole("dialog"); - await expect(dialog).toMatchScreenshot(`dialog.png`); + await dialog.getByPlaceholder("Name").fill("Jim"); + await dialog.getByRole("button", { name: "Continue as guest" }).click(); - await dialog.getByPlaceholder("Name").fill("Jim"); - await dialog.getByRole("button", { name: "Continue as guest" }).click(); + await expect(page.getByText("Ask to join?")).toBeVisible(); + }, + ); + } else { + test("should show the module login ux", { tag: ["@screenshot"] }, async ({ page }) => { + const button = page.getByRole("button", { name: "Join as guest", exact: true }); + await expect(button).toBeVisible(); + await expect(page.getByRole("main")).toMatchScreenshot(`login-${auth}.png`); - await expect(page.getByText("Ask to join?")).toBeVisible(); - }, - ); - }); - }); -}; + await button.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toMatchScreenshot(`dialog.png`); -// The screenshots between the two tests should be identical. -defineRestrictedGuestsTests(test, "Restricted Guests"); -defineRestrictedGuestsTests(masTest as RestrictedGuestsTestInstance, "Restricted Guests (MAS)"); + await dialog.getByPlaceholder("Name").fill("Jim"); + await dialog.getByRole("button", { name: "Continue as guest" }).click(); + + await expect(page.getByText("Join the discussion")).toBeVisible(); + }); + } + }); + }); + } +} diff --git a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/dialog-linux.png b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/dialog-linux.png index f053bea1..89cd54c9 100644 Binary files a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/dialog-linux.png and b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/dialog-linux.png differ diff --git a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/login-legacy-linux.png b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/login-legacy-linux.png new file mode 100644 index 00000000..31d48fab Binary files /dev/null and b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/login-legacy-linux.png differ diff --git a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/login-mas-linux.png b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/login-mas-linux.png new file mode 100644 index 00000000..4e6a663d Binary files /dev/null and b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/login-mas-linux.png differ diff --git a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png index 1771b8e6..f556a718 100644 Binary files a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png and b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png differ diff --git a/modules/restricted-guests/element-web/src/AuthFooter.tsx b/modules/restricted-guests/element-web/src/AuthFooter.tsx new file mode 100644 index 00000000..4b908ecb --- /dev/null +++ b/modules/restricted-guests/element-web/src/AuthFooter.tsx @@ -0,0 +1,59 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type FC } from "react"; +import { Button } from "@vector-im/compound-web"; +import { type AccountAuthInfo, type Api } from "@element-hq/element-web-module-api"; +import styled from "styled-components"; + +import { type ModuleConfig } from "./config.ts"; +import RegisterDialog from "./RegisterDialog.tsx"; + +interface Props { + api: Api; + config: ModuleConfig; + onLoggedIn(data: AccountAuthInfo): void; +} + +const Container = styled.aside` + margin: var(--cpd-space-3x) 0; + + button { + width: 100%; + } +`; + +const AuthFooter: FC = ({ api, config, onLoggedIn }) => { + const onTryJoin = async (): Promise => { + const { finished } = api.openDialog( + { + title: api.i18n.translate("register_dialog_title"), + }, + RegisterDialog, + { + api, + config, + }, + ); + + const { model: accountAuthInfo, ok } = await finished; + + if (ok && accountAuthInfo) { + onLoggedIn(accountAuthInfo); + } + }; + + return ( + + + + ); +}; + +export default AuthFooter; diff --git a/modules/restricted-guests/element-web/src/RegisterDialog.tsx b/modules/restricted-guests/element-web/src/RegisterDialog.tsx index 1c65708b..1b445df2 100644 --- a/modules/restricted-guests/element-web/src/RegisterDialog.tsx +++ b/modules/restricted-guests/element-web/src/RegisterDialog.tsx @@ -15,6 +15,7 @@ import { type ModuleConfig } from "./config.ts"; interface RegisterDialogProps extends DialogProps { api: Api; config: ModuleConfig; + showLoginLink?: boolean; } const enum State { @@ -29,7 +30,7 @@ const StyledFormRoot = styled(Form.Root)` font-feature-settings: normal; `; -const RegisterDialog: FC = ({ api, config, onCancel, onSubmit }) => { +const RegisterDialog: FC = ({ api, config, onCancel, onSubmit, showLoginLink }) => { const [username, setUsername] = useState(""); const [state, setState] = useState(State.Idle); @@ -69,7 +70,7 @@ const RegisterDialog: FC = ({ api, config, onCancel, onSubm return ( - + {api.i18n.translate("register_dialog_register_username_label")} = ({ api, config, onCancel, onSubm {message} - - {api.i18n.translate("register_dialog_existing_account")} - + {showLoginLink && ( + + {api.i18n.translate("register_dialog_existing_account")} + + )} {api.i18n.translate("register_dialog_continue_label")} diff --git a/modules/restricted-guests/element-web/src/RoomPreviewBar.tsx b/modules/restricted-guests/element-web/src/RoomPreviewBar.tsx index 75a2b974..bda49e57 100644 --- a/modules/restricted-guests/element-web/src/RoomPreviewBar.tsx +++ b/modules/restricted-guests/element-web/src/RoomPreviewBar.tsx @@ -45,6 +45,7 @@ const RoomPreviewBar: FC = ({ api, config, roomId, roomAlia { api, config, + showLoginLink: true, }, ); diff --git a/modules/restricted-guests/element-web/src/config.ts b/modules/restricted-guests/element-web/src/config.ts index 07a86902..766a6571 100644 --- a/modules/restricted-guests/element-web/src/config.ts +++ b/modules/restricted-guests/element-web/src/config.ts @@ -40,5 +40,8 @@ export const CONFIG_KEY = "io.element.element-web-modules.restricted-guests"; declare module "@element-hq/element-web-module-api" { export interface Config { [CONFIG_KEY]: input; + sso_redirect_options?: { + immediate?: boolean; // incompatible option + }; } } diff --git a/modules/restricted-guests/element-web/src/index.tsx b/modules/restricted-guests/element-web/src/index.tsx index a28b5c6c..d11e4c06 100644 --- a/modules/restricted-guests/element-web/src/index.tsx +++ b/modules/restricted-guests/element-web/src/index.tsx @@ -12,6 +12,7 @@ import Translations from "./translations.json"; import { ModuleConfig, CONFIG_KEY } from "./config"; import { name as ModuleName } from "../package.json"; import RoomPreviewBar from "./RoomPreviewBar.tsx"; +import AuthFooter from "./AuthFooter.tsx"; const GUEST_INVISIBLE_COMPONENTS = [ "UIComponent.sendInvites", @@ -41,14 +42,26 @@ class RestrictedGuestsModule implements Module { throw new Error(`Errors in module configuration for "${ModuleName}"`); } + const appConfig = this.api.config.get(); + if (appConfig.sso_redirect_options?.immediate) { + console.warn(`${ModuleName} found incompatible option 'sso_redirect_options.immediate', turning it off.`); + appConfig.sso_redirect_options.immediate = false; + } + + // Room preview bar customisations (for Matrix guest support) this.api.customComponents.registerRoomPreviewBar((props, OriginalComponent) => ( )); + this.api.customisations.registerShouldShowComponent(this.shouldShowComponent); - // TODO replace this with a more generic API - this.api._registerLegacyComponentVisibilityCustomisations(this); + // Login component customisations (for no guest support) + this.api.customComponents.registerLoginComponent((props, OriginalComponent) => ( + + + + )); } /** @@ -58,11 +71,12 @@ class RestrictedGuestsModule implements Module { * @returns true, if the user should see the component */ public readonly shouldShowComponent = (component: string): boolean => { - if (!this.config || !this.api.profile.value.userId?.startsWith(this.config.guest_user_prefix)) { - return true; + const profile = this.api.profile.value; + if (this.config && (profile.isGuest || profile.userId?.startsWith(this.config.guest_user_prefix))) { + return GUEST_INVISIBLE_COMPONENTS.includes(component); } - return GUEST_INVISIBLE_COMPONENTS.includes(component); + return true; }; } diff --git a/modules/restricted-guests/element-web/src/translations.json b/modules/restricted-guests/element-web/src/translations.json index bca1dc4a..315a1e93 100644 --- a/modules/restricted-guests/element-web/src/translations.json +++ b/modules/restricted-guests/element-web/src/translations.json @@ -4,8 +4,8 @@ "de": "Benutzername" }, "register_dialog_title": { - "en": "Request room access", - "de": "Raumbeitritt anfragen" + "en": "Request access", + "de": "Zugriff anfordern" }, "register_dialog_busy": { "en": "Creating your account...", @@ -32,7 +32,7 @@ "de": "Treten Sie dem Raum bei, um teilzunehmen" }, "join_cta": { - "en": "Join", - "de": "Verbinden" + "en": "Join as guest", + "de": "Als Gast beitreten" } } diff --git a/packages/element-web-playwright-common/src/fixtures/services.ts b/packages/element-web-playwright-common/src/fixtures/services.ts index d4256c62..de5e9af6 100644 --- a/packages/element-web-playwright-common/src/fixtures/services.ts +++ b/packages/element-web-playwright-common/src/fixtures/services.ts @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import { type MailpitClient } from "mailpit-api"; import { Network, type StartedNetwork } from "testcontainers"; -import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; +import { type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; import { type SynapseConfig, @@ -22,6 +22,7 @@ import { Logger } from "../utils/logger.js"; // We want to avoid using `mergeTests` in index.ts because it drops useful type information about the fixtures. Instead, // we add `axe` into our fixture suite by using its `test` as a base, so that there is a linear hierarchy. import { test as base } from "./axe.js"; +import { makePostgres } from "../testcontainers/postgres.js"; /** * Test-scoped fixtures available in the test @@ -101,27 +102,7 @@ export const test = base.extend({ ], postgres: [ async ({ logger, network }, use) => { - const container = await new PostgreSqlContainer("postgres:13.3-alpine") - .withNetwork(network) - .withNetworkAliases("postgres") - .withLogConsumer(logger.getConsumer("postgres")) - .withTmpFs({ - "/dev/shm/pgdata/data": "", - }) - .withEnvironment({ - PG_DATA: "/dev/shm/pgdata/data", - }) - .withCommand([ - "-c", - "shared_buffers=128MB", - "-c", - `fsync=off`, - "-c", - `synchronous_commit=off`, - "-c", - "full_page_writes=off", - ]) - .start(); + const container = await makePostgres(network, logger); await use(container); await container.stop(); }, diff --git a/packages/element-web-playwright-common/src/testcontainers/mas.ts b/packages/element-web-playwright-common/src/testcontainers/mas.ts index 294afb73..8e64db59 100644 --- a/packages/element-web-playwright-common/src/testcontainers/mas.ts +++ b/packages/element-web-playwright-common/src/testcontainers/mas.ts @@ -11,6 +11,7 @@ import { type StartedTestContainer, Wait, type ExecResult, + type StartedNetwork, } from "testcontainers"; import { type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; import * as YAML from "yaml"; @@ -23,6 +24,7 @@ import { type Credentials } from "../utils/api.js"; // curl -sL https://element-hq.github.io/matrix-authentication-service/config.schema.json \ // | npx json-schema-to-typescript -o packages/element-web-playwright-common/src/testconainers/mas-config.ts import type { RootConfig as MasConfig } from "./mas-config.js"; +import type { Logger } from "../utils/logger.js"; export { type MasConfig }; @@ -156,6 +158,7 @@ export class MatrixAuthenticationServiceContainer extends GenericContainer { super(image); const initialConfig = deepCopy(DEFAULT_CONFIG); + initialConfig.database.host = db.getHostname(); initialConfig.database.username = db.getUsername(); initialConfig.database.password = db.getPassword(); @@ -205,6 +208,7 @@ export class MatrixAuthenticationServiceContainer extends GenericContainer { await super.start(), `http://localhost:${port}`, this.args, + this.config.matrix.secret, ); } } @@ -219,6 +223,7 @@ export class StartedMatrixAuthenticationServiceContainer extends AbstractStarted container: StartedTestContainer, public readonly baseUrl: string, private readonly args: string[], + public readonly sharedSecret: string, ) { super(container); } @@ -346,3 +351,19 @@ export class StartedMatrixAuthenticationServiceContainer extends AbstractStarted await this.manage("add-email", username, address); } } + +export async function makeMas( + postgres: StartedPostgreSqlContainer, + network: StartedNetwork, + logger: Logger, + config: Partial, + name = "mas", +): Promise { + const container = await new MatrixAuthenticationServiceContainer(postgres) + .withNetwork(network) + .withNetworkAliases(name) + .withLogConsumer(logger.getConsumer(name)) + .withConfig(config) + .start(); + return container; +} diff --git a/packages/element-web-playwright-common/src/testcontainers/postgres.ts b/packages/element-web-playwright-common/src/testcontainers/postgres.ts new file mode 100644 index 00000000..0dd0eab2 --- /dev/null +++ b/packages/element-web-playwright-common/src/testcontainers/postgres.ts @@ -0,0 +1,40 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; +import { type StartedNetwork } from "testcontainers"; + +import { type Logger } from "../utils/logger.js"; + +export async function makePostgres( + network: StartedNetwork, + logger: Logger, + name = "postgres", +): Promise { + const container = await new PostgreSqlContainer("postgres:13.3-alpine") + .withNetwork(network) + .withNetworkAliases(name) + .withLogConsumer(logger.getConsumer(name)) + .withTmpFs({ + "/dev/shm/pgdata/data": "", + }) + .withEnvironment({ + PG_DATA: "/dev/shm/pgdata/data", + }) + .withCommand([ + "-c", + "shared_buffers=128MB", + "-c", + `fsync=off`, + "-c", + `synchronous_commit=off`, + "-c", + "full_page_writes=off", + ]) + .start(); + return container; +} diff --git a/packages/element-web-playwright-common/src/testcontainers/synapse.ts b/packages/element-web-playwright-common/src/testcontainers/synapse.ts index bec34aa4..65d92c0a 100644 --- a/packages/element-web-playwright-common/src/testcontainers/synapse.ts +++ b/packages/element-web-playwright-common/src/testcontainers/synapse.ts @@ -184,6 +184,14 @@ const DEFAULT_CONFIG = { }, room_list_publication_rules: [{ action: "allow" }], modules: [] as Array<{ module: string; config?: Record }>, + matrix_authentication_service: undefined as + | undefined + | { + enabled?: boolean; + endpoint?: string; + secret?: string | null; + secret_path?: string | null; + }, }; /** @@ -278,7 +286,22 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont } public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this { - this.mas = mas; + if (mas) { + this.mas = mas; + this.withConfig({ + matrix_authentication_service: { + enabled: true, + endpoint: `http://${mas.getHostname()}:8080/`, + secret: mas.sharedSecret, + }, + // Must be disabled when using MAS. + password_config: { + enabled: false, + }, + // Must be disabled when using MAS. + enable_registration: false, + }); + } return this; }