Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit f36b603

Browse files
authored
Fix logout can take ages (#12191)
* Fix logout can take ages * fix for of loop * Add logout tests * Unit test for logout behavior * UserMenu tests update snapshot
1 parent 53b3d6f commit f36b603

File tree

5 files changed

+260
-41
lines changed

5 files changed

+260
-41
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
Copyright 2024 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { Page } from "@playwright/test";
18+
19+
import { test, expect } from "../../element-web-test";
20+
import { logIntoElement } from "./utils";
21+
import { ElementAppPage } from "../../pages/ElementAppPage";
22+
23+
test.describe("Logout tests", () => {
24+
test.beforeEach(async ({ page, homeserver, credentials }) => {
25+
await logIntoElement(page, homeserver, credentials);
26+
});
27+
28+
async function createRoom(page: Page, roomName: string, isEncrypted: boolean): Promise<void> {
29+
await page.getByRole("button", { name: "Add room" }).click();
30+
await page.locator(".mx_IconizedContextMenu").getByRole("menuitem", { name: "New room" }).click();
31+
32+
const dialog = page.locator(".mx_Dialog");
33+
34+
await dialog.getByLabel("Name").fill(roomName);
35+
36+
if (!isEncrypted) {
37+
// it's enabled by default
38+
await page.getByLabel("Enable end-to-end encryption").click();
39+
}
40+
41+
await dialog.getByRole("button", { name: "Create room" }).click();
42+
}
43+
44+
async function sendMessageInCurrentRoom(page: Page, message: string): Promise<void> {
45+
await page.locator(".mx_MessageComposer").getByRole("textbox").fill(message);
46+
await page.getByTestId("sendmessagebtn").click();
47+
}
48+
async function setupRecovery(app: ElementAppPage, page: Page): Promise<void> {
49+
const securityTab = await app.settings.openUserSettings("Security & Privacy");
50+
51+
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
52+
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
53+
54+
const currentDialogLocator = page.locator(".mx_Dialog");
55+
56+
// It's the first time and secure storage is not set up, so it will create one
57+
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
58+
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
59+
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
60+
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
61+
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
62+
63+
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
64+
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
65+
}
66+
67+
test("Ask to set up recovery on logout if not setup", async ({ page, app }) => {
68+
await createRoom(page, "E2e room", true);
69+
70+
// send a message (will be the first one so will create a new megolm session)
71+
await sendMessageInCurrentRoom(page, "Hello secret world");
72+
73+
const locator = await app.settings.openUserMenu();
74+
await locator.getByRole("menuitem", { name: "Sign out", exact: true }).click();
75+
76+
const currentDialogLocator = page.locator(".mx_Dialog");
77+
78+
await expect(
79+
currentDialogLocator.getByRole("heading", { name: "You'll lose access to your encrypted messages" }),
80+
).toBeVisible();
81+
});
82+
83+
test("If backup is set up show standard confirm", async ({ page, app }) => {
84+
await setupRecovery(app, page);
85+
86+
await createRoom(page, "E2e room", true);
87+
88+
// send a message (will be the first one so will create a new megolm session)
89+
await sendMessageInCurrentRoom(page, "Hello secret world");
90+
91+
const locator = await app.settings.openUserMenu();
92+
await locator.getByRole("menuitem", { name: "Sign out", exact: true }).click();
93+
94+
const currentDialogLocator = page.locator(".mx_Dialog");
95+
96+
await expect(currentDialogLocator.getByText("Are you sure you want to sign out?")).toBeVisible();
97+
});
98+
99+
test("Logout directly if the user has no room keys", async ({ page, app }) => {
100+
await createRoom(page, "Clear room", false);
101+
102+
await sendMessageInCurrentRoom(page, "Hello public world!");
103+
104+
const locator = await app.settings.openUserMenu();
105+
await locator.getByRole("menuitem", { name: "Sign out", exact: true }).click();
106+
107+
// Should have logged out directly
108+
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible();
109+
});
110+
});

src/components/structures/UserMenu.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -258,17 +258,36 @@ export default class UserMenu extends React.Component<IProps, IState> {
258258
ev.preventDefault();
259259
ev.stopPropagation();
260260

261-
const cli = MatrixClientPeg.get();
262-
if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) {
263-
// log out without user prompt if they have no local megolm sessions
264-
defaultDispatcher.dispatch({ action: "logout" });
265-
} else {
261+
if (await this.shouldShowLogoutDialog()) {
266262
Modal.createDialog(LogoutDialog);
263+
} else {
264+
defaultDispatcher.dispatch({ action: "logout" });
267265
}
268266

269267
this.setState({ contextMenuPosition: null }); // also close the menu
270268
};
271269

270+
/**
271+
* Checks if the `LogoutDialog` should be shown instead of the simple logout flow.
272+
* The `LogoutDialog` will check the crypto recovery status of the account and
273+
* help the user setup recovery properly if needed.
274+
* @private
275+
*/
276+
private async shouldShowLogoutDialog(): Promise<boolean> {
277+
const cli = MatrixClientPeg.get();
278+
const crypto = cli?.getCrypto();
279+
if (!crypto) return false;
280+
281+
// If any room is encrypted, we need to show the advanced logout flow
282+
const allRooms = cli!.getRooms();
283+
for (const room of allRooms) {
284+
const isE2e = await crypto.isEncryptionEnabledInRoom(room.roomId);
285+
if (isE2e) return true;
286+
}
287+
288+
return false;
289+
}
290+
272291
private onSignInClick = (): void => {
273292
defaultDispatcher.dispatch({ action: "start_login" });
274293
this.setState({ contextMenuPosition: null }); // also close the menu

test/components/structures/UserMenu-test.tsx

Lines changed: 124 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ limitations under the License.
1515
*/
1616

1717
import React from "react";
18-
import { act, render, RenderResult } from "@testing-library/react";
19-
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
18+
import { act, render, RenderResult, screen, waitFor } from "@testing-library/react";
19+
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
20+
import { mocked } from "jest-mock";
2021

2122
import UnwrappedUserMenu from "../../../src/components/structures/UserMenu";
2223
import { stubClient, wrapInSdkContext } from "../../test-utils";
@@ -27,65 +28,153 @@ import {
2728
} from "../../../src/voice-broadcast";
2829
import { mkVoiceBroadcastInfoStateEvent } from "../../voice-broadcast/utils/test-utils";
2930
import { TestSdkContext } from "../../TestSdkContext";
31+
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
32+
import LogoutDialog from "../../../src/components/views/dialogs/LogoutDialog";
33+
import Modal from "../../../src/Modal";
3034

3135
describe("<UserMenu>", () => {
3236
let client: MatrixClient;
3337
let renderResult: RenderResult;
3438
let sdkContext: TestSdkContext;
35-
let voiceBroadcastInfoEvent: MatrixEvent;
36-
let voiceBroadcastRecording: VoiceBroadcastRecording;
37-
let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore;
38-
39-
beforeAll(() => {
40-
client = stubClient();
41-
voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent(
42-
"!room:example.com",
43-
VoiceBroadcastInfoState.Started,
44-
client.getUserId() || "",
45-
client.getDeviceId() || "",
46-
);
47-
});
4839

4940
beforeEach(() => {
5041
sdkContext = new TestSdkContext();
51-
voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore();
52-
sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore;
53-
54-
voiceBroadcastRecording = new VoiceBroadcastRecording(voiceBroadcastInfoEvent, client);
5542
});
5643

57-
describe("when rendered", () => {
58-
beforeEach(() => {
59-
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
60-
renderResult = render(<UserMenu isPanelCollapsed={true} />);
44+
describe("<UserMenu> when video broadcast", () => {
45+
let voiceBroadcastInfoEvent: MatrixEvent;
46+
let voiceBroadcastRecording: VoiceBroadcastRecording;
47+
let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore;
48+
49+
beforeAll(() => {
50+
client = stubClient();
51+
voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent(
52+
"!room:example.com",
53+
VoiceBroadcastInfoState.Started,
54+
client.getUserId() || "",
55+
client.getDeviceId() || "",
56+
);
6157
});
6258

63-
it("should render as expected", () => {
64-
expect(renderResult.container).toMatchSnapshot();
59+
beforeEach(() => {
60+
voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore();
61+
sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore;
62+
63+
voiceBroadcastRecording = new VoiceBroadcastRecording(voiceBroadcastInfoEvent, client);
6564
});
6665

67-
describe("and a live voice broadcast starts", () => {
66+
describe("when rendered", () => {
6867
beforeEach(() => {
69-
act(() => {
70-
voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording);
71-
});
68+
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
69+
renderResult = render(<UserMenu isPanelCollapsed={true} />);
7270
});
7371

74-
it("should render the live voice broadcast avatar addon", () => {
75-
expect(renderResult.queryByTestId("user-menu-live-vb")).toBeInTheDocument();
72+
it("should render as expected", () => {
73+
expect(renderResult.container).toMatchSnapshot();
7674
});
7775

78-
describe("and the broadcast ends", () => {
76+
describe("and a live voice broadcast starts", () => {
7977
beforeEach(() => {
8078
act(() => {
81-
voiceBroadcastRecordingsStore.clearCurrent();
79+
voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording);
8280
});
8381
});
8482

85-
it("should not render the live voice broadcast avatar addon", () => {
86-
expect(renderResult.queryByTestId("user-menu-live-vb")).not.toBeInTheDocument();
83+
it("should render the live voice broadcast avatar addon", () => {
84+
expect(renderResult.queryByTestId("user-menu-live-vb")).toBeInTheDocument();
85+
});
86+
87+
describe("and the broadcast ends", () => {
88+
beforeEach(() => {
89+
act(() => {
90+
voiceBroadcastRecordingsStore.clearCurrent();
91+
});
92+
});
93+
94+
it("should not render the live voice broadcast avatar addon", () => {
95+
expect(renderResult.queryByTestId("user-menu-live-vb")).not.toBeInTheDocument();
96+
});
8797
});
8898
});
8999
});
90100
});
101+
102+
describe("<UserMenu> logout", () => {
103+
beforeEach(() => {
104+
client = stubClient();
105+
});
106+
107+
it("should logout directly if no crypto", async () => {
108+
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
109+
renderResult = render(<UserMenu isPanelCollapsed={true} />);
110+
111+
mocked(client.getRooms).mockReturnValue([
112+
{
113+
roomId: "!room0",
114+
} as unknown as Room,
115+
{
116+
roomId: "!room1",
117+
} as unknown as Room,
118+
]);
119+
jest.spyOn(client, "getCrypto").mockReturnValue(undefined);
120+
121+
const spy = jest.spyOn(defaultDispatcher, "dispatch");
122+
screen.getByRole("button", { name: /User menu/i }).click();
123+
screen.getByRole("menuitem", { name: /Sign out/i }).click();
124+
await waitFor(() => {
125+
expect(spy).toHaveBeenCalledWith({ action: "logout" });
126+
});
127+
});
128+
129+
it("should logout directly if no encrypted rooms", async () => {
130+
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
131+
renderResult = render(<UserMenu isPanelCollapsed={true} />);
132+
133+
mocked(client.getRooms).mockReturnValue([
134+
{
135+
roomId: "!room0",
136+
} as unknown as Room,
137+
{
138+
roomId: "!room1",
139+
} as unknown as Room,
140+
]);
141+
const crypto = client.getCrypto()!;
142+
143+
jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(false);
144+
145+
const spy = jest.spyOn(defaultDispatcher, "dispatch");
146+
screen.getByRole("button", { name: /User menu/i }).click();
147+
screen.getByRole("menuitem", { name: /Sign out/i }).click();
148+
await waitFor(() => {
149+
expect(spy).toHaveBeenCalledWith({ action: "logout" });
150+
});
151+
});
152+
153+
it("should show dialog if some encrypted rooms", async () => {
154+
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
155+
renderResult = render(<UserMenu isPanelCollapsed={true} />);
156+
157+
mocked(client.getRooms).mockReturnValue([
158+
{
159+
roomId: "!room0",
160+
} as unknown as Room,
161+
{
162+
roomId: "!room1",
163+
} as unknown as Room,
164+
]);
165+
const crypto = client.getCrypto()!;
166+
167+
jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockImplementation(async (roomId: string) => {
168+
return roomId === "!room0";
169+
});
170+
171+
const spy = jest.spyOn(Modal, "createDialog");
172+
screen.getByRole("button", { name: /User menu/i }).click();
173+
screen.getByRole("menuitem", { name: /Sign out/i }).click();
174+
175+
await waitFor(() => {
176+
expect(spy).toHaveBeenCalledWith(LogoutDialog);
177+
});
178+
});
179+
});
91180
});

test/components/structures/__snapshots__/UserMenu-test.tsx.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3-
exports[`<UserMenu> when rendered should render as expected 1`] = `
3+
exports[`<UserMenu> <UserMenu> when video broadcast when rendered should render as expected 1`] = `
44
<div>
55
<div
66
class="mx_UserMenu"

test/test-utils/test-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export function createTestClient(): MatrixClient {
137137
getUserVerificationStatus: jest.fn(),
138138
getDeviceVerificationStatus: jest.fn(),
139139
resetKeyBackup: jest.fn(),
140+
isEncryptionEnabledInRoom: jest.fn(),
140141
}),
141142

142143
getPushActionsForEvent: jest.fn(),

0 commit comments

Comments
 (0)