diff --git a/cypress/integration/1-register/register.spec.ts b/cypress/integration/1-register/register.spec.ts index e06d1e5f28..f61a10e304 100644 --- a/cypress/integration/1-register/register.spec.ts +++ b/cypress/integration/1-register/register.spec.ts @@ -22,10 +22,10 @@ describe("Registration", () => { let synapse: SynapseInstance; beforeEach(() => { + cy.visit("/#/register"); cy.startSynapse("consent").then(data => { synapse = data; }); - cy.visit("/#/register"); }); afterEach(() => { @@ -34,14 +34,16 @@ describe("Registration", () => { it("registers an account and lands on the home screen", () => { cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click(); - cy.get(".mx_ServerPickerDialog_otherHomeserver").type(`http://localhost:${synapse.port}`); + cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); cy.get(".mx_ServerPickerDialog_continue").click(); // wait for the dialog to go away cy.get('.mx_ServerPickerDialog').should('not.exist'); + cy.get("#mx_RegistrationForm_username").type("alice"); cy.get("#mx_RegistrationForm_password").type("totally a great password"); cy.get("#mx_RegistrationForm_passwordConfirm").type("totally a great password"); cy.get(".mx_Login_submit").click(); + cy.get(".mx_RegistrationEmailPromptDialog button.mx_Dialog_primary").click(); cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy input").click(); cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click(); diff --git a/cypress/integration/2-login/login.spec.ts b/cypress/integration/2-login/login.spec.ts new file mode 100644 index 0000000000..9fb7ba4792 --- /dev/null +++ b/cypress/integration/2-login/login.spec.ts @@ -0,0 +1,57 @@ +/* +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 { SynapseInstance } from "../../plugins/synapsedocker"; + +describe("Login", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.visit("/#/login"); + cy.startSynapse("consent").then(data => { + synapse = data; + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + describe("m.login.password", () => { + const username = "user1234"; + const password = "p4s5W0rD"; + + beforeEach(() => { + cy.registerUser(synapse, username, password); + }); + + it("logs in with an existing account and lands on the home screen", () => { + cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click(); + cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); + cy.get(".mx_ServerPickerDialog_continue").click(); + // wait for the dialog to go away + cy.get('.mx_ServerPickerDialog').should('not.exist'); + + cy.get("#mx_LoginForm_username").type(username); + cy.get("#mx_LoginForm_password").type(password); + cy.get(".mx_Login_submit").click(); + + cy.url().should('contain', '/#/home'); + }); + }); +}); diff --git a/cypress/integration/3-user-menu/user-menu.spec.ts b/cypress/integration/3-user-menu/user-menu.spec.ts new file mode 100644 index 0000000000..b3c482d9f1 --- /dev/null +++ b/cypress/integration/3-user-menu/user-menu.spec.ts @@ -0,0 +1,45 @@ +/* +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 { SynapseInstance } from "../../plugins/synapsedocker"; +import type { UserCredentials } from "../../support/login"; + +describe("UserMenu", () => { + let synapse: SynapseInstance; + let user: UserCredentials; + + beforeEach(() => { + cy.startSynapse("consent").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Jeff").then(credentials => { + user = credentials; + }); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should contain our name & userId", () => { + cy.get('[aria-label="User menu"]', { timeout: 15000 }).click(); + cy.get(".mx_UserMenu_contextMenu_displayName").should("contain", "Jeff"); + cy.get(".mx_UserMenu_contextMenu_userId").should("contain", user.userId); + }); +}); diff --git a/cypress/plugins/synapsedocker/index.ts b/cypress/plugins/synapsedocker/index.ts index 912e99431e..af8ddac73c 100644 --- a/cypress/plugins/synapsedocker/index.ts +++ b/cypress/plugins/synapsedocker/index.ts @@ -21,6 +21,7 @@ import * as os from "os"; import * as crypto from "crypto"; import * as childProcess from "child_process"; import * as fse from "fs-extra"; +import * as net from "net"; import PluginEvents = Cypress.PluginEvents; import PluginConfigOptions = Cypress.PluginConfigOptions; @@ -31,11 +32,13 @@ import PluginConfigOptions = Cypress.PluginConfigOptions; interface SynapseConfig { configDir: string; registrationSecret: string; + // Synapse must be configured with its public_baseurl so we have to allocate a port & url at this stage + baseUrl: string; + port: number; } export interface SynapseInstance extends SynapseConfig { synapseId: string; - port: number; } const synapses = new Map(); @@ -44,6 +47,16 @@ function randB64Bytes(numBytes: number): string { return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, ""); } +async function getFreePort(): Promise { + return new Promise(resolve => { + const srv = net.createServer(); + srv.listen(0, () => { + const port = (srv.address()).port; + srv.close(() => resolve(port)); + }); + }); +} + async function cfgDirFromTemplate(template: string): Promise { const templateDir = path.join(__dirname, "templates", template); @@ -64,12 +77,16 @@ async function cfgDirFromTemplate(template: string): Promise { const macaroonSecret = randB64Bytes(16); const formSecret = randB64Bytes(16); - // now copy homeserver.yaml, applying sustitutions + const port = await getFreePort(); + const baseUrl = `http://localhost:${port}`; + + // now copy homeserver.yaml, applying substitutions console.log(`Gen ${path.join(templateDir, "homeserver.yaml")}`); let hsYaml = await fse.readFile(path.join(templateDir, "homeserver.yaml"), "utf8"); hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret); hsYaml = hsYaml.replace(/{{MACAROON_SECRET_KEY}}/g, macaroonSecret); hsYaml = hsYaml.replace(/{{FORM_SECRET}}/g, formSecret); + hsYaml = hsYaml.replace(/{{PUBLIC_BASEURL}}/g, baseUrl); await fse.writeFile(path.join(tempDir, "homeserver.yaml"), hsYaml); // now generate a signing key (we could use synapse's config generation for @@ -80,6 +97,8 @@ async function cfgDirFromTemplate(template: string): Promise { await fse.writeFile(path.join(tempDir, "localhost.signing.key"), `ed25519 x ${signingKey}`); return { + port, + baseUrl, configDir: tempDir, registrationSecret, }; @@ -101,7 +120,7 @@ async function synapseStart(template: string): Promise { "--name", containerName, "-d", "-v", `${synCfg.configDir}:/data`, - "-p", "8008/tcp", + "-p", `${synCfg.port}:8008/tcp`, "matrixdotorg/synapse:develop", "run", ], (err, stdout) => { @@ -110,26 +129,27 @@ async function synapseStart(template: string): Promise { }); }); - // Get the port that docker allocated: specifying only one - // port above leaves docker to just grab a free one, although - // in hindsight we need to put the port in public_baseurl in the - // config really, so this will probably need changing to use a fixed - // / configured port. - const port = await new Promise((resolve, reject) => { - childProcess.execFile('docker', [ - "port", synapseId, "8008", + synapses.set(synapseId, { synapseId, ...synCfg }); + + console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`); + + // Await Synapse healthcheck + await new Promise((resolve, reject) => { + childProcess.execFile("docker", [ + "exec", synapseId, + "curl", + "--connect-timeout", "30", + "--retry", "30", + "--retry-delay", "1", + "--retry-all-errors", + "--silent", + "http://localhost:8008/health", ], { encoding: 'utf8' }, (err, stdout) => { if (err) reject(err); - resolve(Number(stdout.trim().split(":")[1])); + else resolve(); }); }); - synapses.set(synapseId, Object.assign({ - port, - synapseId, - }, synCfg)); - - console.log(`Started synapse with id ${synapseId} on port ${port}.`); return synapses.get(synapseId); } diff --git a/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml b/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml index e26133f6d1..6decaeb5a0 100644 --- a/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml +++ b/cypress/plugins/synapsedocker/templates/consent/homeserver.yaml @@ -1,6 +1,6 @@ server_name: "localhost" pid_file: /data/homeserver.pid -public_baseurl: http://localhost:5005/ +public_baseurl: "{{PUBLIC_BASEURL}}" listeners: - port: 8008 tls: false diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 289319fe97..598cc4de7e 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -17,3 +17,4 @@ limitations under the License. /// import "./synapse"; +import "./login"; diff --git a/cypress/support/login.ts b/cypress/support/login.ts new file mode 100644 index 0000000000..2d7d3ef84a --- /dev/null +++ b/cypress/support/login.ts @@ -0,0 +1,86 @@ +/* +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 Chainable = Cypress.Chainable; +import { SynapseInstance } from "../plugins/synapsedocker"; + +export interface UserCredentials { + accessToken: string; + userId: string; + deviceId: string; + password: string; + homeServer: string; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Generates a test user and instantiates an Element session with that user. + * @param synapse the synapse returned by startSynapse + * @param displayName the displayName to give the test user + */ + initTestUser(synapse: SynapseInstance, displayName: string): Chainable; + } + } +} + +Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string): Chainable => { + const username = Cypress._.uniqueId("userId_"); + const password = Cypress._.uniqueId("password_"); + return cy.registerUser(synapse, username, password, displayName).then(() => { + const url = `${synapse.baseUrl}/_matrix/client/r0/login`; + return cy.request<{ + access_token: string; + user_id: string; + device_id: string; + home_server: string; + }>({ + url, + method: "POST", + body: { + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": username, + }, + "password": password, + }, + }); + }).then(response => { + return cy.window().then(win => { + // Seed the localStorage with the required credentials + win.localStorage.setItem("mx_hs_url", synapse.baseUrl); + win.localStorage.setItem("mx_user_id", response.body.user_id); + win.localStorage.setItem("mx_access_token", response.body.access_token); + win.localStorage.setItem("mx_device_id", response.body.device_id); + win.localStorage.setItem("mx_is_guest", "false"); + win.localStorage.setItem("mx_has_pickle_key", "false"); + win.localStorage.setItem("mx_has_access_token", "true"); + + return cy.visit("/").then(() => ({ + password, + accessToken: response.body.access_token, + userId: response.body.user_id, + deviceId: response.body.device_id, + homeServer: response.body.home_server, + })); + }); + }); +}); diff --git a/cypress/support/synapse.ts b/cypress/support/synapse.ts index f49f55a19d..1571ddef36 100644 --- a/cypress/support/synapse.ts +++ b/cypress/support/synapse.ts @@ -16,6 +16,8 @@ limitations under the License. /// +import * as crypto from 'crypto'; + import Chainable = Cypress.Chainable; import AUTWindow = Cypress.AUTWindow; import { SynapseInstance } from "../plugins/synapsedocker"; @@ -29,12 +31,27 @@ declare global { * @param template path to template within cypress/plugins/synapsedocker/template/ directory. */ startSynapse(template: string): Chainable; + /** * Custom command wrapping task:synapseStop whilst preventing uncaught exceptions * for if Synapse stopping races with the app's background sync loop. * @param synapse the synapse instance returned by startSynapse */ stopSynapse(synapse: SynapseInstance): Chainable; + + /** + * Register a user on the given Synapse using the shared registration secret. + * @param synapse the synapse instance returned by startSynapse + * @param username the username of the user to register + * @param password the password of the user to register + * @param displayName optional display name to set on the newly registered user + */ + registerUser( + synapse: SynapseInstance, + username: string, + password: string, + displayName?: string, + ): Chainable; } } } @@ -51,5 +68,54 @@ function stopSynapse(synapse: SynapseInstance): Chainable { }); } +interface Credentials { + accessToken: string; + userId: string; + deviceId: string; + homeServer: string; +} + +function registerUser( + synapse: SynapseInstance, + username: string, + password: string, + displayName?: string, +): Chainable { + const url = `${synapse.baseUrl}/_synapse/admin/v1/register`; + return cy.then(() => { + // get a nonce + return cy.request<{ nonce: string }>({ url }); + }).then(response => { + const { nonce } = response.body; + const mac = crypto.createHmac('sha1', synapse.registrationSecret).update( + `${nonce}\0${username}\0${password}\0notadmin`, + ).digest('hex'); + + return cy.request<{ + access_token: string; + user_id: string; + home_server: string; + device_id: string; + }>({ + url, + method: "POST", + body: { + nonce, + username, + password, + mac, + admin: false, + displayname: displayName, + }, + }); + }).then(response => ({ + homeServer: response.body.home_server, + accessToken: response.body.access_token, + userId: response.body.user_id, + deviceId: response.body.device_id, + })); +} + Cypress.Commands.add("startSynapse", startSynapse); Cypress.Commands.add("stopSynapse", stopSynapse); +Cypress.Commands.add("registerUser", registerUser); diff --git a/docs/cypress.md b/docs/cypress.md index a6436e4b99..95b9b330d1 100644 --- a/docs/cypress.md +++ b/docs/cypress.md @@ -9,7 +9,7 @@ It aims to cover: ## Running the Tests Our Cypress tests run automatically as part of our CI along with our other tests, -on every pull request and on every merge to develop. +on every pull request and on every merge to develop & master. However the Cypress tests are run, an element-web must be running on http://localhost:8080 (this is configured in `cypress.json`) - this is what will @@ -53,17 +53,17 @@ Synapse can be launched with different configurations in order to test element in different configurations. `cypress/plugins/synapsedocker/templates` contains template configuration files for each different configuration. -Each test suite can then launch whatever Syanpse instances it needs it whatever +Each test suite can then launch whatever Synapse instances it needs it whatever configurations. Note that although tests should stop the Synapse instances after running and the plugin also stop any remaining instances after all tests have run, it is possible to be left with some stray containers if, for example, you terminate a test such that the `after()` does not run and also exit Cypress uncleanly. All the containers -it starts are prefixed so they are easy to recognise. They can be removed safely. +it starts are prefixed, so they are easy to recognise. They can be removed safely. -After each test run, logs from the Syanpse instances are saved in `cypress/synapselogs` -with each instance in a separate directory named after it's ID. These logs are removed +After each test run, logs from the Synapse instances are saved in `cypress/synapselogs` +with each instance in a separate directory named after its ID. These logs are removed at the start of each test run. ## Writing Tests @@ -73,23 +73,29 @@ https://docs.cypress.io/guides/references/best-practices . ### Getting a Synapse The key difference is in starting Synapse instances. Tests use this plugin via -`cy.task()` to provide a Synapse instance to log into: +`cy.startSynapse()` to provide a Synapse instance to log into: -``` -cy.task("synapseStart", "consent").then(result => { - synapseId = result.synapseId; - synapsePort = result.port; +```javascript +cy.startSynapse("consent").then(result => { + synapse = result; }); ``` This returns an object with information about the Synapse instance, including what port it was started on and the ID that needs to be passed to shut it down again. It also returns the registration shared secret (`registrationSecret`) that can be used to -register users via the REST API. +register users via the REST API. The Synapse has been ensured ready to go by awaiting +its internal health-check. Synapse instances should be reasonably cheap to start (you may see the first one take a while as it pulls the Docker image), so it's generally expected that tests will start a -Synapse instance for each test suite, ie. in `before()`, and then tear it down in `after()`. +Synapse instance for each test suite, i.e. in `before()`, and then tear it down in `after()`. + +To later destroy your Synapse you should call `stopSynapse`, passing the SynapseInstance +object you received when starting it. +```javascript +cy.stopSynapse(synapse); +``` ### Synapse Config Templates When a Synapse instance is started, it's given a config generated from one of the config @@ -100,6 +106,7 @@ in these templates: * `REGISTRATION_SECRET`: The secret used to register users via the REST API. * `MACAROON_SECRET_KEY`: Generated each time for security * `FORM_SECRET`: Generated each time for security + * `PUBLIC_BASEURL`: The localhost url + port combination the synapse is accessible at * `localhost.signing.key`: A signing key is auto-generated and saved to this file. Config templates should not contain a signing key and instead assume that one will exist in this file. @@ -108,24 +115,18 @@ All other files in the template are copied recursively to `/data/`, so the file in a template can be referenced in the config as `/data/foo.html`. ### Logging In -This doesn't quite exist yet. Most tests will just want to start with the client in a 'logged in' -state, so we should provide an easy way to start a test with element in this state. The -`registrationSecret` provided when starting a Synapse can be used to create a user (porting -the code from https://github.com/matrix-org/matrix-react-sdk/blob/develop/test/end-to-end-tests/src/rest/creator.ts#L49). -We'd then need to log in as this user. Ways of doing this would be: +There exists a basic utility to start the app with a random user already logged in: +```javascript +cy.initTestUser(synapse, "Jeff"); +``` +It takes the SynapseInstance you received from `startSynapse` and a display name for your test user. +This custom command will register a random userId using the registrationSecret with a random password +and the given display name. The returned Chainable will contain details about the credentials for if +they are needed for User-Interactive Auth or similar but localStorage will already be seeded with them +and the app loaded (path `/`). -1. Fill in the login form. This isn't ideal as it's effectively testing the login process in each - test, and will just be slower. -1. Mint an access token using https://matrix-org.github.io/synapse/develop/admin_api/user_admin_api.html#login-as-a-user - then inject this into element-web. This would probably be fastest, although also relies on correctly - setting up localstorage -1. Mint a login token, inject the Homeserver URL into localstorage and then load element, passing the login - token as a URL parameter. This is a supported way of logging in to element-web, but there's no API - on Synapse to make such a token currently. It would be fairly easy to add a synapse-specific admin API - to do so. We should write tests for token login (and the rest of SSO) at some point anyway though. - -If we make this as a convenience API, it can easily be swapped out later: we could start with option 1 -and then switch later. +The internals of how this custom command run may be swapped out later, +but the signature can be maintained for simpler maintenance. ### Joining a Room Many tests will also want to start with the client in a room, ready to send & receive messages. Best @@ -141,9 +142,9 @@ This section mostly summarises general good Cypress testing practice, and should already familiar with Cypress. 1. Test a well-isolated unit of functionality. The more specific, the easier it will be to tell what's - wrong when they fail. + wrong when they fail. 1. Don't depend on state from other tests: any given test should be able to run in isolation. -1. Try to avoid driving the UI for anything other than the UI you're trying to test. eg. if you're +1. Try to avoid driving the UI for anything other than the UI you're trying to test. e.g. if you're testing that the user can send a reaction to a message, it's best to send a message using a REST API, then react to it using the UI, rather than using the element-web UI to send the message. 1. Avoid explicit waits. `cy.get()` will implicitly wait for the specified element to appear and