Move Enterprise Erin tests from Puppeteer to Cypress (#8569)

* Move Enterprise Erin tests from Puppeteer to Cypress

* delint

* types

* Fix double space

* Better handle logout in Lifecycle

* Fix test by awaiting the network request

* Improve some logout handlings

* Try try try again

* Delint

* Fix tests

* Delint
This commit is contained in:
Michael Telatynski 2022-05-26 11:12:49 +01:00 committed by GitHub
parent 7efd7b67ea
commit 655bca63e6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 131 additions and 150 deletions

View file

@ -59,4 +59,45 @@ describe("Login", () => {
cy.stopMeasuring("from-submit-to-home"); cy.stopMeasuring("from-submit-to-home");
}); });
}); });
describe("logout", () => {
beforeEach(() => {
cy.initTestUser(synapse, "Erin");
});
it("should go to login page on logout", () => {
cy.get('[aria-label="User menu"]').click();
// give a change for the outstanding requests queue to settle before logging out
cy.wait(500);
cy.get(".mx_UserMenu_contextMenu").within(() => {
cy.get(".mx_UserMenu_iconSignOut").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.get('[aria-label="User menu"]').click();
// give a change for the outstanding requests queue to settle before logging out
cy.wait(500);
cy.get(".mx_UserMenu_contextMenu").within(() => {
cy.get(".mx_UserMenu_iconSignOut").click();
});
cy.url().should("contains", "decoder-ring");
});
});
}); });

View file

