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:
parent
09c5e06d12
commit
839c0a720c
17 changed files with 663 additions and 50 deletions
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue