From a6705304aa43f96188a3602082e87a40ee719e9a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 23 Nov 2023 10:27:11 +0000 Subject: [PATCH] Port remaining login.spec.ts & soft_logout.spec.ts tests from Cypress to Playwright (#11917) Co-authored-by: R Midhun Suresh --- cypress/e2e/login/login.spec.ts | 103 ------------- cypress/e2e/login/soft_logout.spec.ts | 141 ------------------ cypress/e2e/login/utils.ts | 49 ------ cypress/plugins/index.ts | 7 +- cypress/plugins/oauth_server/index.ts | 81 ---------- package.json | 1 + playwright/e2e/login/login.spec.ts | 64 +++++++- playwright/e2e/login/soft_logout.spec.ts | 125 ++++++++++++++++ playwright/e2e/login/utils.ts | 68 +++++++++ playwright/element-web-test.ts | 59 +++++++- .../plugins/oauth_server/README.md | 0 playwright/plugins/oauth_server/index.ts | 72 +++++++++ .../plugins/oauth_server/res/oauth/auth.html | 0 playwright/plugins/utils/homeserver.ts | 2 +- playwright/tsconfig.json | 2 +- yarn.lock | 77 ++++++++++ 16 files changed, 465 insertions(+), 386 deletions(-) delete mode 100644 cypress/e2e/login/login.spec.ts delete mode 100644 cypress/e2e/login/soft_logout.spec.ts delete mode 100644 cypress/e2e/login/utils.ts delete mode 100644 cypress/plugins/oauth_server/index.ts create mode 100644 playwright/e2e/login/soft_logout.spec.ts create mode 100644 playwright/e2e/login/utils.ts rename {cypress => playwright}/plugins/oauth_server/README.md (100%) create mode 100644 playwright/plugins/oauth_server/index.ts rename {cypress => playwright}/plugins/oauth_server/res/oauth/auth.html (100%) diff --git a/cypress/e2e/login/login.spec.ts b/cypress/e2e/login/login.spec.ts deleted file mode 100644 index 05dd6f7b8b..0000000000 --- a/cypress/e2e/login/login.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { doTokenRegistration } from "./utils"; - -describe("Login", () => { - let homeserver: HomeserverInstance; - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - // tests for old-style SSO login, in which we exchange tokens with Synapse, and Synapse talks to an auth server - describe("SSO login", () => { - beforeEach(() => { - cy.task("startOAuthServer") - .then((oAuthServerPort: number) => { - return cy.startHomeserver({ template: "default", oAuthServerPort }); - }) - .then((data) => { - homeserver = data; - }); - }); - - afterEach(() => { - cy.task("stopOAuthServer"); - }); - - it("logs in with SSO and lands on the home screen", () => { - // 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 - // - doTokenRegistration(homeserver.baseUrl); - - // Eventually, we should end up at the home screen. - cy.url().should("contain", "/#/home", { timeout: 30000 }); - cy.findByRole("heading", { name: "Welcome Alice" }); - }); - }); - - describe("logout", () => { - beforeEach(() => { - cy.startHomeserver("consent").then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Erin"); - }); - }); - - it("should go to login page on logout", () => { - cy.findByRole("button", { name: "User menu" }).click(); - - // give a change for the outstanding requests queue to settle before logging out - cy.wait(2000); - - cy.get(".mx_UserMenu_contextMenu").within(() => { - cy.findByRole("menuitem", { name: "Sign out" }).click(); - }); - - cy.url().should("contain", "/#/login"); - }); - - it("should respect logout_redirect_url", () => { - cy.tweakConfig({ - // 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/", - }); - - cy.findByRole("button", { name: "User menu" }).click(); - - // give a change for the outstanding requests queue to settle before logging out - cy.wait(2000); - - cy.get(".mx_UserMenu_contextMenu").within(() => { - cy.findByRole("menuitem", { name: "Sign out" }).click(); - }); - - cy.url().should("contains", "decoder-ring"); - }); - }); -}); diff --git a/cypress/e2e/login/soft_logout.spec.ts b/cypress/e2e/login/soft_logout.spec.ts deleted file mode 100644 index 8a96b56e9c..0000000000 --- a/cypress/e2e/login/soft_logout.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; -import { doTokenRegistration } from "./utils"; - -describe("Soft logout", () => { - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.task("startOAuthServer") - .then((oAuthServerPort: number) => { - return cy.startHomeserver({ template: "default", oAuthServerPort }); - }) - .then((data) => { - homeserver = data; - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.task("stopOAuthServer"); - }); - - describe("with password user", () => { - let testUserCreds: UserCredentials; - - beforeEach(() => { - cy.initTestUser(homeserver, "Alice").then((creds) => { - testUserCreds = creds; - }); - }); - - it("shows the soft-logout page when a request fails, and allows a re-login", () => { - interceptRequestsWithSoftLogout(); - cy.findByText("You're signed out"); - cy.findByPlaceholderText("Password").type(testUserCreds.password).type("{enter}"); - - // back to the welcome page - cy.url().should("contain", "/#/home", { timeout: 30000 }); - cy.findByRole("heading", { name: "Welcome Alice" }); - }); - - it("still shows the soft-logout page when the page is reloaded after a soft-logout", () => { - interceptRequestsWithSoftLogout(); - cy.findByText("You're signed out"); - cy.reload(); - cy.findByText("You're signed out"); - }); - }); - - describe("with SSO user", () => { - beforeEach(() => { - doTokenRegistration(homeserver.baseUrl); - - // Eventually, we should end up at the home screen. - cy.url().should("contain", "/#/home", { timeout: 30000 }); - cy.findByRole("heading", { name: "Welcome Alice" }); - }); - - it("shows the soft-logout page when a request fails, and allows a re-login", () => { - cy.findByRole("heading", { name: "Welcome Alice" }); - - interceptRequestsWithSoftLogout(); - - cy.findByText("You're signed out"); - cy.findByRole("button", { name: "Continue with OAuth test" }).click(); - - // click the submit button - cy.findByRole("button", { name: "Submit" }).click(); - - // Synapse prompts us to grant permission to Element - cy.findByRole("heading", { name: "Continue to your account" }); - cy.findByRole("link", { name: "Continue" }).click(); - - // back to the welcome page - cy.url().should("contain", "/#/home", { timeout: 30000 }); - cy.findByRole("heading", { name: "Welcome Alice" }); - }); - }); -}); - -/** - * Intercept calls to /sync and have them fail with a soft-logout - * - * Any further requests to /sync with the same access token are blocked. - */ -function interceptRequestsWithSoftLogout(): void { - let expiredAccessToken: string | null = null; - cy.intercept( - { - pathname: "/_matrix/client/*/sync", - }, - (req) => { - const accessToken = req.headers["authorization"] as string; - - // on the first request, record the access token - if (!expiredAccessToken) { - console.log(`Soft-logout on access token ${accessToken}`); - expiredAccessToken = accessToken; - } - - // now, if the access token on this request matches the expired one, block it - if (expiredAccessToken && accessToken === expiredAccessToken) { - console.log(`Intercepting request with soft-logged-out access token`); - req.reply({ - statusCode: 401, - body: { - errcode: "M_UNKNOWN_TOKEN", - error: "Soft logout", - soft_logout: true, - }, - }); - return; - } - - // otherwise, pass through as normal - req.continue(); - }, - ); - - // do something to make the active /sync return: create a new room - cy.getClient().then((client) => { - // don't wait for this to complete: it probably won't, because of the broken sync - return client.createRoom({}); - }); -} diff --git a/cypress/e2e/login/utils.ts b/cypress/e2e/login/utils.ts deleted file mode 100644 index 39d87b4275..0000000000 --- a/cypress/e2e/login/utils.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** Visit the login page, choose to log in with "OAuth test", register a new account, and redirect back to Element - */ -export function doTokenRegistration(homeserverUrl: string) { - cy.visit("/#/login"); - - cy.findByRole("button", { name: "Edit" }).click(); - cy.findByRole("textbox", { name: "Other homeserver" }).type(homeserverUrl); - cy.findByRole("button", { name: "Continue" }).click(); - // wait for the dialog to go away - cy.get(".mx_ServerPickerDialog").should("not.exist"); - - // click on "Continue with OAuth test" - cy.findByRole("button", { name: "Continue with OAuth test" }).click(); - - // wait for the Test OAuth Page to load - cy.findByText("Test OAuth page"); - - // click the submit button - cy.findByRole("button", { name: "Submit" }).click(); - - // Synapse prompts us to pick a user ID - cy.findByRole("heading", { name: "Create your account" }); - cy.findByRole("textbox", { name: "Username (required)" }).type("alice"); - - // wait for username validation to start, and complete - cy.wait(50); - cy.get("#field-username-output").should("have.value", ""); - cy.findByRole("button", { name: "Continue" }).click(); - - // Synapse prompts us to grant permission to Element - cy.findByRole("heading", { name: "Continue to your account" }); - cy.findByRole("link", { name: "Continue" }).click(); -} diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index d66adb2672..a0bd7a5c7f 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -26,17 +26,12 @@ import { slidingSyncProxyDocker } from "./sliding-sync"; import { webserver } from "./webserver"; import { docker } from "./docker"; import { log } from "./log"; -import { oAuthServer } from "./oauth_server"; /** * @type {Cypress.PluginConfig} */ export default function (on: PluginEvents, config: PluginConfigOptions) { - initPlugins( - on, - [docker, synapseDocker, dendriteDocker, slidingSyncProxyDocker, webserver, oAuthServer, log], - config, - ); + initPlugins(on, [docker, synapseDocker, dendriteDocker, slidingSyncProxyDocker, webserver, log], config); installLogsPrinter(on, { printLogsToConsole: "never", diff --git a/cypress/plugins/oauth_server/index.ts b/cypress/plugins/oauth_server/index.ts deleted file mode 100644 index 862ffd9af2..0000000000 --- a/cypress/plugins/oauth_server/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import http from "http"; -import express from "express"; -import { AddressInfo } from "net"; - -import PluginEvents = Cypress.PluginEvents; -import PluginConfigOptions = Cypress.PluginConfigOptions; - -const servers: http.Server[] = []; - -function startOAuthServer(html: string): number { - const app = express(); - - // static files. This includes the "authorization endpoint". - app.use(express.static(__dirname + "/res")); - - // token endpoint (see https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint) - app.use("/oauth/token", express.urlencoded()); - app.post("/oauth/token", (req, res) => { - // if the code is valid, accept it. Otherwise, return an error. - const code = req.body.code; - if (code === "valid_auth_code") { - res.send({ - access_token: "oauth_access_token", - token_type: "Bearer", - expires_in: "3600", - }); - } else { - res.send({ error: "bad auth code" }); - } - }); - - // userinfo endpoint (see https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) - app.get("/oauth/userinfo", (req, res) => { - // TODO: validate that the request carries an auth header which matches the access token we issued above - - // return an OAuth2 user info object - res.send({ - sub: "alice", - name: "Alice", - }); - }); - - const server = http.createServer(app); - server.listen(); - servers.push(server); - const address = server.address() as AddressInfo; - console.log(`Started OAuth server at ${address.address}:${address.port}`); - return address.port; -} - -function stopOAuthServer(): null { - console.log("Stopping OAuth servers"); - for (const server of servers) { - const address = server.address() as AddressInfo; - server.close(); - console.log(`Stopped OAuth server at ${address.address}:${address.port}`); - } - servers.splice(0, servers.length); // clear - return null; -} - -export function oAuthServer(on: PluginEvents, config: PluginConfigOptions) { - on("task", { startOAuthServer, stopOAuthServer }); - on("after:run", stopOAuthServer); -} diff --git a/package.json b/package.json index 8edf49777a..0cab33910a 100644 --- a/package.json +++ b/package.json @@ -160,6 +160,7 @@ "@types/counterpart": "^0.18.1", "@types/diff-match-patch": "^1.0.32", "@types/escape-html": "^1.0.1", + "@types/express": "^4.17.21", "@types/file-saver": "^2.0.3", "@types/fs-extra": "^11.0.0", "@types/glob-to-regexp": "^0.4.1", diff --git a/playwright/e2e/login/login.spec.ts b/playwright/e2e/login/login.spec.ts index 4754e92c51..43dda43022 100644 --- a/playwright/e2e/login/login.spec.ts +++ b/playwright/e2e/login/login.spec.ts @@ -15,8 +15,9 @@ limitations under the License. */ import { test, expect } from "../../element-web-test"; +import { doTokenRegistration } from "./utils"; -test.describe("Consent", () => { +test.describe("Login", () => { test.describe("m.login.password", () => { test.use({ startHomeserverOpts: "consent" }); @@ -75,4 +76,65 @@ test.describe("Consent", () => { await expect(page).toHaveURL(/\/#\/home$/); }); }); + + // 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.use({ + startHomeserverOpts: ({ oAuthServer }, use) => + use({ + template: "default", + oAuthServerPort: oAuthServer.port, + }), + }); + + 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({ startHomeserverOpts: "consent" }); + + 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(); + + // Allow 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(/\/#\/login$/); + }); + }); + + test.describe("logout with logout_redirect_url", () => { + test.use({ + startHomeserverOpts: "consent", + 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\/$/); + }); + }); }); diff --git a/playwright/e2e/login/soft_logout.spec.ts b/playwright/e2e/login/soft_logout.spec.ts new file mode 100644 index 0000000000..3b8e51e399 --- /dev/null +++ b/playwright/e2e/login/soft_logout.spec.ts @@ -0,0 +1,125 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Page } from "@playwright/test"; + +import { test, expect } from "../../element-web-test"; +import { doTokenRegistration } from "./utils"; +import { Credentials } from "../../plugins/utils/homeserver"; + +test.describe("Soft logout", () => { + test.use({ + displayName: "Alice", + startHomeserverOpts: ({ oAuthServer }, use) => + use({ + template: "default", + oAuthServerPort: oAuthServer.port, + }), + }); + + 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: "Welcome Alice", 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.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 { + 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(); + }); + + // 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 page.waitForResponse((resp) => resp.url().includes("/sync") && resp.status() === 401); +} diff --git a/playwright/e2e/login/utils.ts b/playwright/e2e/login/utils.ts new file mode 100644 index 0000000000..856e3f610b --- /dev/null +++ b/playwright/e2e/login/utils.ts @@ -0,0 +1,68 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Page, expect } from "@playwright/test"; + +import { Credentials, HomeserverInstance } from "../../plugins/utils/homeserver"; + +/** Visit the login page, choose to log in with "OAuth test", register a new account, and redirect back to Element + */ +export async function doTokenRegistration( + page: Page, + homeserver: HomeserverInstance, +): Promise { + await page.goto("/#/login"); + + await page.getByRole("button", { name: "Edit" }).click(); + await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl); + await page.getByRole("button", { name: "Continue" }).click(); + // wait for the dialog to go away + await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0); + + // click on "Continue with OAuth test" + await page.getByRole("button", { name: "Continue with OAuth test" }).click(); + + // wait for the Test OAuth Page to load + await expect(page.getByText("Test OAuth page")).toBeVisible(); + + // click the submit button + await page.getByRole("button", { name: "Submit" }).click(); + + // Synapse prompts us to pick a user ID + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + await page.getByRole("textbox", { name: "Username (required)" }).type("alice"); + + // wait for username validation to start, and complete + await expect(page.locator("#field-username-output")).toHaveText(""); + await page.getByRole("button", { name: "Continue" }).click(); + + // Synapse prompts us to grant permission to Element + page.getByRole("heading", { name: "Continue to your account" }); + await page.getByRole("link", { name: "Continue" }).click(); + + // Eventually, we should end up at the home screen. + await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 }); + await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible(); + + return page.evaluate(() => ({ + accessToken: window.mxMatrixClientPeg.get().getAccessToken(), + userId: window.mxMatrixClientPeg.get().getUserId(), + deviceId: window.mxMatrixClientPeg.get().getDeviceId(), + homeServer: window.mxMatrixClientPeg.get().getHomeserverUrl(), + password: null, + displayName: "Alice", + })); +} diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 2e8e796df3..51cb003489 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -16,12 +16,14 @@ limitations under the License. import { test as base, expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; +import _ from "lodash"; import type mailhog from "mailhog"; import type { IConfigOptions } from "../src/IConfigOptions"; -import { HomeserverInstance, StartHomeserverOpts } from "./plugins/utils/homeserver"; +import { Credentials, HomeserverInstance, StartHomeserverOpts } from "./plugins/utils/homeserver"; import { Synapse } from "./plugins/synapse"; import { Instance } from "./plugins/mailhog"; +import { OAuthServer } from "./plugins/oauth_server"; const CONFIG_JSON: Partial = { // This is deliberately quite a minimal config.json, so that we can test that the default settings @@ -47,9 +49,16 @@ export const test = base.extend< TestOptions & { axe: AxeBuilder; checkA11y: () => Promise; + // The contents of the config.json to send config: typeof CONFIG_JSON; + // The options with which to run the `homeserver` fixture startHomeserverOpts: StartHomeserverOpts | string; homeserver: HomeserverInstance; + oAuthServer: { port: number }; + user: Credentials & { + displayName: string; + }; + displayName?: string; mailhog?: { api: mailhog.API; instance: Instance }; } >({ @@ -57,7 +66,7 @@ export const test = base.extend< config: CONFIG_JSON, page: async ({ context, page, config, crypto }, use) => { await context.route(`http://localhost:8080/config.json*`, async (route) => { - const json = { ...config }; + const json = { ...CONFIG_JSON, ...config }; if (crypto === "rust") { json["features"] = { ...json["features"], @@ -66,6 +75,7 @@ export const test = base.extend< } await route.fulfill({ json }); }); + await use(page); }, @@ -79,6 +89,49 @@ export const test = base.extend< await use(await server.start(opts)); await server.stop(); }, + // eslint-disable-next-line no-empty-pattern + oAuthServer: async ({}, use) => { + const server = new OAuthServer(); + const port = server.start(); + await use({ port }); + server.stop(); + }, + + displayName: undefined, + user: async ({ page, homeserver, displayName: testDisplayName }, use) => { + const names = ["Alice", "Bob", "Charlie", "Daniel", "Eve", "Frank", "Grace", "Hannah", "Isaac", "Judy"]; + const username = _.uniqueId("user_"); + const password = _.uniqueId("password_"); + const displayName = testDisplayName ?? _.sample(names)!; + + const credentials = await homeserver.registerUser(username, password, displayName); + console.log(`Registered test user ${username} with displayname ${displayName}`); + + await page.addInitScript( + ({ baseUrl, credentials }) => { + // Seed the localStorage with the required credentials + window.localStorage.setItem("mx_hs_url", baseUrl); + window.localStorage.setItem("mx_user_id", credentials.userId); + window.localStorage.setItem("mx_access_token", credentials.accessToken); + window.localStorage.setItem("mx_device_id", credentials.deviceId); + window.localStorage.setItem("mx_is_guest", "false"); + window.localStorage.setItem("mx_has_pickle_key", "false"); + window.localStorage.setItem("mx_has_access_token", "true"); + + // Ensure the language is set to a consistent value + window.localStorage.setItem("mx_local_settings", '{"language":"en"}'); + }, + { baseUrl: homeserver.config.baseUrl, credentials }, + ); + await page.goto("/"); + + await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 }); + + await use({ + ...credentials, + displayName, + }); + }, axe: async ({ page }, use) => { await use(new AxeBuilder({ page })); @@ -98,4 +151,4 @@ export const test = base.extend< test.use({}); -export { expect } from "@playwright/test"; +export { expect }; diff --git a/cypress/plugins/oauth_server/README.md b/playwright/plugins/oauth_server/README.md similarity index 100% rename from cypress/plugins/oauth_server/README.md rename to playwright/plugins/oauth_server/README.md diff --git a/playwright/plugins/oauth_server/index.ts b/playwright/plugins/oauth_server/index.ts new file mode 100644 index 0000000000..065436ef37 --- /dev/null +++ b/playwright/plugins/oauth_server/index.ts @@ -0,0 +1,72 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import http from "http"; +import express from "express"; +import { AddressInfo } from "net"; + +export class OAuthServer { + private server?: http.Server; + + public start(): number { + if (this.server) this.stop(); + + const app = express(); + + // static files. This includes the "authorization endpoint". + app.use(express.static(__dirname + "/res")); + + // token endpoint (see https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint) + app.use("/oauth/token", express.urlencoded({ extended: true })); + app.post("/oauth/token", (req, res) => { + // if the code is valid, accept it. Otherwise, return an error. + const code = req.body.code; + if (code === "valid_auth_code") { + res.send({ + access_token: "oauth_access_token", + token_type: "Bearer", + expires_in: "3600", + }); + } else { + res.send({ error: "bad auth code" }); + } + }); + + // userinfo endpoint (see https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) + app.get("/oauth/userinfo", (req, res) => { + // TODO: validate that the request carries an auth header which matches the access token we issued above + + // return an OAuth2 user info object + res.send({ + sub: "alice", + name: "Alice", + }); + }); + + this.server = http.createServer(app); + this.server.listen(); + const address = this.server.address() as AddressInfo; + console.log(`Started OAuth server at ${address.address}:${address.port}`); + return address.port; + } + + public stop(): void { + console.log("Stopping OAuth server"); + const address = this.server.address() as AddressInfo; + this.server.close(); + console.log(`Stopped OAuth server at ${address.address}:${address.port}`); + } +} diff --git a/cypress/plugins/oauth_server/res/oauth/auth.html b/playwright/plugins/oauth_server/res/oauth/auth.html similarity index 100% rename from cypress/plugins/oauth_server/res/oauth/auth.html rename to playwright/plugins/oauth_server/res/oauth/auth.html diff --git a/playwright/plugins/utils/homeserver.ts b/playwright/plugins/utils/homeserver.ts index e369fb2142..853e7dcb26 100644 --- a/playwright/plugins/utils/homeserver.ts +++ b/playwright/plugins/utils/homeserver.ts @@ -53,5 +53,5 @@ export interface Credentials { userId: string; deviceId: string; homeServer: string; - password: string; + password: string | null; // null for password-less users } diff --git a/playwright/tsconfig.json b/playwright/tsconfig.json index 5baa4a700b..7651d24e97 100644 --- a/playwright/tsconfig.json +++ b/playwright/tsconfig.json @@ -8,5 +8,5 @@ "moduleResolution": "node", "module": "es2022" }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "../src/@types/global.d.ts"] } diff --git a/yarn.lock b/yarn.lock index 1bf91f21c8..d0dd55aeda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2624,11 +2624,26 @@ dependencies: "@babel/types" "^7.20.7" +"@types/body-parser@*": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" + integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== + dependencies: + "@types/connect" "*" + "@types/node" "*" + "@types/commonmark@^0.27.4": version "0.27.9" resolved "https://registry.yarnpkg.com/@types/commonmark/-/commonmark-0.27.9.tgz#2d2d42e72127c84525fbbc87aaefb5a43e1129d7" integrity sha512-d3+57WgyPCcIc6oshmcPkmP4+JqRRot9eeZLsBsutWtIxwWivpoyc2wEcolOp8MyO3ZWN846mMdoR02kdHSMCw== +"@types/connect@*": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + "@types/content-type@^1.1.5": version "1.1.8" resolved "https://registry.yarnpkg.com/@types/content-type/-/content-type-1.1.8.tgz#319644d07ee6b4bfc734483008393b89b99f0219" @@ -2654,6 +2669,26 @@ resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.3.tgz#a8ef894305af28d1fc6d2dfdfc98e899591ea529" integrity sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g== +"@types/express-serve-static-core@^4.17.33": + version "4.17.41" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz#5077defa630c2e8d28aa9ffc2c01c157c305bef6" + integrity sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@^4.17.21": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" + integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + "@types/file-saver@^2.0.3": version "2.0.7" resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.7.tgz#8dbb2f24bdc7486c54aa854eb414940bbd056f7d" @@ -2692,6 +2727,11 @@ "@types/react" "*" hoist-non-react-statics "^3.3.0" +"@types/http-errors@*": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" + integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" @@ -2774,6 +2814,16 @@ "@types/mapbox__point-geometry" "*" "@types/pbf" "*" +"@types/mime@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45" + integrity sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw== + +"@types/mime@^1": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" + integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== + "@types/minimist@^1.2.2": version "1.2.3" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.3.tgz#dd249cef80c6fff2ba6a0d4e5beca913e04e25f8" @@ -2841,6 +2891,16 @@ dependencies: "@types/node" "*" +"@types/qs@*": + version "6.9.10" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.10.tgz#0af26845b5067e1c9a622658a51f60a3934d51e8" + integrity sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw== + +"@types/range-parser@*": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" + integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== + "@types/react-beautiful-dnd@^13.0.0": version "13.1.5" resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.5.tgz#04869f2ec4658aa963e56dc3cbb91f261587dedc" @@ -2908,6 +2968,23 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.5.tgz#deed5ab7019756c9c90ea86139106b0346223f35" integrity sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg== +"@types/send@*": + version "0.17.4" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" + integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@types/serve-static@*": + version "1.15.5" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.5.tgz#15e67500ec40789a1e8c9defc2d32a896f05b033" + integrity sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ== + dependencies: + "@types/http-errors" "*" + "@types/mime" "*" + "@types/node" "*" + "@types/sinonjs__fake-timers@8.1.1": version "8.1.1" resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz#b49c2c70150141a15e0fa7e79cf1f92a72934ce3"