Files
ThreadNet-Web/apps/web/test/unit-tests/Lifecycle-test.ts
T
David BakerandGitHub ff8329be09 Take client creating functionality out of MatrixClientPeg (#34092)
* Take client creating functionality out of MatrixClientPeg

Because all sorts of things import MatrixClientPeg and this means
they pull in all manner of things related to creating a client, when
all they need is to get the current one (and specifically it fixes
the import cycle that means I can't add my test).

* Remove fake indexeddb

as it seems like the tests didn't actually see the fake indexeddb before
(somehow) but now do, and are failing because they have that but no postmessage.
It feels like the right solution is for these tests to not need indexeddb.

* Don't mock a refresh token

The tests don't mock out enough for the token refreshing setup to work,
it wa somehow always ending up as null previously and now wasn't, at which
point it broke, so just make it actually unset.

* Move formatter instatiation to lazy

rather than eagerly at parse time, as this apparently shifted one
test to hit this import cycle instead.

* Tests for room name generator
2026-07-03 15:21:59 +00:00

1013 lines
43 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { Crypto } from "@peculiar/webcrypto";
import { logger } from "matrix-js-sdk/src/logger";
import * as MatrixJs from "matrix-js-sdk/src/matrix";
import { decodeBase64, encodeUnpaddedBase64 } from "matrix-js-sdk/src/matrix";
import * as encryptAESSecretStorageItemModule from "matrix-js-sdk/src/utils/encryptAESSecretStorageItem";
import { mocked, type MockedObject } from "jest-mock-vitest-adapter";
import fetchMock from "@fetch-mock/jest";
import StorageEvictedDialog from "../../src/components/views/dialogs/StorageEvictedDialog";
import * as Lifecycle from "../../src/Lifecycle";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import Modal from "../../src/Modal";
import * as StorageAccess from "../../src/utils/StorageAccess";
import { idbSave } from "../../src/utils/StorageAccess";
import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser, mockPlatformPeg } from "../test-utils";
import { OidcClientStore } from "../../src/stores/oidc/OidcClientStore";
import { makeDelegatedAuthConfig } from "../test-utils/oidc";
import { Action } from "../../src/dispatcher/actions";
import PlatformPeg from "../../src/PlatformPeg";
import { persistAccessTokenInStorage, persistRefreshTokenInStorage } from "../../src/utils/tokens/tokens";
import { encryptPickleKey } from "../../src/utils/tokens/pickling";
import * as StorageManager from "../../src/utils/StorageManager.ts";
import type BasePlatform from "../../src/BasePlatform.ts";
import * as createMatrixClientModule from "../../src/utils/createMatrixClient";
const { logout, restoreSessionFromStorage, setLoggedIn } = Lifecycle;
const webCrypto = new Crypto();
const windowCrypto = window.crypto;
describe("Lifecycle", () => {
const homeserverUrl = "https://domain";
const identityServerUrl = "https://is.org";
const userId = "@alice:domain";
const deviceId = "abc123";
const accessToken = "test-access-token";
let mockPlatform: MockedObject<BasePlatform>;
const realLocalStorage = global.localStorage;
let mockClient!: MockedObject<MatrixJs.MatrixClient>;
beforeEach(() => {
jest.restoreAllMocks();
mockPlatform = mockPlatformPeg();
mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
stopClient: jest.fn(),
removeAllListeners: jest.fn(),
clearStores: jest.fn(),
getAccountData: jest.fn(),
getDeviceId: jest.fn().mockReturnValue(deviceId),
isVersionSupported: jest.fn().mockResolvedValue(true),
getCrypto: jest.fn(),
getClientWellKnown: jest.fn(),
waitForClientWellKnown: jest.fn(),
getThirdpartyProtocols: jest.fn(),
store: {
destroy: jest.fn(),
},
getVersions: jest.fn().mockResolvedValue({ versions: ["v1.1"] }),
logout: jest.fn().mockResolvedValue(undefined),
getAccessToken: jest.fn(),
getRefreshToken: jest.fn(),
setGuest: jest.fn(),
setNotifTimelineSet: jest.fn(),
});
// stub this
jest.spyOn(MatrixClientPeg, "set").mockImplementation(() => {});
jest.spyOn(MatrixClientPeg, "start").mockResolvedValue(undefined);
// reset any mocking
// @ts-ignore mocking
delete global.localStorage;
global.localStorage = realLocalStorage;
// @ts-ignore mocking
delete window.crypto;
window.crypto = webCrypto;
jest.spyOn(encryptAESSecretStorageItemModule, "default").mockRestore();
});
afterAll(() => {
// @ts-ignore unmocking
delete window.crypto;
window.crypto = windowCrypto;
});
const initIdbMock = (mockStore: Record<string, Record<string, unknown>> = {}): void => {
jest.spyOn(StorageAccess, "idbLoad")
.mockClear()
.mockImplementation(
// @ts-ignore mock type
async (table: string, key: string) => mockStore[table]?.[key] ?? null,
);
jest.spyOn(StorageAccess, "idbSave")
.mockClear()
.mockImplementation(
// @ts-ignore mock type
async (tableKey: string, key: string, value: unknown) => {
const table = mockStore[tableKey] || {};
table[key as string] = value;
mockStore[tableKey] = table;
},
);
jest.spyOn(StorageAccess, "idbDelete")
.mockClear()
.mockImplementation(async (tableKey: string, key: string | string[]) => {
const table = mockStore[tableKey];
delete table?.[key as string];
});
jest.spyOn(StorageAccess, "idbClear")
.mockClear()
.mockImplementation(async (tableKey: string) => {
mockStore[tableKey] = {};
});
};
const localStorageSession: Record<string, string> = {
mx_hs_url: homeserverUrl,
mx_is_url: identityServerUrl,
mx_user_id: userId,
mx_device_id: deviceId,
mx_oidc_token_issuer: "test-issuer.dummy",
mx_oidc_client_id: "test-client-id",
};
const idbStorageSession = {
account: {
mx_access_token: accessToken,
},
};
const credentials = {
homeserverUrl,
identityServerUrl,
userId,
deviceId,
accessToken,
};
const refreshToken = "test-refresh-token";
const encryptedTokenShapedObject = {
ciphertext: expect.any(String),
iv: expect.any(String),
mac: expect.any(String),
};
describe("loadSession", () => {
beforeEach(() => {
// stub this out
jest.spyOn(Modal, "createDialog").mockReturnValue(
// @ts-ignore allow bad mock
{ finished: Promise.resolve([true]) },
);
});
it("should not show any error dialog when checkConsistency throws but abortSignal has triggered", async () => {
jest.spyOn(StorageManager, "checkConsistency").mockRejectedValue(new Error("test error"));
const abortController = new AbortController();
const prom = Lifecycle.loadSession({
enableGuest: true,
guestHsUrl: "https://guest.server",
urlParams: { guest: { guest_user_id: "a", guest_access_token: "b" } },
abortSignal: abortController.signal,
});
abortController.abort();
await expect(prom).resolves.toBeFalsy();
expect(Modal.createDialog).not.toHaveBeenCalled();
});
});
describe("restoreSessionFromStorage()", () => {
beforeEach(() => {
initIdbMock();
jest.clearAllMocks();
jest.spyOn(logger, "log").mockClear();
jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
jest.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient);
// stub this out
jest.spyOn(Modal, "createDialog").mockReturnValue(
// @ts-ignore allow bad mock
{ finished: Promise.resolve([true]) },
);
});
it("should return false when localStorage is not available", async () => {
// @ts-ignore dirty mocking
delete global.localStorage;
// @ts-ignore dirty mocking
global.localStorage = undefined;
expect(await restoreSessionFromStorage()).toEqual(false);
});
it("should return false when no session data is found in local storage", async () => {
expect(await restoreSessionFromStorage()).toEqual(false);
expect(logger.log).toHaveBeenCalledWith("No previous session found.");
});
it("should abort login when we expect to find an access token but don't", async () => {
localStorage.setItem("mx_has_access_token", "true");
await expect(() => restoreSessionFromStorage()).rejects.toThrow();
expect(Modal.createDialog).toHaveBeenCalledWith(StorageEvictedDialog);
expect(mockClient.clearStores).toHaveBeenCalled();
});
describe("when session is found in storage", () => {
describe("guest account", () => {
beforeEach(() => {
localStorage.setItem("mx_is_guest", "true");
for (const key in localStorageSession) {
localStorage.setItem(key, localStorageSession[key]);
}
initIdbMock(idbStorageSession);
});
it("should ignore guest accounts when ignoreGuest is true", async () => {
expect(await restoreSessionFromStorage({ ignoreGuest: true })).toEqual(false);
expect(logger.log).toHaveBeenCalledWith(`Ignoring stored guest account: ${userId}`);
});
it("should restore guest accounts when ignoreGuest is false", async () => {
expect(await restoreSessionFromStorage({ ignoreGuest: false })).toEqual(true);
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
expect.objectContaining({
userId,
guest: true,
}),
undefined,
);
expect(localStorage.getItem("mx_is_guest")).toEqual("true");
});
});
describe("without a pickle key", () => {
beforeEach(() => {
for (const key in localStorageSession) {
localStorage.setItem(key, localStorageSession[key]);
}
initIdbMock(idbStorageSession);
});
it("should persist credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(localStorage.getItem("mx_user_id")).toEqual(userId);
expect(localStorage.getItem("mx_has_access_token")).toEqual("true");
expect(localStorage.getItem("mx_is_guest")).toEqual("false");
expect(localStorage.getItem("mx_device_id")).toEqual(deviceId);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
// dont put accessToken in localstorage when we have idb
expect(localStorage.getItem("mx_access_token")).not.toEqual(accessToken);
});
it("should persist access token when idb is not available", async () => {
jest.spyOn(StorageAccess, "idbSave").mockRejectedValue("oups");
expect(await restoreSessionFromStorage()).toEqual(true);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
// put accessToken in localstorage as fallback
expect(localStorage.getItem("mx_access_token")).toEqual(accessToken);
});
it("should create and start new matrix client with credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
{
userId,
accessToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: false,
guest: false,
pickleKey: undefined,
},
undefined,
);
expect(MatrixClientPeg.start).toHaveBeenCalledWith({});
});
it("should remove fresh login flag from session storage", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(sessionStorage.getItem("mx_fresh_login")).toBeFalsy();
});
it("should start matrix client", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(MatrixClientPeg.start).toHaveBeenCalled();
});
describe("with a refresh token", () => {
beforeEach(() => {
localStorage.setItem("mx_refresh_token", refreshToken);
for (const key in localStorageSession) {
localStorage.setItem(key, localStorageSession[key]);
}
initIdbMock(idbStorageSession);
});
it("should persist credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
// refresh token from storage is re-persisted
expect(localStorage.getItem("mx_has_refresh_token")).toEqual("true");
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken);
});
it("should create new matrix client with credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
{
userId,
accessToken,
// refreshToken included in credentials
refreshToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: false,
guest: false,
pickleKey: undefined,
},
expect.any(Function),
);
});
});
});
describe("with a normal pickle key", () => {
let pickleKey: string;
beforeEach(async () => {
for (const key in localStorageSession) {
localStorage.setItem(key, localStorageSession[key]);
}
initIdbMock({});
// Create a pickle key, and store it, encrypted, in IDB.
pickleKey = (await PlatformPeg.get()!.createPickleKey(credentials.userId, credentials.deviceId))!;
// Indicate that we should have a pickle key
localStorage.setItem("mx_has_pickle_key", "true");
await persistAccessTokenInStorage(credentials.accessToken, pickleKey);
});
it("should persist credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(localStorage.getItem("mx_has_access_token")).toEqual("true");
// token encrypted and persisted
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
"account",
"mx_access_token",
encryptedTokenShapedObject,
);
});
it("should persist access token when idb is not available", async () => {
// dont fail for pickle key persist
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
async (table: string, key: string | string[]) => {
if (table === "account" && key === "mx_access_token") {
throw new Error("oups");
}
},
);
expect(await restoreSessionFromStorage()).toEqual(true);
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
"account",
"mx_access_token",
encryptedTokenShapedObject,
);
// put accessToken in localstorage as fallback
expect(localStorage.getItem("mx_access_token")).toEqual(accessToken);
});
it("should create and start new matrix client with credentials", async () => {
// Check that the rust crypto key is as expected. We have to do this during the call, as
// the buffer is cleared afterwards.
mocked(MatrixClientPeg.start).mockImplementation(async (opts) => {
expect(opts?.rustCryptoStoreKey).toEqual(decodeBase64(pickleKey));
});
// Perform the restore
expect(await restoreSessionFromStorage()).toEqual(true);
// Ensure that the expected calls were made
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
{
userId,
// decrypted accessToken
accessToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: false,
guest: false,
pickleKey,
},
undefined,
);
expect(MatrixClientPeg.start).toHaveBeenCalledWith({ rustCryptoStoreKey: expect.any(Uint8Array) });
});
describe("with a refresh token", () => {
beforeEach(async () => {
await persistRefreshTokenInStorage(refreshToken, pickleKey);
});
it("should persist credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
// refresh token from storage is re-persisted
expect(localStorage.getItem("mx_has_refresh_token")).toEqual("true");
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
"account",
"mx_refresh_token",
encryptedTokenShapedObject,
);
});
it("should create new matrix client with credentials", async () => {
expect(await restoreSessionFromStorage()).toEqual(true);
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
{
userId,
accessToken,
// refreshToken included in credentials
refreshToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: false,
guest: false,
pickleKey: pickleKey,
},
expect.any(Function),
);
});
});
});
describe("with a non-standard pickle key", () => {
// Most pickle keys are 43 bytes of base64. Test what happens when it is something else.
let pickleKey: string;
beforeEach(async () => {
for (const key in localStorageSession) {
localStorage.setItem(key, localStorageSession[key]);
}
initIdbMock({});
// Generate the pickle key. I don't *think* it's possible for there to be a pickle key
// which is not some amount of base64.
const rawPickleKey = new Uint8Array(10);
crypto.getRandomValues(rawPickleKey);
pickleKey = encodeUnpaddedBase64(rawPickleKey);
// Store it, encrypted, in the db
await idbSave(
"pickleKey",
[userId, deviceId],
(await encryptPickleKey(rawPickleKey, userId, deviceId))!,
);
// Indicate that we should have a pickle key
localStorage.setItem("mx_has_pickle_key", "true");
await persistAccessTokenInStorage(credentials.accessToken, pickleKey);
});
it("should create and start new matrix client with credentials", async () => {
// Perform the restore
expect(await restoreSessionFromStorage()).toEqual(true);
// Ensure that the expected calls were made
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
{
userId,
// decrypted accessToken
accessToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: false,
guest: false,
pickleKey,
},
undefined,
);
expect(MatrixClientPeg.start).toHaveBeenCalledWith({ rustCryptoStorePassword: pickleKey });
});
});
it("should proceed if server is not accessible", async () => {
for (const key in localStorageSession) {
localStorage.setItem(key, localStorageSession[key]);
}
initIdbMock(idbStorageSession);
mockClient.isVersionSupported.mockRejectedValue(new Error("Oh, noes, the server is down!"));
expect(await restoreSessionFromStorage()).toEqual(true);
});
it("should throw if the token was persisted with a pickle key but there is no pickle key available now", async () => {
for (const key in localStorageSession) {
localStorage.setItem(key, localStorageSession[key]);
}
initIdbMock({});
// Create a pickle key, and store it, encrypted, in IDB.
const pickleKey = (await PlatformPeg.get()!.createPickleKey(credentials.userId, credentials.deviceId))!;
localStorage.setItem("mx_has_pickle_key", "true");
await persistAccessTokenInStorage(credentials.accessToken, pickleKey);
// Now destroy the pickle key
await PlatformPeg.get()!.destroyPickleKey(credentials.userId, credentials.deviceId);
await expect(restoreSessionFromStorage()).rejects.toThrow(
"Error decrypting secret access_token: no pickle key found.",
);
});
});
});
describe("setLoggedIn()", () => {
beforeEach(() => {
initIdbMock();
jest.clearAllMocks();
jest.spyOn(logger, "log").mockClear();
jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
// remove any mock implementations
jest.spyOn(mockPlatform, "createPickleKey").mockRestore();
// but still spy and call through
jest.spyOn(mockPlatform, "createPickleKey");
});
const refreshToken = "test-refresh-token";
it("should remove fresh login flag from session storage", async () => {
await setLoggedIn(credentials);
expect(sessionStorage.getItem("mx_fresh_login")).toBeFalsy();
});
it("should start matrix client", async () => {
await setLoggedIn(credentials);
expect(MatrixClientPeg.start).toHaveBeenCalled();
});
describe("after a soft-logout", () => {
beforeEach(async () => {
await setLoggedIn(credentials);
localStorage.setItem("mx_soft_logout", "true");
});
it("should not clear the storage if device is the same", async () => {
await Lifecycle.hydrateSession(credentials);
expect(localStorage.getItem("mx_soft_logout")).toBeFalsy();
expect(mockClient.getUserId).toHaveReturnedWith(userId);
expect(mockClient.getDeviceId).toHaveReturnedWith(deviceId);
expect(mockClient.clearStores).toHaveBeenCalledTimes(1);
});
it("should clear the storage if device is not the same", async () => {
const fakeCredentials = {
homeserverUrl,
identityServerUrl,
userId: "@bob:domain",
deviceId,
accessToken,
};
await Lifecycle.hydrateSession(fakeCredentials);
expect(localStorage.getItem("mx_soft_logout")).toBeFalsy();
expect(mockClient.getUserId).toHaveReturnedWith(userId);
expect(mockClient.getDeviceId).toHaveReturnedWith(deviceId);
expect(mockClient.clearStores).toHaveBeenCalledTimes(2);
});
});
describe("without a pickle key", () => {
beforeEach(() => {
jest.spyOn(mockPlatform, "createPickleKey").mockResolvedValue(null);
jest.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient);
});
it("should persist credentials", async () => {
await setLoggedIn(credentials);
expect(localStorage.getItem("mx_user_id")).toEqual(userId);
expect(localStorage.getItem("mx_has_access_token")).toEqual("true");
expect(localStorage.getItem("mx_is_guest")).toEqual("false");
expect(localStorage.getItem("mx_device_id")).toEqual(deviceId);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
// dont put accessToken in localstorage when we have idb
expect(localStorage.getItem("mx_access_token")).not.toEqual(accessToken);
});
it("should persist a refreshToken when present", async () => {
localStorage.setItem("mx_oidc_token_issuer", "test-issuer.dummy");
localStorage.setItem("mx_oidc_client_id", "test-client-id");
await setLoggedIn({
...credentials,
refreshToken,
});
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken);
// dont put accessToken in localstorage when we have idb
expect(localStorage.getItem("mx_access_token")).not.toEqual(accessToken);
});
it("should remove any access token from storage when there is none in credentials and idb save fails", async () => {
jest.spyOn(StorageAccess, "idbSave").mockRejectedValue("oups");
await setLoggedIn({
...credentials,
// @ts-ignore
accessToken: undefined,
});
expect(localStorage.getItem("mx_has_access_token")).toBeFalsy();
expect(localStorage.getItem("mx_access_token")).toBeFalsy();
});
it("should clear stores", async () => {
await setLoggedIn(credentials);
expect(StorageAccess.idbClear).toHaveBeenCalledWith("account");
expect(sessionStorage.length).toBe(0);
expect(mockClient.clearStores).toHaveBeenCalled();
});
it("should create new matrix client with credentials", async () => {
expect(await setLoggedIn(credentials)).toEqual(mockClient);
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
{
userId,
accessToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: true,
guest: false,
pickleKey: undefined,
},
undefined,
);
});
});
describe("with a pickle key", () => {
it("should not create a pickle key when credentials do not include deviceId", async () => {
await setLoggedIn({
...credentials,
deviceId: undefined,
});
// unpickled access token saved
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
expect(mockPlatform.createPickleKey).not.toHaveBeenCalled();
});
it("creates a pickle key with userId and deviceId", async () => {
await setLoggedIn(credentials);
expect(mockPlatform.createPickleKey).toHaveBeenCalledWith(userId, deviceId);
});
it("should persist credentials", async () => {
await setLoggedIn(credentials);
expect(localStorage.getItem("mx_user_id")).toEqual(userId);
expect(localStorage.getItem("mx_has_access_token")).toEqual("true");
expect(localStorage.getItem("mx_is_guest")).toEqual("false");
expect(localStorage.getItem("mx_device_id")).toEqual(deviceId);
expect(localStorage.getItem("mx_has_pickle_key")).toEqual("true");
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
"account",
"mx_access_token",
encryptedTokenShapedObject,
);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("pickleKey", [userId, deviceId], expect.any(Object));
// dont put accessToken in localstorage when we have idb
expect(localStorage.getItem("mx_access_token")).not.toEqual(accessToken);
});
it("should persist token when encrypting the token fails", async () => {
jest.spyOn(encryptAESSecretStorageItemModule, "default").mockRejectedValue("MOCK REJECT ENCRYPTAES");
await setLoggedIn(credentials);
// persist the unencrypted token
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
});
it("should persist token in localStorage when idb fails to save token", async () => {
// dont fail for pickle key persist
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
async (table: string, key: string | string[]) => {
if (table === "account" && key === "mx_access_token") {
throw new Error("oups");
}
},
);
await setLoggedIn(credentials);
// put plain accessToken in localstorage when we dont have idb
expect(localStorage.getItem("mx_access_token")).toEqual(accessToken);
});
it("should remove any access token from storage when there is none in credentials and idb save fails", async () => {
// dont fail for pickle key persist
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
async (table: string, key: string | string[]) => {
if (table === "account" && key === "mx_access_token") {
throw new Error("oups");
}
},
);
await setLoggedIn({
...credentials,
// @ts-ignore
accessToken: undefined,
});
expect(localStorage.getItem("mx_has_access_token")).toBeFalsy();
expect(localStorage.getItem("mx_access_token")).toBeFalsy();
});
it("should create new matrix client with credentials", async () => {
jest.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient);
expect(await setLoggedIn(credentials)).toEqual(mockClient);
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
{
userId,
accessToken,
homeserverUrl,
identityServerUrl,
deviceId,
freshLogin: true,
guest: false,
pickleKey: expect.any(String),
},
undefined,
);
});
});
// XXX: these tests are broken, Lifecycle.setLoggedIn does not work with OIDC and its token refreshers due to clearing storage
describe.skip("when authenticated via OIDC native flow", () => {
const clientId = "test-client-id";
const issuer = "https://auth.com/";
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
const idToken =
"eyJhbGciOiJSUzI1NiIsImtpZCI6Imh4ZEhXb0Y5bW4ifQ.eyJzdWIiOiIwMUhQUDJGU0JZREU5UDlFTU04REQ3V1pIUiIsImlzcyI6Imh0dHBzOi8vYXV0aC1vaWRjLmxhYi5lbGVtZW50LmRldi8iLCJpYXQiOjE3MTUwNzE5ODUsImF1dGhfdGltZSI6MTcwNzk5MDMxMiwiY19oYXNoIjoidGt5R1RhUjU5aTk3YXoyTU4yMGdidyIsImV4cCI6MTcxNTA3NTU4NSwibm9uY2UiOiJxaXhwM0hFMmVaIiwiYXVkIjoiMDFIWDk0Mlg3QTg3REgxRUs2UDRaNjI4WEciLCJhdF9oYXNoIjoiNFlFUjdPRlVKTmRTeEVHV2hJUDlnZyJ9.HxODneXvSTfWB5Vc4cf7b8GiN2gdwUuTiyVqZuupWske2HkZiJZUt5Lsxg9BW3gz28POkE0Ln17snlkmy02B_AD3DQxKOOxQCzIIARHdfFvZxgGWsMdFcVQZDW7rtXcqgj-SpVaUQ_8acsgxSrz_DF2o0O4tto0PT6wVUiw8KlBmgWTscWPeAWe-39T-8EiQ8Wi16h6oSPcz2NzOQ7eOM_S9fDkOorgcBkRGLl1nrahrPSdWJSGAeruk5mX4YxN714YThFDyEA2t9YmKpjaiSQ2tT-Xkd7tgsZqeirNs2ni9mIiFX3bRX6t2AhUNzA7MaX9ZyizKGa6go3BESO_oDg";
beforeEach(() => {
fetchMock.get(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`, delegatedAuthConfig);
fetchMock.get(`${delegatedAuthConfig.issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
// set values in local storage as they would be after a successful oidc authentication
localStorage.setItem("mx_oidc_client_id", clientId);
localStorage.setItem("mx_oidc_token_issuer", issuer);
localStorage.setItem("mx_oidc_id_token", idToken);
});
it("should not try to create a token refresher without a refresh token", async () => {
await setLoggedIn(credentials);
// didn't try to initialise token refresher
expect(fetchMock).toHaveFetchedTimes(
0,
`${delegatedAuthConfig.issuer}.well-known/openid-configuration`,
);
});
it("should not try to create a token refresher without a deviceId", async () => {
await expect(
setLoggedIn({
...credentials,
refreshToken,
deviceId: undefined,
}),
).rejects.toThrow("Expected deviceId in user credentials.");
// didn't try to initialise token refresher
expect(fetchMock).toHaveFetchedTimes(
0,
`${delegatedAuthConfig.issuer}.well-known/openid-configuration`,
);
});
it("should not try to create a token refresher without an issuer in session storage", async () => {
localStorage.removeItem("mx_oidc_token_issuer");
await expect(
setLoggedIn({
...credentials,
refreshToken,
}),
).rejects.toThrow("Cannot create an OIDC token refresher as no stored OIDC token issuer was found.");
// didn't try to initialise token refresher
expect(fetchMock).toHaveFetchedTimes(
0,
`${delegatedAuthConfig.issuer}.well-known/openid-configuration`,
);
});
it("should create a client with a tokenRefreshFunction", async () => {
expect(
await setLoggedIn({
...credentials,
refreshToken,
}),
).toEqual(mockClient);
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
expect.objectContaining({
accessToken,
refreshToken,
}),
expect.any(Function),
);
});
it("should create a client when creating token refresher fails", async () => {
// create invalid value in local storage for a malformed oidc authentication
localStorage.removeItem("mx_oidc_client_id");
// succeeded
expect(
await setLoggedIn({
...credentials,
refreshToken,
}),
).toEqual(mockClient);
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
expect.objectContaining({
accessToken,
refreshToken,
}),
// no token refresh function
undefined,
);
});
});
});
describe("logout()", () => {
let oidcClientStore!: OidcClientStore;
const accessToken = "test-access-token";
const refreshToken = "test-refresh-token";
beforeEach(() => {
oidcClientStore = new OidcClientStore(mockClient);
// stub
jest.spyOn(oidcClientStore, "revokeTokens").mockResolvedValue(undefined);
mockClient.getAccessToken.mockReturnValue(accessToken);
mockClient.getRefreshToken.mockReturnValue(refreshToken);
});
it("should call logout on the client when oidcClientStore is falsy", async () => {
logout();
await flushPromises();
expect(mockClient.logout).toHaveBeenCalledWith(true);
});
it("should call logout on the client when oidcClientStore.isUserAuthenticatedWithOidc is falsy", async () => {
jest.spyOn(oidcClientStore, "isUserAuthenticatedWithOidc", "get").mockReturnValue(false);
logout(oidcClientStore);
await flushPromises();
expect(mockClient.logout).toHaveBeenCalledWith(true);
expect(oidcClientStore.revokeTokens).not.toHaveBeenCalled();
});
it("should revoke tokens when user is authenticated with oidc", async () => {
jest.spyOn(oidcClientStore, "isUserAuthenticatedWithOidc", "get").mockReturnValue(true);
logout(oidcClientStore);
await flushPromises();
expect(mockClient.logout).not.toHaveBeenCalled();
expect(oidcClientStore.revokeTokens).toHaveBeenCalledWith(accessToken, refreshToken);
});
});
describe("overwritelogin", () => {
beforeEach(async () => {
jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
});
it("should replace the current login with a new one", async () => {
const stopSpy = jest.spyOn(mockClient, "stopClient").mockReturnValue(undefined);
jest.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient);
const dis = window.mxDispatcher;
const firstLoginEvent: Promise<void> = new Promise((resolve) => {
dis.register(({ action }) => {
if (action === Action.OnLoggedIn) {
resolve();
}
});
});
// set a logged in state
await setLoggedIn(credentials);
await firstLoginEvent;
expect(stopSpy).toHaveBeenCalledTimes(1);
// important the overwrite action should not call unset before replacing.
// So spy on it and make sure it's not called.
jest.spyOn(MatrixClientPeg, "unset").mockReturnValue(undefined);
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
expect.objectContaining({
userId,
}),
undefined,
);
const otherCredentials = {
...credentials,
userId: "@bob:server.org",
deviceId: "def456",
};
const secondLoginEvent: Promise<void> = new Promise((resolve) => {
dis.register(({ action }) => {
if (action === Action.OnLoggedIn) {
resolve();
}
});
});
// Trigger the overwrite login action
dis.dispatch(
{
action: "overwrite_login",
credentials: otherCredentials,
},
true,
);
await secondLoginEvent;
// the client should have been stopped
expect(stopSpy).toHaveBeenCalledTimes(2);
expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith(
expect.objectContaining({
userId: otherCredentials.userId,
}),
undefined,
);
expect(MatrixClientPeg.unset).not.toHaveBeenCalled();
});
});
});