Merge branch 'develop' of https://github.com/vector-im/element-web into t3chguy/playwright-homeservers

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

# Conflicts:
#	.github/workflows/end-to-end-tests.yaml
#	playwright/e2e/crypto/backups.spec.ts
#	playwright/e2e/login/login-consent.spec.ts
#	playwright/e2e/login/soft_logout.spec.ts
#	playwright/e2e/oidc/oidc-native.spec.ts
#	playwright/e2e/register/register.spec.ts
#	playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts
#	playwright/element-web-test.ts
#	playwright/plugins/homeserver/dendrite/index.ts
#	playwright/plugins/homeserver/synapse/consentHomeserver.ts
#	playwright/plugins/homeserver/synapse/emailHomeserver.ts
#	playwright/plugins/homeserver/synapse/legacyOAuthHomeserver.ts
#	playwright/plugins/homeserver/synapse/masHomeserver.ts
#	playwright/services.ts
#	playwright/testcontainers/synapse.ts
t3chguy/playwright-homeservers
Michael Telatynski 2025-01-08 13:42:39 +00:00
commit b10fa4e71e
No known key found for this signature in database
GPG Key ID: A2B008A5F49F5D0D
38 changed files with 756 additions and 625 deletions

View File

@ -0,0 +1,91 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
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 { test, expect } from "../../element-web-test";
import { registerAccountMas } from "../oidc";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { TestClientServerAPI } from "../csAPI";
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts";
// These tests register an account with MAS because then we go through the "normal" registration flow
// and crypto gets set up. Using the 'user' fixture create a user and synthesizes an existing login,
// which is faster but leaves us without crypto set up.
test.use(masHomeserver);
test.describe("Encryption state after registration", () => {
test.skip(isDendrite, "does not yet support MAS");
test("Key backup is enabled by default", async ({ page, mailhogClient, app }) => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhogClient, "alice", "alice@email.com", "Pa$sW0rD!");
await app.settings.openUserSettings("Security & Privacy");
await expect(page.getByText("This session is backing up your keys.")).toBeVisible();
});
test("user is prompted to set up recovery", async ({ page, mailhogClient, app }) => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhogClient, "alice", "alice@email.com", "Pa$sW0rD!");
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("test room");
await page.getByRole("button", { name: "Create room" }).click();
await expect(page.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
});
});
test.describe("Key backup reset from elsewhere", () => {
test.skip(isDendrite, "does not yet support MAS");
test("Key backup is disabled when reset from elsewhere", async ({ page, mailhogClient, request, homeserver }) => {
const testUsername = "alice";
const testPassword = "Pa$sW0rD!";
// there's a delay before keys are uploaded so the error doesn't appear immediately: use a fake
// clock so we can skip the delay
await page.clock.install();
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhogClient, testUsername, "alice@email.com", testPassword);
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("test room");
await page.getByRole("button", { name: "Create room" }).click();
// @ts-ignore - this runs in the browser scope where mxMatrixClientPeg is a thing. Here, it is not.
const accessToken = await page.evaluate(() => mxMatrixClientPeg.get().getAccessToken());
const csAPI = new TestClientServerAPI(request, homeserver, accessToken);
const backupInfo = await csAPI.getCurrentBackupInfo();
await csAPI.deleteBackupVersion(backupInfo.version);
await page.getByRole("textbox", { name: "Send an encrypted message…" }).fill("/discardsession");
await page.getByRole("button", { name: "Send message" }).click();
await page.getByRole("textbox", { name: "Send an encrypted message…" }).fill("Message with broken key backup");
await page.getByRole("button", { name: "Send message" }).click();
// Should be the message we sent plus the room creation event
await expect(page.locator(".mx_EventTile")).toHaveCount(2);
await expect(
page.locator(".mx_RoomView_MessageList > .mx_EventTile_last .mx_EventTile_receiptSent"),
).toBeVisible();
// Wait for it to try uploading the key
await page.clock.fastForward(20000);
await expect(page.getByRole("heading", { level: 1, name: "New Recovery Method" })).toBeVisible();
});
});

View File

@ -9,10 +9,7 @@ Please see LICENSE files in the repository root for full details.
import { type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { registerAccountMas } from "../oidc";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { TestClientServerAPI } from "../csAPI";
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts";
async function expectBackupVersionToBe(page: Page, version: string) {
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
@ -22,83 +19,6 @@ async function expectBackupVersionToBe(page: Page, version: string) {
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version);
}
// These tests register an account with MAS because then we go through the "normal" registration flow
// and crypto gets set up. Using the 'user' fixture create a user and synthesizes an existing login,
// which is faster but leaves us without crypto set up.
test.describe("Encryption state after registration", () => {
test.use(masHomeserver);
test("Key backup is enabled by default", async ({ page, mailhogClient, app }) => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhogClient, "alice", "alice@email.com", "Pa$sW0rD!");
await app.settings.openUserSettings("Security & Privacy");
await expect(page.getByText("This session is backing up your keys.")).toBeVisible();
});
test("user is prompted to set up recovery", async ({ page, mailhogClient, app }) => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhogClient, "alice", "alice@email.com", "Pa$sW0rD!");
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("test room");
await page.getByRole("button", { name: "Create room" }).click();
await expect(page.getByRole("heading", { name: "Set up recovery" })).toBeVisible();
});
});
test.describe("Key backup reset from elsewhere", () => {
test.use(masHomeserver);
test("Key backup is disabled when reset from elsewhere", async ({ page, mailhogClient, request, homeserver }) => {
const testUsername = "alice";
const testPassword = "Pa$sW0rD!";
// there's a delay before keys are uploaded so the error doesn't appear immediately: use a fake
// clock so we can skip the delay
await page.clock.install();
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhogClient, testUsername, "alice@email.com", testPassword);
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("test room");
await page.getByRole("button", { name: "Create room" }).click();
// @ts-ignore - this runs in the browser scope where mxMatrixClientPeg is a thing. Here, it is not.
const accessToken = await page.evaluate(() => mxMatrixClientPeg.get().getAccessToken());
const csAPI = new TestClientServerAPI(request, homeserver, accessToken);
const backupInfo = await csAPI.getCurrentBackupInfo();
await csAPI.deleteBackupVersion(backupInfo.version);
await page.getByRole("textbox", { name: "Send an encrypted message…" }).fill("/discardsession");
await page.getByRole("button", { name: "Send message" }).click();
await page.getByRole("textbox", { name: "Send an encrypted message…" }).fill("Message with broken key backup");
await page.getByRole("button", { name: "Send message" }).click();
// Should be the message we sent plus the room creation event
await expect(page.locator(".mx_EventTile")).toHaveCount(2);
await expect(
page.locator(".mx_RoomView_MessageList > .mx_EventTile_last .mx_EventTile_receiptSent"),
).toBeVisible();
// Wait for it to try uploading the key
await page.clock.fastForward(20000);
await expect(page.getByRole("heading", { level: 1, name: "New Recovery Method" })).toBeVisible();
});
});
test.describe("Backups", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.use({

View File

@ -19,31 +19,31 @@ function getMemberTileByName(page: Page, name: string): Locator {
return page.locator(`.mx_EntityTile, [title="${name}"]`);
}
test.use({
displayName: NAME,
synapseConfigOptions: {
experimental_features: {
msc2697_enabled: false,
msc3814_enabled: true,
},
},
config: async ({ config, context }, use) => {
const wellKnown = {
...config.default_server_config,
"org.matrix.msc3814": true,
};
await context.route("https://localhost/.well-known/matrix/client", async (route) => {
await route.fulfill({ json: wellKnown });
});
await use(config);
},
});
test.describe("Dehydration", () => {
test.skip(isDendrite, "does not yet support dehydration v2");
test.use({
displayName: NAME,
synapseConfigOptions: {
experimental_features: {
msc2697_enabled: false,
msc3814_enabled: true,
},
},
config: async ({ config, context }, use) => {
const wellKnown = {
...config.default_server_config,
"org.matrix.msc3814": true,
};
await context.route("https://localhost/.well-known/matrix/client", async (route) => {
await route.fulfill({ json: wellKnown });
});
await use(config);
},
});
test("Create dehydrated device", async ({ page, user, app }, workerInfo) => {
// Create a backup (which will create SSSS, and dehydrated device)

View File

@ -16,20 +16,21 @@ const username = "user1234";
const password = "oETo7MPf0o";
const email = "user@nowhere.dummy";
test.describe("Forgot Password", () => {
test.skip(isDendrite, "not yet wired up");
test.use(emailHomeserver);
test.use({
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
test.use(emailHomeserver);
test.use({
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
},
});
},
});
test.describe("Forgot Password", () => {
test.skip(isDendrite, "not yet wired up");
test("renders properly", { tag: "@screenshot" }, async ({ page, homeserver }) => {
await page.goto("/");

View File

@ -9,12 +9,12 @@ Please see LICENSE files in the repository root for full details.
import { test, expect } from "../../element-web-test";
import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts";
test.describe("Consent", () => {
test.use(consentHomeserver);
test.use({
displayName: "Bob",
});
test.use(consentHomeserver);
test.use({
displayName: "Bob",
});
test.describe("Consent", () => {
test("should prompt the user to consent to terms when server deems it necessary", async ({
context,
page,

View File

@ -9,13 +9,12 @@ Please see LICENSE files in the repository root for full details.
import { Page } from "playwright-core";
import { expect, test } from "../../element-web-test";
import { doTokenRegistration } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { selectHomeserver } from "../utils";
import { Credentials, HomeserverInstance } from "../../plugins/homeserver";
import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts";
import { legacyOAuthHomeserver } from "../../plugins/homeserver/synapse/legacyOAuthHomeserver.ts";
import { isDendrite } from "../../plugins/homeserver/dendrite";
// This test requires fixed credentials for the device signing keys below to work
const username = "user1234";
const password = "p4s5W0rD";
@ -70,39 +69,44 @@ const DEVICE_SIGNING_KEYS_BODY = {
},
};
async function login(page: Page, homeserver: HomeserverInstance) {
async function login(page: Page, homeserver: HomeserverInstance, credentials: Credentials) {
await page.getByRole("link", { name: "Sign in" }).click();
await selectHomeserver(page, homeserver.baseUrl);
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByPlaceholder("Password").fill(password);
await page.getByRole("textbox", { name: "Username" }).fill(credentials.username);
await page.getByPlaceholder("Password").fill(credentials.password);
await page.getByRole("button", { name: "Sign in" }).click();
}
test.describe("Login", () => {
test.use({
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
test.use(consentHomeserver);
test.use({
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
},
});
},
credentials: async ({ context, homeserver }, use) => {
const displayName = "Dave";
const credentials = await homeserver.registerUser(username, password, displayName);
console.log(`Registered test user @user:localhost with displayname ${displayName}`);
await use({
...credentials,
displayName,
});
},
});
test.describe("Login", () => {
test.describe("Password login", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.use(consentHomeserver);
let creds: Credentials;
test.beforeEach(async ({ homeserver }) => {
creds = await homeserver.registerUser(username, password);
});
test("Loads the welcome page by default; then logs in with an existing account and lands on the home screen", async ({
credentials,
page,
homeserver,
checkA11y,
@ -136,16 +140,16 @@ test.describe("Login", () => {
// cy.percySnapshot("Login");
await checkA11y();
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByPlaceholder("Password").fill(password);
await page.getByRole("textbox", { name: "Username" }).fill(credentials.username);
await page.getByPlaceholder("Password").fill(credentials.password);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL(/\/#\/home$/);
});
test("Follows the original link after login", async ({ page, homeserver }) => {
test("Follows the original link after login", async ({ page, homeserver, credentials }) => {
await page.goto("/#/room/!room:id"); // should redirect to the welcome page
await login(page, homeserver);
await login(page, homeserver, credentials);
await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
await expect(page.getByRole("button", { name: "Join the discussion" })).toBeVisible();
@ -156,9 +160,10 @@ test.describe("Login", () => {
page,
homeserver,
request,
credentials,
}) => {
const res = await request.post(`${homeserver.baseUrl}/_matrix/client/v3/keys/device_signing/upload`, {
headers: { Authorization: `Bearer ${creds.accessToken}` },
headers: { Authorization: `Bearer ${credentials.accessToken}` },
data: DEVICE_SIGNING_KEYS_BODY,
});
if (res.status() / 100 !== 2) {
@ -167,7 +172,7 @@ test.describe("Login", () => {
expect(res.status() / 100).toEqual(2);
await page.goto("/");
await login(page, homeserver);
await login(page, homeserver, credentials);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
@ -185,10 +190,14 @@ test.describe("Login", () => {
page,
homeserver,
request,
credentials,
}) => {
const res = await request.post(
`${homeserver.baseUrl}/_matrix/client/v3/keys/device_signing/upload`,
{ headers: { Authorization: `Bearer ${creds.accessToken}` }, data: DEVICE_SIGNING_KEYS_BODY },
{
headers: { Authorization: `Bearer ${credentials.accessToken}` },
data: DEVICE_SIGNING_KEYS_BODY,
},
);
if (res.status() / 100 !== 2) {
console.log("Uploading dummy keys failed", await res.json());
@ -196,7 +205,7 @@ test.describe("Login", () => {
expect(res.status() / 100).toEqual(2);
await page.goto("/");
await login(page, homeserver);
await login(page, homeserver, credentials);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
@ -215,11 +224,15 @@ test.describe("Login", () => {
page,
homeserver,
request,
credentials,
}) => {
console.log(`uid ${creds.userId} body`, DEVICE_SIGNING_KEYS_BODY);
console.log(`uid ${credentials.userId} body`, DEVICE_SIGNING_KEYS_BODY);
const res = await request.post(
`${homeserver.baseUrl}/_matrix/client/v3/keys/device_signing/upload`,
{ headers: { Authorization: `Bearer ${creds.accessToken}` }, data: DEVICE_SIGNING_KEYS_BODY },
{
headers: { Authorization: `Bearer ${credentials.accessToken}` },
data: DEVICE_SIGNING_KEYS_BODY,
},
);
if (res.status() / 100 !== 2) {
console.log("Uploading dummy keys failed", await res.json());
@ -227,9 +240,9 @@ test.describe("Login", () => {
expect(res.status() / 100).toEqual(2);
await page.goto("/");
await login(page, homeserver);
await login(page, homeserver, credentials);
const h1 = await page.getByRole("heading", { name: "Verify this device", level: 1 });
const h1 = page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
await expect(h1.locator(".mx_CompleteSecurity_skip")).toHaveCount(0);
@ -238,25 +251,7 @@ test.describe("Login", () => {
});
});
// tests for old-style SSO login, in which we exchange tokens with Synapse, and Synapse talks to an auth server
test.describe("SSO login", () => {
test.skip(isDendrite, "does not yet support SSO");
test.use(legacyOAuthHomeserver);
test("logs in with SSO and lands on the home screen", async ({ page, homeserver }) => {
// If this test fails with a screen showing "Timeout connecting to remote server", it is most likely due to
// your firewall settings: Synapse is unable to reach the OIDC server.
//
// If you are using ufw, try something like:
// sudo ufw allow in on docker0
//
await doTokenRegistration(page, homeserver);
});
});
test.describe("logout", () => {
test.use(consentHomeserver);
test("should go to login page on logout", async ({ page, user }) => {
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(user.displayName, { exact: true })).toBeVisible();
@ -268,29 +263,4 @@ test.describe("Login", () => {
await expect(page).toHaveURL(/\/#\/login$/);
});
});
test.describe("logout with logout_redirect_url", () => {
test.use(consentHomeserver);
test.use({
config: {
// We redirect to decoder-ring because it's a predictable page that isn't Element itself.
// We could use example.org, matrix.org, or something else, however this puts dependency of external
// infrastructure on our tests. In the same vein, we don't really want to figure out how to ship a
// `test-landing.html` page when running with an uncontrolled Element (via `yarn start`).
// Using the decoder-ring is just as fine, and we can search for strategic names.
logout_redirect_url: "/decoder-ring/",
},
});
test("should respect logout_redirect_url", async ({ page, user }) => {
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(user.displayName, { exact: true })).toBeVisible();
// give a change for the outstanding requests queue to settle before logging out
await page.waitForTimeout(2000);
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/decoder-ring\/$/);
});
});
});

View File

@ -0,0 +1,29 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test } from "../../element-web-test";
import { doTokenRegistration } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { legacyOAuthHomeserver } from "../../plugins/homeserver/synapse/legacyOAuthHomeserver.ts";
test.use(legacyOAuthHomeserver);
// tests for old-style SSO login, in which we exchange tokens with Synapse, and Synapse talks to an auth server
test.describe("SSO login", () => {
test.skip(isDendrite, "does not yet support SSO");
test("logs in with SSO and lands on the home screen", async ({ page, homeserver }) => {
// If this test fails with a screen showing "Timeout connecting to remote server", it is most likely due to
// your firewall settings: Synapse is unable to reach the OIDC server.
//
// If you are using ufw, try something like:
// sudo ufw allow in on docker0
//
await doTokenRegistration(page, homeserver);
});
});

View File

@ -0,0 +1,35 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../element-web-test";
import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts";
test.use(consentHomeserver);
test.use({
config: {
// We redirect to decoder-ring because it's a predictable page that isn't Element itself.
// We could use example.org, matrix.org, or something else, however this puts dependency of external
// infrastructure on our tests. In the same vein, we don't really want to figure out how to ship a
// `test-landing.html` page when running with an uncontrolled Element (via `yarn start`).
// Using the decoder-ring is just as fine, and we can search for strategic names.
logout_redirect_url: "/decoder-ring/",
},
});
test.describe("logout with logout_redirect_url", () => {
test("should respect logout_redirect_url", async ({ page, user }) => {
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(user.displayName, { exact: true })).toBeVisible();
// give a change for the outstanding requests queue to settle before logging out
await page.waitForTimeout(2000);
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/decoder-ring\/$/);
});
});

View File

@ -6,124 +6,38 @@ 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 { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { doTokenRegistration } from "./utils";
import { Credentials } from "../../plugins/homeserver";
import { legacyOAuthHomeserver } from "../../plugins/homeserver/synapse/legacyOAuthHomeserver.ts";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { interceptRequestsWithSoftLogout } from "./utils";
test.describe("Soft logout", () => {
test.use({
displayName: "Alice",
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
test.use({
displayName: "Alice",
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
},
});
test.describe("with password user", () => {
test("shows the soft-logout page when a request fails, and allows a re-login", async ({ page, user }) => {
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.getByPlaceholder("Password").fill(user.password);
await page.getByPlaceholder("Password").press("Enter");
// back to the welcome page
await expect(page).toHaveURL(/\/#\/home/);
await expect(
page.getByRole("heading", { name: "Now, let's help you get started", exact: true }),
).toBeVisible();
});
test("still shows the soft-logout page when the page is reloaded after a soft-logout", async ({
page,
user,
}) => {
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.reload();
await expect(page.getByText("You're signed out")).toBeVisible();
});
});
test.describe("with SSO user", () => {
test.skip(isDendrite, "does not yet support SSO");
test.use(legacyOAuthHomeserver);
test.use({
user: async ({ page, homeserver }, use) => {
const user = await doTokenRegistration(page, homeserver);
// Eventually, we should end up at the home screen.
await expect(page).toHaveURL(/\/#\/home$/);
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
await use(user);
},
});
test("shows the soft-logout page when a request fails, and allows a re-login", async ({ page, user }) => {
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.getByRole("button", { name: "Continue with OAuth test" }).click();
// click the submit button
await page.getByRole("button", { name: "Submit" }).click();
// Synapse prompts us to grant permission to Element
await expect(page.getByRole("heading", { name: "Continue to your account" })).toBeVisible();
await page.getByRole("link", { name: "Continue" }).click();
// back to the welcome page
await expect(page).toHaveURL(/\/#\/home$/);
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
});
});
},
});
/**
* Intercept calls to /sync and have them fail with a soft-logout
*
* Any further requests to /sync with the same access token are blocked.
*/
async function interceptRequestsWithSoftLogout(page: Page, user: Credentials): Promise<void> {
await page.route("**/_matrix/client/*/sync*", async (route, req) => {
const accessToken = await req.headerValue("Authorization");
test.describe("Soft logout with password user", () => {
test("shows the soft-logout page when a request fails, and allows a re-login", async ({ page, user }) => {
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.getByPlaceholder("Password").fill(user.password);
await page.getByPlaceholder("Password").press("Enter");
// now, if the access token on this request matches the expired one, block it
if (accessToken === `Bearer ${user.accessToken}`) {
console.log("Intercepting request with soft-logged-out access token");
await route.fulfill({
status: 401,
json: {
errcode: "M_UNKNOWN_TOKEN",
error: "Soft logout",
soft_logout: true,
},
});
return;
}
// otherwise, pass through as normal
await route.continue();
// back to the welcome page
await expect(page).toHaveURL(/\/#\/home/);
await expect(page.getByRole("heading", { name: "Now, let's help you get started", exact: true })).toBeVisible();
});
const promise = page.waitForResponse((resp) => resp.url().includes("/sync") && resp.status() === 401);
// do something to make the active /sync return: create a new room
await page.evaluate(() => {
// don't wait for this to complete: it probably won't, because of the broken sync
window.mxMatrixClientPeg.get().createRoom({});
test("still shows the soft-logout page when the page is reloaded after a soft-logout", async ({ page, user }) => {
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.reload();
await expect(page.getByText("You're signed out")).toBeVisible();
});
await promise;
}
});

View File

@ -0,0 +1,59 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
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 { test, expect } from "../../element-web-test";
import { doTokenRegistration, interceptRequestsWithSoftLogout } from "./utils";
import { legacyOAuthHomeserver } from "../../plugins/homeserver/synapse/legacyOAuthHomeserver.ts";
test.use({
displayName: "Alice",
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
},
},
});
test.use(legacyOAuthHomeserver);
test.describe("Soft logout with SSO user", () => {
test.use({
user: async ({ page, homeserver }, use) => {
const user = await doTokenRegistration(page, homeserver);
// Eventually, we should end up at the home screen.
await expect(page).toHaveURL(/\/#\/home$/);
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
await use(user);
},
});
test("shows the soft-logout page when a request fails, and allows a re-login", async ({ page, user }) => {
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.getByRole("button", { name: "Continue with OAuth test" }).click();
// click the submit button
await page.getByRole("button", { name: "Submit" }).click();
// Synapse prompts us to grant permission to Element
await expect(page.getByRole("heading", { name: "Continue to your account" })).toBeVisible();
await page.getByRole("link", { name: "Continue" }).click();
// back to the welcome page
await expect(page).toHaveURL(/\/#\/home$/);
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
});
});

View File

@ -20,7 +20,7 @@ export async function doTokenRegistration(
await page.getByRole("button", { name: "Edit" }).click();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.baseUrl);
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue", exact: true }).click();
// wait for the dialog to go away
await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0);
@ -56,5 +56,44 @@ export async function doTokenRegistration(
homeServer: window.mxMatrixClientPeg.get().getHomeserverUrl(),
password: null,
displayName: "Alice",
username: window.mxMatrixClientPeg.get().getUserIdLocalpart(),
}));
}
/**
* Intercept calls to /sync and have them fail with a soft-logout
*
* Any further requests to /sync with the same access token are blocked.
*/
export async function interceptRequestsWithSoftLogout(page: Page, user: Credentials): Promise<void> {
await page.route("**/_matrix/client/*/sync*", async (route, req) => {
const accessToken = await req.headerValue("Authorization");
// now, if the access token on this request matches the expired one, block it
if (accessToken === `Bearer ${user.accessToken}`) {
console.log("Intercepting request with soft-logged-out access token");
await route.fulfill({
status: 401,
json: {
errcode: "M_UNKNOWN_TOKEN",
error: "Soft logout",
soft_logout: true,
},
});
return;
}
// otherwise, pass through as normal
await route.continue();
});
const promise = page.waitForResponse((resp) => resp.url().includes("/sync") && resp.status() === 401);
// do something to make the active /sync return: create a new room
await page.evaluate(() => {
// don't wait for this to complete: it probably won't, because of the broken sync
window.mxMatrixClientPeg.get().createRoom({});
});
await promise;
}

View File

@ -11,8 +11,8 @@ import { registerAccountMas } from ".";
import { ElementAppPage } from "../../pages/ElementAppPage.ts";
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts";
test.use(masHomeserver);
test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test.use(masHomeserver);
test.slow(); // trace recording takes a while here
test("can register the oauth2 client and an account", async ({ context, page, homeserver, mailhogClient, mas }) => {

View File

@ -10,21 +10,22 @@ import { test, expect } from "../../element-web-test";
import { emailHomeserver } from "../../plugins/homeserver/synapse/emailHomeserver.ts";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.use(emailHomeserver);
test.use({
config: ({ config }, use) =>
use({
...config,
default_server_config: {
...config.default_server_config,
"m.identity_server": {
base_url: "https://server.invalid",
},
},
}),
});
test.describe("Email Registration", async () => {
test.skip(isDendrite, "not yet wired up");
test.use(emailHomeserver);
test.use({
config: ({ config }, use) =>
use({
...config,
default_server_config: {
...config.default_server_config,
"m.identity_server": {
base_url: "https://server.invalid",
},
},
}),
});
test.beforeEach(async ({ homeserver, page }) => {
await page.goto("/#/register");

View File

@ -10,20 +10,21 @@ import { test, expect } from "../../element-web-test";
import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Registration", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.use(consentHomeserver);
test.use({
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
test.use(consentHomeserver);
test.use({
config: {
// The only thing that we really *need* (otherwise Element refuses to load) is a default homeserver.
// We point that to a guaranteed-invalid domain.
default_server_config: {
"m.homeserver": {
base_url: "https://server.invalid",
},
},
});
},
});
test.describe("Registration", () => {
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
test.beforeEach(async ({ page }) => {
await page.goto("/#/register");

View File

@ -376,37 +376,42 @@ test.describe("Sliding Sync", () => {
roomIds.push(id);
await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible();
}
const [roomAId, roomPId] = roomIds;
const [roomAId, roomPId, roomOId] = roomIds;
const assertUnsubExists = (request: Request, subRoomId: string, unsubRoomId: string) => {
const matchRoomSubRequest = (subRoomId: string) => (request: Request) => {
if (!request.url().includes("/sync")) return false;
const body = request.postDataJSON();
// There may be a request without a txn_id, ignore it, as there won't be any subscription changes
if (body.txn_id === undefined) {
return;
}
expect(body.unsubscribe_rooms).toEqual([unsubRoomId]);
expect(body.room_subscriptions).not.toHaveProperty(unsubRoomId);
expect(body.room_subscriptions).toHaveProperty(subRoomId);
return body.txn_id && body.room_subscriptions?.[subRoomId];
};
const matchRoomUnsubRequest = (unsubRoomId: string) => (request: Request) => {
if (!request.url().includes("/sync")) return false;
const body = request.postDataJSON();
return (
body.txn_id && body.unsubscribe_rooms?.includes(unsubRoomId) && !body.room_subscriptions?.[unsubRoomId]
);
};
let promise = page.waitForRequest(/sync/);
// Select the Test Room
await page.getByRole("treeitem", { name: "Apple", exact: true }).click();
// and wait for playwright to get the request
const roomSubscriptions = (await promise).postDataJSON().room_subscriptions;
// Select the Test Room and wait for playwright to get the request
const [request] = await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomAId)),
page.getByRole("treeitem", { name: "Apple", exact: true }).click(),
]);
const roomSubscriptions = request.postDataJSON().room_subscriptions;
expect(roomSubscriptions, "room_subscriptions is object").toBeDefined();
// Switch to another room
promise = page.waitForRequest(/sync/);
await page.getByRole("treeitem", { name: "Pineapple", exact: true }).click();
assertUnsubExists(await promise, roomPId, roomAId);
// Switch to another room and wait for playwright to get the request
await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomPId)),
page.waitForRequest(matchRoomUnsubRequest(roomAId)),
page.getByRole("treeitem", { name: "Pineapple", exact: true }).click(),
]);
// And switch to even another room
promise = page.waitForRequest(/sync/);
await page.getByRole("treeitem", { name: "Apple", exact: true }).click();
assertUnsubExists(await promise, roomPId, roomAId);
// And switch to even another room and wait for playwright to get the request
await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomOId)),
page.waitForRequest(matchRoomUnsubRequest(roomPId)),
page.getByRole("treeitem", { name: "Orange", exact: true }).click(),
]);
// TODO: Add tests for encrypted rooms
});

View File

@ -334,17 +334,17 @@ export class Helpers {
/**
* Populate the rooms with messages and threads
* @param user the user sending the messages
* @param room1
* @param room2
* @param msg - MessageBuilder
* @param user - the user to mention in the first message
* @param hasMention - whether to include a mention in the first message
*/
async populateThreads(
user: Credentials,
room1: { name: string; roomId: string },
room2: { name: string; roomId: string },
msg: MessageBuilder,
user: Credentials,
hasMention = true,
) {
if (hasMention) {

View File

@ -15,6 +15,7 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => {
isDendrite,
"due to Dendrite lacking full threads support https://github.com/element-hq/dendrite/issues/3283",
);
test.use({
displayName: "Alice",
botCreateOpts: { displayName: "Other User" },
@ -79,7 +80,7 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => {
{ tag: "@screenshot" },
async ({ room1, room2, util, msg, user }) => {
await util.goTo(room2);
await util.populateThreads(user, room1, room2, msg);
await util.populateThreads(room1, room2, msg, user);
// The indicator should be shown
await util.assertHighlightIndicator();
@ -97,7 +98,7 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => {
test("should update with a thread is read", { tag: "@screenshot" }, async ({ room1, room2, util, msg, user }) => {
await util.goTo(room2);
await util.populateThreads(user, room1, room2, msg);
await util.populateThreads(room1, room2, msg, user);
// Click on the first room in TAC
await util.openTac();
@ -120,7 +121,7 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => {
test("should order by recency after notification level", async ({ room1, room2, util, msg, user }) => {
await util.goTo(room2);
await util.populateThreads(user, room1, room2, msg, false);
await util.populateThreads(room1, room2, msg, user, false);
await util.openTac();
await util.assertRoomsInTac([

View File

@ -6,7 +6,15 @@ 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 { expect as baseExpect, Locator, Page, ExpectMatcherState, ElementHandle } from "@playwright/test";
import {
expect as baseExpect,
Locator,
Page,
ExpectMatcherState,
ElementHandle,
PlaywrightTestArgs,
Fixtures as _Fixtures,
} from "@playwright/test";
import { sanitizeForFilePath } from "playwright-core/lib/utils";
import AxeBuilder from "@axe-core/playwright";
import _ from "lodash";
@ -19,7 +27,7 @@ import { Crypto } from "./pages/crypto";
import { Toasts } from "./pages/toasts";
import { Bot, CreateBotOpts } from "./pages/bot";
import { Webserver } from "./plugins/webserver";
import { test as base } from "./services.ts";
import { Options, Services, test as base } from "./services.ts";
// Enable experimental service worker support
// See https://playwright.dev/docs/service-workers-experimental#how-to-enable
@ -45,7 +53,7 @@ interface CredentialsWithDisplayName extends Credentials {
displayName: string;
}
export interface Fixtures {
export interface TestFixtures {
axe: AxeBuilder;
checkA11y: () => Promise<void>;
@ -101,7 +109,10 @@ export interface Fixtures {
webserver: Webserver;
}
export const test = base.extend<Fixtures>({
type CombinedTestFixtures = PlaywrightTestArgs & TestFixtures;
export type Fixtures = _Fixtures<CombinedTestFixtures, Services & Options, CombinedTestFixtures>;
export const test = base.extend<TestFixtures>({
context: async ({ context }, use, testInfo) => {
// We skip tests instead of using grep-invert to still surface the counts in the html report
test.skip(
@ -137,12 +148,12 @@ export const test = base.extend<Fixtures>({
},
displayName: undefined,
credentials: async ({ homeserver, displayName: testDisplayName }, use) => {
credentials: async ({ context, homeserver, displayName: testDisplayName }, use, testInfo) => {
const names = ["Alice", "Bob", "Charlie", "Daniel", "Eve", "Frank", "Grace", "Hannah", "Isaac", "Judy"];
const password = _.uniqueId("password_");
const displayName = testDisplayName ?? _.sample(names)!;
const credentials = await homeserver.registerUser("user", password, displayName);
const credentials = await homeserver.registerUser(`user_${testInfo.testId}`, password, displayName);
console.log(`Registered test user ${credentials.userId} with displayname ${displayName}`);
await use({
@ -167,6 +178,7 @@ export const test = base.extend<Fixtures>({
window.localStorage.setItem(
"mx_local_settings",
JSON.stringify({
// Retain any other settings which may have already been set
...JSON.parse(window.localStorage.getItem("mx_local_settings") || "{}"),
// Ensure the language is set to a consistent value
language: "en",

View File

@ -41,6 +41,7 @@ export interface Credentials {
homeServer: string;
password: string | null; // null for password-less users
displayName?: string;
username: string; // the localpart of the userId
}
export type HomeserverType = "synapse" | "dendrite" | "pinecone";

View File

@ -6,53 +6,57 @@ 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 { Fixtures } from "@playwright/test";
import { Fixtures } from "../../../element-web-test.ts";
import { Options, Services } from "../../../services.ts";
export const consentHomeserver: Fixtures<Services & Options, {}, Services & Options> = {
_homeserver: async ({ homeserverType, _homeserver: container, mailhog }, use, testInfo) => {
testInfo.skip(homeserverType !== "synapse", "does not yet support MAS");
container
.withCopyDirectoriesToContainer([
{ source: "playwright/plugins/homeserver/synapse/res", target: "/data/res" },
])
.withConfig({
email: {
enable_notifs: false,
smtp_host: "mailhog",
smtp_port: 1025,
smtp_user: "username",
smtp_pass: "password",
require_transport_security: false,
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>",
app_name: "Matrix",
notif_template_html: "notif_mail.html",
notif_template_text: "notif_mail.txt",
notif_for_new_users: true,
client_base_url: "http://localhost/element",
},
user_consent: {
template_dir: "/data/res/templates/privacy",
version: "1.0",
server_notice_content: {
msgtype: "m.text",
body: "To continue using this homeserver you must review and agree to the terms and conditions at %(consent_uri)s",
export const consentHomeserver: Fixtures = {
_homeserver: [
async ({ _homeserver: container, mailhog }, use) => {
container
.withCopyDirectoriesToContainer([
{ source: "playwright/plugins/homeserver/synapse/res", target: "/data/res" },
])
.withConfig({
email: {
enable_notifs: false,
smtp_host: "mailhog",
smtp_port: 1025,
smtp_user: "username",
smtp_pass: "password",
require_transport_security: false,
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>",
app_name: "Matrix",
notif_template_html: "notif_mail.html",
notif_template_text: "notif_mail.txt",
notif_for_new_users: true,
client_base_url: "http://localhost/element",
},
send_server_notice_to_guests: true,
block_events_error:
"To continue using this homeserver you must review and agree to the terms and conditions at %(consent_uri)s",
require_at_registration: true,
},
server_notices: {
system_mxid_localpart: "notices",
system_mxid_display_name: "Server Notices",
system_mxid_avatar_url: "mxc://localhost/oumMVlgDnLYFaPVkExemNVVZ",
room_name: "Server Notices",
},
})
.withConfigField("listeners[0].resources[0].names", ["client", "consent"]);
await use(container);
user_consent: {
template_dir: "/data/res/templates/privacy",
version: "1.0",
server_notice_content: {
msgtype: "m.text",
body: "To continue using this homeserver you must review and agree to the terms and conditions at %(consent_uri)s",
},
send_server_notice_to_guests: true,
block_events_error:
"To continue using this homeserver you must review and agree to the terms and conditions at %(consent_uri)s",
require_at_registration: true,
},
server_notices: {
system_mxid_localpart: "notices",
system_mxid_display_name: "Server Notices",
system_mxid_avatar_url: "mxc://localhost/oumMVlgDnLYFaPVkExemNVVZ",
room_name: "Server Notices",
},
})
.withConfigField("listeners[0].resources[0].names", ["client", "consent"]);
await use(container);
},
{ scope: "worker" },
],
context: async ({ homeserverType, context }, use, testInfo) => {
testInfo.skip(homeserverType !== "synapse", "does not yet support MAS");
await use(context);
},
};

View File

@ -6,24 +6,29 @@ 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 { Fixtures } from "@playwright/test";
import { Fixtures } from "../../../element-web-test.ts";
import { Options, Services } from "../../../services.ts";
export const emailHomeserver: Fixtures = {
_homeserver: [
async ({ _homeserver: container, mailhog }, use) => {
container.withConfig({
enable_registration_without_verification: undefined,
disable_msisdn_registration: undefined,
registrations_require_3pid: ["email"],
email: {
smtp_host: "mailhog",
smtp_port: 1025,
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>",
app_name: "my_branded_matrix_server",
},
});
await use(container);
},
{ scope: "worker" },
],
export const emailHomeserver: Fixtures<Services & Options, {}, Services & Options> = {
_homeserver: async ({ homeserverType, _homeserver: container, mailhog }, use, testInfo) => {
context: async ({ homeserverType, context }, use, testInfo) => {
testInfo.skip(homeserverType !== "synapse", "does not yet support MAS");
container.withConfig({
enable_registration_without_verification: undefined,
disable_msisdn_registration: undefined,
registrations_require_3pid: ["email"],
email: {
smtp_host: "mailhog",
smtp_port: 1025,
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>",
app_name: "my_branded_matrix_server",
},
});
await use(container);
await use(context);
},
};

View File

@ -6,44 +6,50 @@ 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 { Fixtures } from "@playwright/test";
import { TestContainers } from "testcontainers";
import { Options, Services } from "../../../services.ts";
import { OAuthServer } from "../../oauth_server";
import { Fixtures } from "../../../element-web-test.ts";
export const legacyOAuthHomeserver: Fixtures<Services & Options, {}, Services & Options> = {
_homeserver: async ({ homeserverType, _homeserver: container }, use, testInfo) => {
testInfo.skip(homeserverType !== "synapse", "does not yet support MAS");
const server = new OAuthServer();
const port = server.start();
export const legacyOAuthHomeserver: Fixtures = {
_homeserver: [
async ({ _homeserver: container }, use) => {
const server = new OAuthServer();
const port = server.start();
await TestContainers.exposeHostPorts(port);
container.withConfig({
oidc_providers: [
{
idp_id: "test",
idp_name: "OAuth test",
issuer: `http://localhost:${port}/oauth`,
authorization_endpoint: `http://localhost:${port}/oauth/auth.html`,
// the token endpoint receives requests from synapse,
// rather than the webapp, so needs to escape the docker container.
token_endpoint: `http://host.testcontainers.internal:${port}/oauth/token`,
userinfo_endpoint: `http://host.testcontainers.internal:${port}/oauth/userinfo`,
client_id: "synapse",
discover: false,
scopes: ["profile"],
skip_verification: true,
client_auth_method: "none",
user_mapping_provider: {
config: {
display_name_template: "{{ user.name }}",
await TestContainers.exposeHostPorts(port);
container.withConfig({
oidc_providers: [
{
idp_id: "test",
idp_name: "OAuth test",
issuer: `http://localhost:${port}/oauth`,
authorization_endpoint: `http://localhost:${port}/oauth/auth.html`,
// the token endpoint receives requests from synapse,
// rather than the webapp, so needs to escape the docker container.
token_endpoint: `http://host.testcontainers.internal:${port}/oauth/token`,
userinfo_endpoint: `http://host.testcontainers.internal:${port}/oauth/userinfo`,
client_id: "synapse",
discover: false,
scopes: ["profile"],
skip_verification: true,
client_auth_method: "none",
user_mapping_provider: {
config: {
display_name_template: "{{ user.name }}",
},
},
},
},
],
});
await use(container);
server.stop();
],
});
await use(container);
server.stop();
},
{ scope: "worker" },
],
context: async ({ homeserverType, context }, use, testInfo) => {
testInfo.skip(homeserverType !== "synapse", "does not yet support MAS");
await use(context);
},
};

View File

@ -6,60 +6,57 @@ 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 { Fixtures, PlaywrightTestArgs } from "@playwright/test";
import { Options, Services } from "../../../services.ts";
import { Fixtures as BaseFixtures } from "../../../element-web-test.ts";
import { Fixtures } from "../../../element-web-test.ts";
import { MatrixAuthenticationServiceContainer } from "../../../testcontainers/mas.ts";
type Fixture = PlaywrightTestArgs & Services & BaseFixtures & Options;
export const masHomeserver: Fixtures<Fixture, {}, Fixture> = {
mas: async ({ homeserverType, _homeserver: homeserver, logger, network, postgres, mailhog }, use, testInfo) => {
testInfo.skip(homeserverType !== "synapse", "does not yet support MAS");
const config = {
clients: [
{
client_id: "0000000000000000000SYNAPSE",
client_auth_method: "client_secret_basic",
client_secret: "SomeRandomSecret",
export const masHomeserver: Fixtures = {
mas: [
async ({ _homeserver: homeserver, logger, network, postgres, mailhog }, use) => {
const config = {
clients: [
{
client_id: "0000000000000000000SYNAPSE",
client_auth_method: "client_secret_basic",
client_secret: "SomeRandomSecret",
},
],
matrix: {
homeserver: "localhost",
secret: "AnotherRandomSecret",
endpoint: "http://homeserver:8008",
},
],
matrix: {
homeserver: "localhost",
secret: "AnotherRandomSecret",
endpoint: "http://homeserver:8008",
},
};
};
const container = await new MatrixAuthenticationServiceContainer(postgres)
.withNetwork(network)
.withNetworkAliases("mas")
.withLogConsumer(logger.getConsumer("mas"))
.withConfig(config)
.start();
const container = await new MatrixAuthenticationServiceContainer(postgres)
.withNetwork(network)
.withNetworkAliases("mas")
.withLogConsumer(logger.getConsumer("mas"))
.withConfig(config)
.start();
homeserver.withConfig({
enable_registration: undefined,
enable_registration_without_verification: undefined,
disable_msisdn_registration: undefined,
password_config: undefined,
experimental_features: {
msc3861: {
enabled: true,
issuer: `http://mas:8080/`,
introspection_endpoint: "http://mas:8080/oauth2/introspect",
client_id: config.clients[0].client_id,
client_auth_method: config.clients[0].client_auth_method,
client_secret: config.clients[0].client_secret,
admin_token: config.matrix.secret,
homeserver.withConfig({
enable_registration: undefined,
enable_registration_without_verification: undefined,
disable_msisdn_registration: undefined,
password_config: undefined,
experimental_features: {
msc3861: {
enabled: true,
issuer: `http://mas:8080/`,
introspection_endpoint: "http://mas:8080/oauth2/introspect",
client_id: config.clients[0].client_id,
client_auth_method: config.clients[0].client_auth_method,
client_secret: config.clients[0].client_secret,
admin_token: config.matrix.secret,
},
},
},
});
});
await use(container);
await container.stop();
},
await use(container);
await container.stop();
},
{ scope: "worker" },
],
config: async ({ homeserver, context, mas }, use) => {
const issuer = `${mas.baseUrl}/`;
@ -82,4 +79,9 @@ export const masHomeserver: Fixtures<Fixture, {}, Fixture> = {
default_server_config: wellKnown,
});
},
context: async ({ homeserverType, context }, use, testInfo) => {
testInfo.skip(homeserverType !== "synapse", "does not yet support MAS");
await use(context);
},
};

View File

@ -36,105 +36,132 @@ export interface Options {
homeserverType: HomeserverType;
}
export const test = base.extend<Services & Options>({
// eslint-disable-next-line no-empty-pattern
logger: async ({}, use, testInfo) => {
const logger = new ContainerLogger();
await use(logger);
await logger.testFinished(testInfo);
},
// eslint-disable-next-line no-empty-pattern
network: async ({}, use) => {
const network = await new Network().start();
await use(network);
await network.stop();
},
postgres: async ({ logger, network }, use) => {
const container = await new PostgreSqlContainer()
.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();
await use(container);
await container.stop();
},
export const test = base.extend<{}, Services & Options>({
logger: [
// eslint-disable-next-line no-empty-pattern
async ({}, use, testInfo) => {
const logger = new ContainerLogger();
await use(logger);
},
{ scope: "worker" },
],
network: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
const network = await new Network().start();
await use(network);
await network.stop();
},
{ scope: "worker" },
],
postgres: [
async ({ logger, network }, use) => {
const container = await new PostgreSqlContainer()
.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();
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mailhog: async ({ logger, network }, use) => {
const container = await new GenericContainer("mailhog/mailhog:latest")
.withNetwork(network)
.withNetworkAliases("mailhog")
.withExposedPorts(8025)
.withLogConsumer(logger.getConsumer("mailhog"))
.withWaitStrategy(Wait.forListeningPorts())
.start();
await use(container);
await container.stop();
},
mailhogClient: async ({ mailhog: container }, use) => {
await use(mailhog({ host: container.getHost(), port: container.getMappedPort(8025) }));
},
mailhog: [
async ({ logger, network }, use) => {
const container = await new GenericContainer("mailhog/mailhog:latest")
.withNetwork(network)
.withNetworkAliases("mailhog")
.withExposedPorts(8025)
.withLogConsumer(logger.getConsumer("mailhog"))
.withWaitStrategy(Wait.forListeningPorts())
.start();
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mailhogClient: [
async ({ mailhog: container }, use) => {
const client = mailhog({ host: container.getHost(), port: container.getMappedPort(8025) });
await use(client);
},
{ scope: "worker" },
],
synapseConfigOptions: [{}, { option: true }],
homeserverType: ["synapse", { option: true }],
_homeserver: async ({ homeserverType, request }, use) => {
let container: HomeserverContainer<any>;
switch (homeserverType) {
case "synapse":
container = new SynapseContainer(request);
break;
case "dendrite":
container = new DendriteContainer(request);
break;
case "pinecone":
container = new PineconeContainer(request);
break;
}
synapseConfigOptions: [{}, { option: true, scope: "worker" }],
homeserverType: ["synapse", { option: true, scope: "worker" }],
_homeserver: [
async ({ homeserverType }, use) => {
let container: HomeserverContainer<any>;
switch (homeserverType) {
case "synapse":
container = new SynapseContainer();
break;
case "dendrite":
container = new DendriteContainer();
break;
case "pinecone":
container = new PineconeContainer();
break;
}
await use(container);
},
homeserver: async (
{ homeserverType, logger, network, _homeserver: homeserver, synapseConfigOptions, mas },
use,
testInfo,
) => {
await use(container);
},
{ scope: "worker" },
],
homeserver: [
async ({ homeserverType, logger, network, _homeserver: homeserver, synapseConfigOptions, mas }, use) => {
if (homeserver instanceof SynapseContainer) {
homeserver.withConfig(synapseConfigOptions);
}
const container = await homeserver
.withNetwork(network)
.withNetworkAliases("homeserver")
.withLogConsumer(logger.getConsumer(homeserverType))
.start();
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mas: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
// we stub the mas fixture to allow `homeserver` to depend on it to ensure
// when it is specified by `masHomeserver` it is started before the homeserver
await use(undefined);
},
{ scope: "worker" },
],
context: async ({ homeserverType, synapseConfigOptions, logger, context, request, homeserver }, use, testInfo) => {
testInfo.skip(
!(homeserver instanceof SynapseContainer) && Object.keys(synapseConfigOptions).length > 0,
`Test specifies Synapse config options so is unsupported with ${homeserverType}`,
);
if (homeserver instanceof SynapseContainer) {
homeserver.withConfig(synapseConfigOptions);
}
const container = await homeserver
.withNetwork(network)
.withNetworkAliases("homeserver")
.withLogConsumer(logger.getConsumer(homeserverType))
.start();
await use(container);
await container.stop();
},
// eslint-disable-next-line no-empty-pattern
mas: async ({}, use) => {
// we stub the mas fixture to allow `homeserver` to depend on it to ensure
// when it is specified by `masHomeserver` it is started before the homeserver
await use(undefined);
homeserver.setRequest(request);
await logger.testStarted(testInfo);
await use(context);
await logger.testFinished(testInfo);
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { AbstractStartedContainer, GenericContainer } from "testcontainers";
import { APIRequestContext } from "@playwright/test";
import { StartedSynapseContainer } from "./synapse.ts";
import { HomeserverInstance } from "../plugins/homeserver";
@ -16,4 +17,6 @@ export interface HomeserverContainer<Config> extends GenericContainer {
start(): Promise<StartedSynapseContainer>;
}
export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance {}
export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance {
setRequest(request: APIRequestContext): void;
}

View File

@ -6,7 +6,6 @@ Please see LICENSE files in the repository root for full details.
*/
import { GenericContainer, Wait } from "testcontainers";
import { APIRequestContext } from "@playwright/test";
import * as YAML from "yaml";
import { set } from "lodash";
@ -208,11 +207,7 @@ const DEFAULT_CONFIG = {
export class DendriteContainer extends GenericContainer implements HomeserverContainer<typeof DEFAULT_CONFIG> {
private config: typeof DEFAULT_CONFIG;
constructor(
private request: APIRequestContext,
image = "matrixdotorg/dendrite-monolith:main",
binary = "/usr/bin/dendrite",
) {
constructor(image = "matrixdotorg/dendrite-monolith:main", binary = "/usr/bin/dendrite") {
super(image);
this.config = deepCopy(DEFAULT_CONFIG);
@ -254,13 +249,12 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon
container,
`http://${container.getHost()}:${container.getMappedPort(8008)}`,
this.config.client_api.registration_shared_secret,
this.request,
);
}
}
export class PineconeContainer extends DendriteContainer {
constructor(request: APIRequestContext) {
super(request, "matrixdotorg/dendrite-demo-pinecone:main", "/usr/bin/dendrite-demo-pinecone");
constructor() {
super("matrixdotorg/dendrite-demo-pinecone:main", "/usr/bin/dendrite-demo-pinecone");
}
}

View File

@ -141,7 +141,7 @@ export type SynapseConfigOptions = Partial<typeof DEFAULT_CONFIG>;
export class SynapseContainer extends GenericContainer implements HomeserverContainer<typeof DEFAULT_CONFIG> {
private config: typeof DEFAULT_CONFIG;
constructor(private readonly request: APIRequestContext) {
constructor() {
super(`ghcr.io/element-hq/synapse:${TAG}`);
this.config = deepCopy(DEFAULT_CONFIG);
@ -221,23 +221,26 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont
await super.start(),
`http://localhost:${port}`,
this.config.registration_shared_secret,
this.request,
);
}
}
export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer {
private adminToken?: string;
private request?: APIRequestContext;
constructor(
container: StartedTestContainer,
public readonly baseUrl: string,
private readonly registrationSharedSecret: string,
private readonly request: APIRequestContext,
) {
super(container);
}
public setRequest(request: APIRequestContext): void {
this.request = request;
}
private async registerUserInternal(
username: string,
password: string,
@ -273,6 +276,7 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
deviceId: data.device_id,
password,
displayName,
username,
};
}
@ -300,6 +304,7 @@ export class StartedSynapseContainer extends AbstractStartedContainer implements
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],
};
}

View File

@ -24,6 +24,12 @@ export class ContainerLogger {
};
}
public async testStarted(testInfo: TestInfo) {
for (const container in this.logs) {
this.logs[container] = "";
}
}
public async testFinished(testInfo: TestInfo) {
if (testInfo.status !== "passed") {
for (const container in this.logs) {

View File

@ -1276,7 +1276,7 @@
"error_already_invited_space": "Die Person wurde bereits eingeladen",
"error_already_joined_room": "Die Person ist bereits im Raum",
"error_already_joined_space": "Die Person ist bereits im Space",
"error_bad_state": "Verbannte Nutzer können nicht eingeladen werden.",
"error_bad_state": "Gesperrte Benutzer können nicht eingeladen werden.",
"error_dm": "Wir konnten deine Direktnachricht nicht erstellen.",
"error_find_room": "Beim Einladen der Nutzer lief etwas schief.",
"error_find_user_description": "Folgende Nutzer konnten nicht eingeladen werden, da sie nicht existieren oder ungültig sind: %(csvNames)s",
@ -1317,7 +1317,7 @@
"unable_find_profiles_invite_label_default": "Dennoch einladen",
"unable_find_profiles_invite_never_warn_label_default": "Trotzdem einladen und mich nicht mehr warnen",
"unable_find_profiles_title": "Eventuell existieren folgende Benutzer nicht",
"unban_first_title": "Benutzer kann nicht eingeladen werden, solange er nicht entbannt ist"
"unban_first_title": "Benutzer kann nicht eingeladen werden, solange die Sperre nicht aufgehoben worden ist."
},
"inviting_user1_and_user2": "Lade %(user1)s und %(user2)s ein",
"inviting_user_and_n_others": {
@ -3732,8 +3732,8 @@
"ban_button_room": "Bannen",
"ban_button_space": "Bannen",
"ban_room_confirm_title": "Aus %(roomName)s verbannen",
"ban_space_everything": "Überall wo ich die Rechte dazu habe bannen",
"ban_space_specific": "In ausgewählten Räumen und Spaces bannen",
"ban_space_everything": "Überall sperren, wo ich die Rechte dazu habe",
"ban_space_specific": "Sperre sie in ausgewählten Chatrooms und Spaces",
"count_of_sessions": {
"other": "%(count)s Sitzungen",
"one": "%(count)s Sitzung"
@ -3798,11 +3798,11 @@
"room_unencrypted_detail": "Nachrichten in verschlüsselten Räumen können nur von dir und vom Empfänger gelesen werden.",
"send_message": "Nachricht senden",
"share_button": "Profil teilen",
"unban_button_room": "Entbannen",
"unban_button_space": "Entbannen",
"unban_room_confirm_title": "Von %(roomName)s entbannen",
"unban_space_everything": "Überall wo ich die Rechte dazu habe, entbannen",
"unban_space_specific": "In ausgewählten Räumen und Spaces entbannen",
"unban_button_room": "Sperrung für den Chatroom aufheben",
"unban_button_space": "Sperrung aus Space aufheben",
"unban_room_confirm_title": "Sperrung für %(roomName)s aufheben",
"unban_space_everything": "Die Verbannung überall aufheben wo ich die Rechte dazu habe",
"unban_space_specific": "In ausgewählten Chatrooms und Spaces die Sperrung für sie aufheben",
"unban_space_warning": "Die Person wird keinen Zutritt zu Bereichen haben, in denen du nicht administrierst.",
"unignore_button": "Nicht mehr ignorieren",
"verify_button": "Nutzer verifizieren",