Lock out the first tab if Element is opened in a second tab. (#11425)

* Implement session lock dialogs

* Bump analytics-events package

* clean up resetJsDomAfterEach

* fix types

* update snapshot

* update i18n strings
This commit is contained in:
Richard van der Hoff 2023-08-24 09:28:43 +01:00 committed by GitHub
parent 09c5e06d12
commit 839c0a720c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 663 additions and 50 deletions

View file

@ -25,6 +25,7 @@ import { completeAuthorizationCodeGrant } from "matrix-js-sdk/src/oidc/authorize
import { logger } from "matrix-js-sdk/src/logger";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { BearerTokenResponse } from "matrix-js-sdk/src/oidc/validate";
import { defer, sleep } from "matrix-js-sdk/src/utils";
import MatrixChat from "../../../src/components/structures/MatrixChat";
import * as StorageManager from "../../../src/utils/StorageManager";
@ -37,7 +38,9 @@ import {
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsUser,
MockClientWithEventEmitter,
mockPlatformPeg,
resetJsDomAfterEach,
} from "../../test-utils";
import * as leaveRoomUtils from "../../../src/utils/leave-behaviour";
import * as voiceBroadcastUtils from "../../../src/voice-broadcast/utils/cleanUpBroadcasts";
@ -47,6 +50,7 @@ import { Call } from "../../../src/models/Call";
import { PosthogAnalytics } from "../../../src/PosthogAnalytics";
import PlatformPeg from "../../../src/PlatformPeg";
import EventIndexPeg from "../../../src/indexing/EventIndexPeg";
import * as Lifecycle from "../../../src/Lifecycle";
jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({
completeAuthorizationCodeGrant: jest.fn(),
@ -137,7 +141,23 @@ describe("<MatrixChat />", () => {
render(<MatrixChat {...defaultProps} {...props} />);
// make test results readable
filterConsole("Failed to parse localStorage object");
filterConsole(
"Failed to parse localStorage object",
"Sync store cannot be used on this browser",
"Crypto store cannot be used on this browser",
"Storage consistency checks failed",
"LegacyCallHandler: missing <audio",
);
/** populate storage with details of a persisted session */
async function populateStorageForSession() {
localStorage.setItem("mx_hs_url", serverConfig.hsUrl);
localStorage.setItem("mx_is_url", serverConfig.isUrl);
// TODO: nowadays the access token lives (encrypted) in indexedDB, and localstorage is only used as a fallback.
localStorage.setItem("mx_access_token", accessToken);
localStorage.setItem("mx_user_id", userId);
localStorage.setItem("mx_device_id", deviceId);
}
/**
* Wait for a bunch of stuff to happen
@ -184,10 +204,10 @@ describe("<MatrixChat />", () => {
await clearAllModals();
});
resetJsDomAfterEach();
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
// emit a loggedOut event so that all of the Store singletons forget about their references to the mock client
defaultDispatcher.dispatch({ action: Action.OnLoggedOut });
@ -206,13 +226,8 @@ describe("<MatrixChat />", () => {
},
};
beforeEach(() => {
localStorage.setItem("mx_hs_url", serverConfig.hsUrl);
localStorage.setItem("mx_is_url", serverConfig.isUrl);
localStorage.setItem("mx_access_token", accessToken);
localStorage.setItem("mx_user_id", userId);
localStorage.setItem("mx_device_id", deviceId);
beforeEach(async () => {
await populateStorageForSession();
jest.spyOn(StorageManager, "idbLoad").mockImplementation(async (table, key) => {
const safeKey = Array.isArray(key) ? key[0] : key;
return mockidb[table]?.[safeKey];
@ -516,12 +531,8 @@ describe("<MatrixChat />", () => {
describe("with a soft-logged-out session", () => {
const mockidb: Record<string, Record<string, string>> = {};
beforeEach(() => {
localStorage.setItem("mx_hs_url", serverConfig.hsUrl);
localStorage.setItem("mx_is_url", serverConfig.isUrl);
localStorage.setItem("mx_access_token", accessToken);
localStorage.setItem("mx_user_id", userId);
localStorage.setItem("mx_device_id", deviceId);
beforeEach(async () => {
await populateStorageForSession();
localStorage.setItem("mx_soft_logout", "true");
mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.password" }] });
@ -742,6 +753,7 @@ describe("<MatrixChat />", () => {
localStorage.removeItem("mx_sso_hs_url");
const localStorageGetSpy = jest.spyOn(localStorage.__proto__, "getItem");
getComponent({ realQueryParams });
await flushPromises();
expect(localStorageGetSpy).toHaveBeenCalledWith("mx_sso_hs_url");
expect(localStorageGetSpy).toHaveBeenCalledWith("mx_sso_is_url");
@ -759,6 +771,7 @@ describe("<MatrixChat />", () => {
it("should attempt token login", async () => {
getComponent({ realQueryParams });
await flushPromises();
expect(loginClient.login).toHaveBeenCalledWith("m.login.token", {
initial_device_display_name: undefined,
@ -1093,4 +1106,138 @@ describe("<MatrixChat />", () => {
});
});
});
describe("Multi-tab lockout", () => {
afterEach(() => {
Lifecycle.setSessionLockNotStolen();
});
it("waits for other tab to stop during startup", async () => {
fetchMock.get("/welcome.html", { body: "<h1>Hello</h1>" });
jest.spyOn(Lifecycle, "attemptDelegatedAuthLogin");
// simulate an active window
localStorage.setItem("react_sdk_session_lock_ping", String(Date.now()));
const rendered = getComponent({});
await flushPromises();
expect(rendered.container).toMatchSnapshot();
// user confirms
rendered.getByRole("button", { name: "Continue" }).click();
await flushPromises();
// we should have claimed the session, but gone no further
expect(Lifecycle.attemptDelegatedAuthLogin).not.toHaveBeenCalled();
const sessionId = localStorage.getItem("react_sdk_session_lock_claimant");
expect(sessionId).toEqual(expect.stringMatching(/./));
expect(rendered.container).toMatchSnapshot();
// the other tab shuts down
localStorage.removeItem("react_sdk_session_lock_ping");
// fire the storage event manually, because writes to localStorage from the same javascript context don't
// fire it automatically
window.dispatchEvent(new StorageEvent("storage", { key: "react_sdk_session_lock_ping" }));
// startup continues
await flushPromises();
expect(Lifecycle.attemptDelegatedAuthLogin).toHaveBeenCalled();
// should just show the welcome screen
await rendered.findByText("Hello");
expect(rendered.container).toMatchSnapshot();
});
describe("shows the lockout page when a second tab opens", () => {
beforeEach(() => {
// make sure we start from a clean DOM for each of these tests
document.body.replaceChildren();
});
function simulateSessionLockClaim() {
localStorage.setItem("react_sdk_session_lock_claimant", "testtest");
window.dispatchEvent(new StorageEvent("storage", { key: "react_sdk_session_lock_claimant" }));
}
it("after a session is restored", async () => {
await populateStorageForSession();
const client = getMockClientWithEventEmitter(getMockClientMethods());
jest.spyOn(MatrixJs, "createClient").mockReturnValue(client);
client.getProfileInfo.mockResolvedValue({ displayname: "Ernie" });
const rendered = getComponent({});
await waitForSyncAndLoad(client, true);
rendered.getByText("Welcome Ernie");
// we're now at the welcome page. Another session wants the lock...
simulateSessionLockClaim();
await flushPromises();
expect(rendered.container).toMatchSnapshot();
});
it("while we were waiting for the lock ourselves", async () => {
// simulate there already being one session
localStorage.setItem("react_sdk_session_lock_ping", String(Date.now()));
const rendered = getComponent({});
await flushPromises();
// user confirms continue
rendered.getByRole("button", { name: "Continue" }).click();
await flushPromises();
expect(rendered.getByTestId("spinner")).toBeInTheDocument();
// now a third session starts
simulateSessionLockClaim();
await flushPromises();
expect(rendered.container).toMatchSnapshot();
});
it("while we are checking the sync store", async () => {
const rendered = getComponent({});
await flushPromises();
expect(rendered.getByTestId("spinner")).toBeInTheDocument();
// now a third session starts
simulateSessionLockClaim();
await flushPromises();
expect(rendered.container).toMatchSnapshot();
});
it("during crypto init", async () => {
await populateStorageForSession();
const client = new MockClientWithEventEmitter({
initCrypto: jest.fn(),
...getMockClientMethods(),
}) as unknown as Mocked<MatrixClient>;
jest.spyOn(MatrixJs, "createClient").mockReturnValue(client);
// intercept initCrypto and have it block until we complete the deferred
const initCryptoCompleteDefer = defer();
const initCryptoCalled = new Promise<void>((resolve) => {
client.initCrypto.mockImplementation(() => {
resolve();
return initCryptoCompleteDefer.promise;
});
});
const rendered = getComponent({});
await initCryptoCalled;
console.log("initCrypto called");
simulateSessionLockClaim();
await flushPromises();
// now we should see the error page
rendered.getByText("Test has been opened in another tab.");
// let initCrypto complete, and check we don't get a modal
initCryptoCompleteDefer.resolve();
await sleep(10); // Modals take a few ms to appear
expect(document.body).toMatchSnapshot();
});
});
});
});

View file

@ -1,5 +1,176 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MatrixChat /> Multi-tab lockout shows the lockout page when a second tab opens after a session is restored 1`] = `
<div>
<main
class="mx_SessionLockStolenView mx_SplashPage"
>
<h1>
Error
</h1>
<h2>
Test has been opened in another tab.
</h2>
</main>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout shows the lockout page when a second tab opens during crypto init 1`] = `
<body>
<div>
<main
class="mx_SessionLockStolenView mx_SplashPage"
>
<h1>
Error
</h1>
<h2>
Test has been opened in another tab.
</h2>
</main>
</div>
</body>
`;
exports[`<MatrixChat /> Multi-tab lockout shows the lockout page when a second tab opens while we are checking the sync store 1`] = `
<div>
<main
class="mx_SessionLockStolenView mx_SplashPage"
>
<h1>
Error
</h1>
<h2>
Test has been opened in another tab.
</h2>
</main>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout shows the lockout page when a second tab opens while we were waiting for the lock ourselves 1`] = `
<div>
<main
class="mx_SessionLockStolenView mx_SplashPage"
>
<h1>
Error
</h1>
<h2>
Test has been opened in another tab.
</h2>
</main>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during startup 1`] = `
<div>
<div
class="mx_ConfirmSessionLockTheftView"
>
<div
class="mx_ConfirmSessionLockTheftView_body"
>
<p>
Test is open in another window. Click "Continue" to use Test here and disconnect the other window.
</p>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Continue
</div>
</div>
</div>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during startup 2`] = `
<div>
<div
class="mx_MatrixChat_splash"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
</div>
`;
exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during startup 3`] = `
<div>
<div
class="mx_AuthPage"
>
<div
class="mx_AuthPage_modal"
>
<div
class="mx_Welcome"
>
<div
class="mx_WelcomePage mx_WelcomePage_loggedIn"
>
<div
class="mx_WelcomePage_body"
>
<h1>
Hello
</h1>
</div>
</div>
<div
class="mx_Dropdown mx_LanguageDropdown mx_AuthBody_language"
>
<div
aria-describedby="mx_LanguageDropdown_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Language Dropdown"
aria-owns="mx_LanguageDropdown_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="mx_LanguageDropdown_value"
>
<div>
English
</div>
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</div>
</div>
<footer
class="mx_AuthFooter"
role="contentinfo"
>
<a
href="https://matrix.org"
rel="noreferrer noopener"
target="_blank"
>
powered by Matrix
</a>
</footer>
</div>
</div>
`;
exports[`<MatrixChat /> should render spinner while app is loading 1`] = `
<div>
<div

View file

@ -234,3 +234,63 @@ export function useMockMediaDevices(): void {
getUserMedia: jest.fn(),
};
}
/**
* Clean up the JSDOM after each test.
*
* Registers `beforeEach` and `afterEach` functions which will deregister any event listeners and timers from the
* `window` and `document` objects.
*
* Also clears out `localStorage` and `sessionStorage`.
*/
export function resetJsDomAfterEach(): void {
// list of calls to run in afterEach
const resetCalls: (() => void)[] = [];
beforeEach(() => {
// intercept `window.addEventListener` and `document.addEventListener`, and register 'removeEventListener' calls
// for `afterEach`.
for (const obj of [window, document]) {
const originalFn = obj.addEventListener;
obj.addEventListener = (...args: Parameters<Window["addEventListener"]>) => {
originalFn.apply(obj, args);
resetCalls.push(() => obj.removeEventListener(...args));
};
// also reset the intercept after the test
resetCalls.push(() => {
obj.addEventListener = originalFn;
});
}
// intercept setTimeout and setInterval, and clear them at the end.
//
// *Don't* use jest.spyOn for this because it makes the DOM testing library think we are using fake timers.
//
["setTimeout", "setInterval"].forEach((name) => {
const originalFn = window[name as keyof Window];
// @ts-ignore assignment to read-only property
window[name] = (...args) => {
const result = originalFn.apply(window, args);
resetCalls.push(() => window.clearTimeout(result));
return result;
};
resetCalls.push(() => {
// @ts-ignore assignment to read-only property
window[name] = originalFn;
});
});
});
afterEach(() => {
// clean up event listeners, timers, etc.
for (const call of resetCalls) {
call();
}
resetCalls.splice(0);
// other cleanup
localStorage.clear();
sessionStorage.clear();
});
}

View file

@ -15,46 +15,23 @@ limitations under the License.
*/
import { checkSessionLockFree, getSessionLock, SESSION_LOCK_CONSTANTS } from "../../src/utils/SessionLock";
import { resetJsDomAfterEach } from "../test-utils";
describe("SessionLock", () => {
const otherWindows: Array<Window> = [];
let windowEventListeners: Array<[string, any]>;
let documentEventListeners: Array<[string, any]>;
beforeEach(() => {
jest.useFakeTimers({ now: 1000 });
// keep track of the registered event listeners, so that we can unregister them in `afterEach`
windowEventListeners = [];
const realWindowAddEventListener = window.addEventListener.bind(window);
jest.spyOn(window, "addEventListener").mockImplementation((type, listener, options) => {
const res = realWindowAddEventListener(type, listener, options);
windowEventListeners.push([type, listener]);
return res;
});
documentEventListeners = [];
const realDocumentAddEventListener = document.addEventListener.bind(document);
jest.spyOn(document, "addEventListener").mockImplementation((type, listener, options) => {
const res = realDocumentAddEventListener(type, listener, options);
documentEventListeners.push([type, listener]);
return res;
});
});
afterEach(() => {
// shut down other windows created by `createWindow`
otherWindows.forEach((window) => window.close());
otherWindows.splice(0);
// remove listeners on our own window
windowEventListeners.forEach(([type, listener]) => window.removeEventListener(type, listener));
documentEventListeners.forEach(([type, listener]) => document.removeEventListener(type, listener));
localStorage.clear();
jest.restoreAllMocks();
});
resetJsDomAfterEach();
it("A single instance starts up normally", async () => {
const onNewInstance = jest.fn();
const result = await getSessionLock(onNewInstance);