@ -39,7 +39,7 @@ describe("User Menu", () => {
it("should contain our name & userId", () => { it("should contain our name & userId", () => {
cy.get('[aria-label="User menu"]').click(); cy.get('[aria-label="User menu"]').click();
cy.get(".mx_ContextualMenu").within(() => { cy.get(".mx_UserMenu_contextMenu").within(() => {
cy.get(".mx_UserMenu_contextMenu_displayName").should("contain", "Jeff"); cy.get(".mx_UserMenu_contextMenu_displayName").should("contain", "Jeff");
cy.get(".mx_UserMenu_contextMenu_userId").should("contain", user.userId); cy.get(".mx_UserMenu_contextMenu_userId").should("contain", user.userId);
}); });

43
cypress/support/app.ts Normal file
View file

@ -0,0 +1,43 @@
/*
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.
*/
/// <reference types="cypress" />
import "./client"; // XXX: without an (any) import here, types break down
import Chainable = Cypress.Chainable;
import AUTWindow = Cypress.AUTWindow;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Applies tweaks to the config read from config.json
*/
tweakConfig(tweaks: Record<string, any>): Chainable<AUTWindow>;
}
}
}
Cypress.Commands.add("tweakConfig", (tweaks: Record<string, any>): Chainable<AUTWindow> => {
return cy.window().then(win => {
// note: we can't *set* the object because the window version is effectively a pointer.
for (const [k, v] of Object.entries(tweaks)) {
// @ts-ignore - for some reason it's not picking up on global.d.ts types.
win.mxReactSdkConfig[k] = v;
}
});
});

View file

@ -27,3 +27,4 @@ import "./settings";
import "./bot"; import "./bot";
import "./clipboard"; import "./clipboard";
import "./util"; import "./util";
import "./app";

View file

@ -18,6 +18,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { ClientEvent, EventType, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { ClientEvent, EventType, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { SyncState } from "matrix-js-sdk/src/sync";
import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixClientPeg } from './MatrixClientPeg';
import dis from "./dispatcher/dispatcher"; import dis from "./dispatcher/dispatcher";
@ -58,13 +59,15 @@ export default class DeviceListener {
private ourDeviceIdsAtStart: Set<string> = null; private ourDeviceIdsAtStart: Set<string> = null;
// The set of device IDs we're currently displaying toasts for // The set of device IDs we're currently displaying toasts for
private displayingToastsForDeviceIds = new Set<string>(); private displayingToastsForDeviceIds = new Set<string>();
private running = false;
static sharedInstance() { public static sharedInstance() {
if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener(); if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener();
return window.mxDeviceListener; return window.mxDeviceListener;
} }
start() { public start() {
this.running = true;
MatrixClientPeg.get().on(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices); MatrixClientPeg.get().on(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices);
MatrixClientPeg.get().on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); MatrixClientPeg.get().on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
MatrixClientPeg.get().on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); MatrixClientPeg.get().on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged);
@ -77,7 +80,8 @@ export default class DeviceListener {
this.recheck(); this.recheck();
} }
stop() { public stop() {
this.running = false;
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices); MatrixClientPeg.get().removeListener(CryptoEvent.WillUpdateDevices, this.onWillUpdateDevices);
MatrixClientPeg.get().removeListener(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); MatrixClientPeg.get().removeListener(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
@ -109,7 +113,7 @@ export default class DeviceListener {
* *
* @param {String[]} deviceIds List of device IDs to dismiss notifications for * @param {String[]} deviceIds List of device IDs to dismiss notifications for
*/ */
async dismissUnverifiedSessions(deviceIds: Iterable<string>) { public async dismissUnverifiedSessions(deviceIds: Iterable<string>) {
logger.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(',')); logger.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(','));
for (const d of deviceIds) { for (const d of deviceIds) {
this.dismissed.add(d); this.dismissed.add(d);
@ -118,7 +122,7 @@ export default class DeviceListener {
this.recheck(); this.recheck();
} }
dismissEncryptionSetup() { public dismissEncryptionSetup() {
this.dismissedThisDeviceToast = true; this.dismissedThisDeviceToast = true;
this.recheck(); this.recheck();
} }
@ -179,8 +183,10 @@ export default class DeviceListener {
} }
}; };
private onSync = (state, prevState) => { private onSync = (state: SyncState, prevState?: SyncState) => {
if (state === 'PREPARED' && prevState === null) this.recheck(); if (state === 'PREPARED' && prevState === null) {
this.recheck();
}
}; };
private onRoomStateEvents = (ev: MatrixEvent) => { private onRoomStateEvents = (ev: MatrixEvent) => {
@ -217,6 +223,7 @@ export default class DeviceListener {
} }
private async recheck() { private async recheck() {
if (!this.running) return; // we have been stopped
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (!(await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"))) return; if (!(await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"))) return;

View file

@ -168,7 +168,7 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean>
* Gets the user ID of the persisted session, if one exists. This does not validate * Gets the user ID of the persisted session, if one exists. This does not validate
* that the user's credentials still work, just that they exist and that a user ID * that the user's credentials still work, just that they exist and that a user ID
* is associated with them. The session is not loaded. * is associated with them. The session is not loaded.
* @returns {[String, bool]} The persisted session's owner and whether the stored * @returns {[string, boolean]} The persisted session's owner and whether the stored
* session is for a guest user, if an owner exists. If there is no stored session, * session is for a guest user, if an owner exists. If there is no stored session,
* return [null, null]. * return [null, null].
*/ */
@ -494,7 +494,7 @@ async function handleLoadSessionFailure(e: Error): Promise<boolean> {
* Also stops the old MatrixClient and clears old credentials/etc out of * Also stops the old MatrixClient and clears old credentials/etc out of
* storage before starting the new client. * storage before starting the new client.
* *
* @param {MatrixClientCreds} credentials The credentials to use * @param {IMatrixClientCreds} credentials The credentials to use
* *
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started * @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/ */
@ -525,7 +525,7 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<Matr
* If the credentials belong to a different user from the session already stored, * If the credentials belong to a different user from the session already stored,
* the old session will be cleared automatically. * the old session will be cleared automatically.
* *
* @param {MatrixClientCreds} credentials The credentials to use * @param {IMatrixClientCreds} credentials The credentials to use
* *
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started * @returns {Promise} promise which resolves to the new MatrixClient once it has been started
*/ */
@ -731,7 +731,7 @@ export function logout(): void {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
// logout doesn't work for guest sessions // logout doesn't work for guest sessions
// Also we sometimes want to re-log in a guest session if we abort the login. // Also we sometimes want to re-log in a guest session if we abort the login.
// defer until next tick because it calls a synchronous dispatch and we are likely here from a dispatch. // defer until next tick because it calls a synchronous dispatch, and we are likely here from a dispatch.
setImmediate(() => onLoggedOut()); setImmediate(() => onLoggedOut());
return; return;
} }
@ -739,19 +739,17 @@ export function logout(): void {
_isLoggingOut = true; _isLoggingOut = true;
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
PlatformPeg.get().destroyPickleKey(client.getUserId(), client.getDeviceId()); PlatformPeg.get().destroyPickleKey(client.getUserId(), client.getDeviceId());
client.logout().then(onLoggedOut, client.logout(undefined, true).then(onLoggedOut, (err) => {
(err) => { // Just throwing an error here is going to be very unhelpful
// Just throwing an error here is going to be very unhelpful // if you're trying to log out because your server's down and
// if you're trying to log out because your server's down and // you want to log into a different server, so just forget the
// you want to log into a different server, so just forget the // access token. It's annoying that this will leave the access
// access token. It's annoying that this will leave the access // token still valid, but we should fix this by having access
// token still valid, but we should fix this by having access // tokens expire (and if you really think you've been compromised,
// tokens expire (and if you really think you've been compromised, // change your password).
// change your password). logger.warn("Failed to call logout API: token will not be invalidated", err);
logger.log("Failed to call logout API: token will not be invalidated"); onLoggedOut();
onLoggedOut(); });
},
);
} }
export function softLogout(): void { export function softLogout(): void {
@ -856,9 +854,8 @@ async function startMatrixClient(startSyncing = true): Promise<void> {
* storage. Used after a session has been logged out. * storage. Used after a session has been logged out.
*/ */
export async function onLoggedOut(): Promise<void> { export async function onLoggedOut(): Promise<void> {
_isLoggingOut = false;
// Ensure that we dispatch a view change **before** stopping the client, // Ensure that we dispatch a view change **before** stopping the client,
// so that React components unmount first. This avoids React soft crashes // that React components unmount first. This avoids React soft crashes
// that can occur when components try to use a null client. // that can occur when components try to use a null client.
dis.fire(Action.OnLoggedOut, true); dis.fire(Action.OnLoggedOut, true);
stopMatrixClient(); stopMatrixClient();
@ -869,8 +866,13 @@ export async function onLoggedOut(): Promise<void> {
// customisations got the memo. // customisations got the memo.
if (SdkConfig.get().logout_redirect_url) { if (SdkConfig.get().logout_redirect_url) {
logger.log("Redirecting to external provider to finish logout"); logger.log("Redirecting to external provider to finish logout");
window.location.href = SdkConfig.get().logout_redirect_url; // XXX: Defer this so that it doesn't race with MatrixChat unmounting the world by going to /#/login
setTimeout(() => {
window.location.href = SdkConfig.get().logout_redirect_url;
}, 100);
} }
// Do this last to prevent racing `stopMatrixClient` and `on_logged_out` with MatrixChat handling Session.logged_out
_isLoggingOut = false;
} }
/** /**
@ -908,9 +910,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void
} }
} }
if (window.sessionStorage) { window.sessionStorage?.clear();
window.sessionStorage.clear();
}
// create a temporary client to clear out the persistent stores. // create a temporary client to clear out the persistent stores.
const cli = createMatrixClient({ const cli = createMatrixClient({
@ -937,7 +937,7 @@ export function stopMatrixClient(unsetClient = true): void {
IntegrationManagers.sharedInstance().stopWatching(); IntegrationManagers.sharedInstance().stopWatching();
Mjolnir.sharedInstance().stop(); Mjolnir.sharedInstance().stop();
DeviceListener.sharedInstance().stop(); DeviceListener.sharedInstance().stop();
if (DMRoomMap.shared()) DMRoomMap.shared().stop(); DMRoomMap.shared()?.stop();
EventIndexPeg.stop(); EventIndexPeg.stop();
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli) { if (cli) {

View file

@ -257,7 +257,7 @@ async function onSecretRequested(
if (userId !== client.getUserId()) { if (userId !== client.getUserId()) {
return; return;
} }
if (!deviceTrust || !deviceTrust.isVerified()) { if (!deviceTrust?.isVerified()) {
logger.log(`Ignoring secret request from untrusted device ${deviceId}`); logger.log(`Ignoring secret request from untrusted device ${deviceId}`);
return; return;
} }
@ -296,7 +296,7 @@ export const crossSigningCallbacks: ICryptoCallbacks = {
}; };
export async function promptForBackupPassphrase(): Promise<Uint8Array> { export async function promptForBackupPassphrase(): Promise<Uint8Array> {
let key; let key: Uint8Array;
const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, { const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
showSummary: false, keyCallback: k => key = k, showSummary: false, keyCallback: k => key = k,

View file

@ -89,9 +89,7 @@ export class SetupEncryptionStore extends EventEmitter {
return; return;
} }
this.started = false; this.started = false;
if (this.verificationRequest) { this.verificationRequest?.off(VerificationRequestEvent.Change, this.onVerificationRequestChange);
this.verificationRequest.off(VerificationRequestEvent.Change, this.onVerificationRequestChange);
}
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener(CryptoEvent.VerificationRequest, this.onVerificationRequest); MatrixClientPeg.get().removeListener(CryptoEvent.VerificationRequest, this.onVerificationRequest);
MatrixClientPeg.get().removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged); MatrixClientPeg.get().removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
@ -99,6 +97,7 @@ export class SetupEncryptionStore extends EventEmitter {
} }
public async fetchKeyInfo(): Promise<void> { public async fetchKeyInfo(): Promise<void> {
if (!this.started) return; // bail if we were stopped
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const keys = await cli.isSecretStored('m.cross_signing.master'); const keys = await cli.isSecretStored('m.cross_signing.master');
if (keys === null || Object.keys(keys).length === 0) { if (keys === null || Object.keys(keys).length === 0) {
@ -270,6 +269,7 @@ export class SetupEncryptionStore extends EventEmitter {
} }
private async setActiveVerificationRequest(request: VerificationRequest): Promise<void> { private async setActiveVerificationRequest(request: VerificationRequest): Promise<void> {
if (!this.started) return; // bail if we were stopped
if (request.otherUserId !== MatrixClientPeg.get().getUserId()) return; if (request.otherUserId !== MatrixClientPeg.get().getUserId()) return;
if (this.verificationRequest) { if (this.verificationRequest) {

View file

@ -27,13 +27,12 @@ import { RestMultiSession } from "./rest/multi";
import { RestSession } from "./rest/session"; import { RestSession } from "./rest/session";
import { stickerScenarios } from './scenarios/sticker'; import { stickerScenarios } from './scenarios/sticker';
import { userViewScenarios } from "./scenarios/user-view"; import { userViewScenarios } from "./scenarios/user-view";
import { ssoCustomisationScenarios } from "./scenarios/sso-customisations";
import { updateScenarios } from "./scenarios/update"; import { updateScenarios } from "./scenarios/update";
export async function scenario(createSession: (s: string) => Promise<ElementSession>, export async function scenario(createSession: (s: string) => Promise<ElementSession>,
restCreator: RestSessionCreator): Promise<void> { restCreator: RestSessionCreator): Promise<void> {
let firstUser = true; let firstUser = true;
async function createUser(username) { async function createUser(username: string) {
const session = await createSession(username); const session = await createSession(username);
if (firstUser) { if (firstUser) {
// only show browser version for first browser opened // only show browser version for first browser opened
@ -65,12 +64,6 @@ export async function scenario(createSession: (s: string) => Promise<ElementSess
const stickerSession = await createSession("sally"); const stickerSession = await createSession("sally");
await stickerScenarios("sally", "ilikestickers", stickerSession, restCreator); await stickerScenarios("sally", "ilikestickers", stickerSession, restCreator);
// we spawn yet another session for SSO stuff because it involves authentication and
// logout, which can/does affect other tests dramatically. See notes above regarding
// stickers for the performance loss of doing this.
const ssoSession = await createUser("enterprise_erin");
await ssoCustomisationScenarios(ssoSession);
// Create a new window to test app auto-updating // Create a new window to test app auto-updating
const updateSession = await createSession("update"); const updateSession = await createSession("update");
await updateScenarios(updateSession); await updateScenarios(updateSession);

View file

@ -1,50 +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 { strict as assert } from "assert";
import { ElementSession } from "../session";
import { logout } from "../usecases/logout";
import { applyConfigChange } from "../util";
export async function ssoCustomisationScenarios(session: ElementSession): Promise<void> {
console.log(" injecting logout customisations for SSO scenarios:");
await session.delay(1000); // wait for dialogs to close
await applyConfigChange(session, {
// we redirect to config.json 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 `./run.sh --app-url http://localhost:8080`).
// Using the config.json is just as fine, and we can search for strategic names.
'logout_redirect_url': '/config.json',
});
await logoutCanCauseRedirect(session);
}
async function logoutCanCauseRedirect(session: ElementSession): Promise<void> {
await logout(session, false); // we'll check the login page ourselves, so don't assert
session.log.step("waits for redirect to config.json (as external page)");
const foundLoginUrl = await session.poll(async () => {
const url = session.page.url();
return url === session.url('/config.json');
});
assert(foundLoginUrl);
session.log.done();
}

View file

@ -1,43 +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 { strict as assert } from 'assert';
import { ElementSession } from "../session";
export async function logout(session: ElementSession, assertLoginPage = true): Promise<void> {
session.log.startGroup("logs out");
session.log.step("navigates to user menu");
const userButton = await session.query('.mx_UserMenu > div.mx_AccessibleButton');
await userButton.click();
session.log.done();
session.log.step("clicks the 'Sign Out' button");
const signOutButton = await session.query('.mx_UserMenu_contextMenu .mx_UserMenu_iconSignOut');
await signOutButton.click();
session.log.done();
if (assertLoginPage) {
const foundLoginUrl = await session.poll(async () => {
const url = session.page.url();
return url === session.url('/#/login');
});
assert(foundLoginUrl);
}
session.log.endGroup();
}

View file

@ -28,7 +28,7 @@ export const range = function(start: number, amount: number, step = 1): Array<nu
return r; return r;
}; };
export const delay = function(ms): Promise<void> { export const delay = function(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
}; };
@ -44,17 +44,6 @@ export const measureStop = function(session: ElementSession, name: string): Prom
}, name); }, name);
}; };
// TODO: Proper types on `config` - for some reason won't accept an import of ConfigOptions.
export async function applyConfigChange(session: ElementSession, config: any): Promise<void> {
await session.page.evaluate((_config) => {
// note: we can't *set* the object because the window version is effectively a pointer.
for (const [k, v] of Object.entries(_config)) {
// @ts-ignore - for some reason it's not picking up on global.d.ts types.
window.mxReactSdkConfig[k] = v;
}
}, config);
}
export async function serializeLog(msg: ConsoleMessage): Promise<string> { export async function serializeLog(msg: ConsoleMessage): Promise<string> {
// 9 characters padding is somewhat arbitrary ("warning".length + some) // 9 characters padding is somewhat arbitrary ("warning".length + some)
let s = `${new Date().toISOString()} | ${ padEnd(msg.type(), 9, ' ')}| ${msg.text()} `; // trailing space is intentional let s = `${new Date().toISOString()} | ${ padEnd(msg.type(), 9, ' ')}| ${msg.text()} `; // trailing space is intentional