Migrate batch of tests to vitest (#34111)
* Migrate batch of tests tro vitest * Iterate * Migrate another test * Fix lockfile
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 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 { vi, describe, it, expect, beforeEach, type Mock } from "vitest";
|
||||
import { type Room, type RoomMember, RoomType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { avatarUrlForRoom } from "./Avatar";
|
||||
import { type Media, mediaFromMxc } from "./customisations/Media";
|
||||
import DMRoomMap from "./utils/DMRoomMap";
|
||||
|
||||
vi.mock("./customisations/Media", () => ({
|
||||
mediaFromMxc: vi.fn(),
|
||||
}));
|
||||
|
||||
const roomId = "!room:example.com";
|
||||
const avatarUrl1 = "https://example.com/avatar1";
|
||||
const avatarUrl2 = "https://example.com/avatar2";
|
||||
|
||||
describe("avatarUrlForRoom", () => {
|
||||
let getThumbnailOfSourceHttp: Mock;
|
||||
let room: Room;
|
||||
let roomMember: RoomMember;
|
||||
let dmRoomMap: DMRoomMap;
|
||||
|
||||
beforeEach(() => {
|
||||
getThumbnailOfSourceHttp = vi.fn();
|
||||
vi.mocked(mediaFromMxc).mockImplementation((): Media => {
|
||||
return {
|
||||
getThumbnailOfSourceHttp,
|
||||
} as unknown as Media;
|
||||
});
|
||||
room = {
|
||||
roomId,
|
||||
getMxcAvatarUrl: vi.fn(),
|
||||
isSpaceRoom: vi.fn(),
|
||||
getType: vi.fn(),
|
||||
getAvatarFallbackMember: vi.fn(),
|
||||
} as unknown as Room;
|
||||
dmRoomMap = {
|
||||
getUserIdForRoomId: vi.fn(),
|
||||
} as unknown as DMRoomMap;
|
||||
DMRoomMap.setShared(dmRoomMap);
|
||||
roomMember = {
|
||||
getMxcAvatarUrl: vi.fn(),
|
||||
} as unknown as RoomMember;
|
||||
});
|
||||
|
||||
it("should return null for a null room", () => {
|
||||
expect(avatarUrlForRoom(null, 128, 128)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return the HTTP source if the room provides a MXC url", () => {
|
||||
vi.mocked(room.getMxcAvatarUrl).mockReturnValue(avatarUrl1);
|
||||
getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2);
|
||||
expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2);
|
||||
expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop");
|
||||
});
|
||||
|
||||
it("should return null for a space room", () => {
|
||||
vi.mocked(room.isSpaceRoom).mockReturnValue(true);
|
||||
vi.mocked(room.getType).mockReturnValue(RoomType.Space);
|
||||
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null if the room is not a DM", () => {
|
||||
vi.mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue(undefined);
|
||||
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
|
||||
expect(dmRoomMap.getUserIdForRoomId).toHaveBeenCalledWith(roomId);
|
||||
});
|
||||
|
||||
it("should return null if there is no other member in the room", () => {
|
||||
vi.mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
|
||||
vi.mocked(room.getAvatarFallbackMember).mockReturnValue(undefined);
|
||||
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null if the other member has no avatar URL", () => {
|
||||
vi.mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
|
||||
vi.mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember);
|
||||
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return the other member's avatar URL", () => {
|
||||
vi.mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
|
||||
vi.mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember);
|
||||
vi.mocked(roomMember.getMxcAvatarUrl).mockReturnValue(avatarUrl2);
|
||||
getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2);
|
||||
expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2);
|
||||
expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 Clemens Zeidler
|
||||
|
||||
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 { describe, it, expect } from "vitest";
|
||||
|
||||
import { isKeyComboMatch, type KeyCombo } from "./KeyBindingsManager";
|
||||
|
||||
function mockKeyEvent(
|
||||
key: string,
|
||||
modifiers?: {
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
},
|
||||
): KeyboardEvent {
|
||||
return {
|
||||
key,
|
||||
ctrlKey: modifiers?.ctrlKey ?? false,
|
||||
altKey: modifiers?.altKey ?? false,
|
||||
shiftKey: modifiers?.shiftKey ?? false,
|
||||
metaKey: modifiers?.metaKey ?? false,
|
||||
} as KeyboardEvent;
|
||||
}
|
||||
|
||||
describe("KeyBindingsManager", () => {
|
||||
it("should match basic key combo", () => {
|
||||
const combo1: KeyCombo = {
|
||||
key: "k",
|
||||
};
|
||||
expect(isKeyComboMatch(mockKeyEvent("k"), combo1, false)).toBe(true);
|
||||
expect(isKeyComboMatch(mockKeyEvent("n"), combo1, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("should match key + modifier key combo", () => {
|
||||
const combo: KeyCombo = {
|
||||
key: "k",
|
||||
ctrlKey: true,
|
||||
};
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true }), combo, false)).toBe(true);
|
||||
expect(isKeyComboMatch(mockKeyEvent("n", { ctrlKey: true }), combo, false)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k"), combo, false)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { shiftKey: true }), combo, false)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { shiftKey: true, metaKey: true }), combo, false)).toBe(false);
|
||||
|
||||
const combo2: KeyCombo = {
|
||||
key: "k",
|
||||
metaKey: true,
|
||||
};
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { metaKey: true }), combo2, false)).toBe(true);
|
||||
expect(isKeyComboMatch(mockKeyEvent("n", { metaKey: true }), combo2, false)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k"), combo2, false)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { altKey: true, metaKey: true }), combo2, false)).toBe(false);
|
||||
|
||||
const combo3: KeyCombo = {
|
||||
key: "k",
|
||||
altKey: true,
|
||||
};
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { altKey: true }), combo3, false)).toBe(true);
|
||||
expect(isKeyComboMatch(mockKeyEvent("n", { altKey: true }), combo3, false)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k"), combo3, false)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, metaKey: true }), combo3, false)).toBe(false);
|
||||
|
||||
const combo4: KeyCombo = {
|
||||
key: "k",
|
||||
shiftKey: true,
|
||||
};
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { shiftKey: true }), combo4, false)).toBe(true);
|
||||
expect(isKeyComboMatch(mockKeyEvent("n", { shiftKey: true }), combo4, false)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k"), combo4, false)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { shiftKey: true, ctrlKey: true }), combo4, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("should match key + multiple modifiers key combo", () => {
|
||||
const combo: KeyCombo = {
|
||||
key: "k",
|
||||
ctrlKey: true,
|
||||
altKey: true,
|
||||
};
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, altKey: true }), combo, false)).toBe(true);
|
||||
expect(isKeyComboMatch(mockKeyEvent("n", { ctrlKey: true, altKey: true }), combo, false)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, metaKey: true }), combo, false)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, metaKey: true, shiftKey: true }), combo, false)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const combo2: KeyCombo = {
|
||||
key: "k",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
altKey: true,
|
||||
};
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, shiftKey: true, altKey: true }), combo2, false)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isKeyComboMatch(mockKeyEvent("n", { ctrlKey: true, shiftKey: true, altKey: true }), combo2, false)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, metaKey: true }), combo2, false)).toBe(false);
|
||||
expect(
|
||||
isKeyComboMatch(
|
||||
mockKeyEvent("k", { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }),
|
||||
combo2,
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const combo3: KeyCombo = {
|
||||
key: "k",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
altKey: true,
|
||||
metaKey: true,
|
||||
};
|
||||
expect(
|
||||
isKeyComboMatch(
|
||||
mockKeyEvent("k", { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }),
|
||||
combo3,
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isKeyComboMatch(
|
||||
mockKeyEvent("n", { ctrlKey: true, shiftKey: true, altKey: true, metaKey: true }),
|
||||
combo3,
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, shiftKey: true, altKey: true }), combo3, false)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should match ctrlOrMeta key combo", () => {
|
||||
const combo: KeyCombo = {
|
||||
key: "k",
|
||||
ctrlOrCmdKey: true,
|
||||
};
|
||||
// PC:
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true }), combo, false)).toBe(true);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { metaKey: true }), combo, false)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("n", { ctrlKey: true }), combo, false)).toBe(false);
|
||||
// MAC:
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { metaKey: true }), combo, true)).toBe(true);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true }), combo, true)).toBe(false);
|
||||
expect(isKeyComboMatch(mockKeyEvent("n", { ctrlKey: true }), combo, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("should match advanced ctrlOrMeta key combo", () => {
|
||||
const combo: KeyCombo = {
|
||||
key: "k",
|
||||
ctrlOrCmdKey: true,
|
||||
altKey: true,
|
||||
};
|
||||
// PC:
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, altKey: true }), combo, false)).toBe(true);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { metaKey: true, altKey: true }), combo, false)).toBe(false);
|
||||
// MAC:
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { metaKey: true, altKey: true }), combo, true)).toBe(true);
|
||||
expect(isKeyComboMatch(mockKeyEvent("k", { ctrlKey: true, altKey: true }), combo, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,997 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach, type MockedObject } from "vitest";
|
||||
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 fetchMock from "@fetch-mock/vitest";
|
||||
import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser, mockPlatformPeg } from "test-utils";
|
||||
import { makeDelegatedAuthConfig } from "test-utils/oidc";
|
||||
|
||||
import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog";
|
||||
import * as Lifecycle from "./Lifecycle";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import Modal from "./Modal";
|
||||
import * as StorageAccess from "./utils/StorageAccess";
|
||||
import { idbSave } from "./utils/StorageAccess";
|
||||
import { OidcClientStore } from "./stores/oidc/OidcClientStore";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import PlatformPeg from "./PlatformPeg";
|
||||
import { persistAccessTokenInStorage, persistRefreshTokenInStorage } from "./utils/tokens/tokens";
|
||||
import { encryptPickleKey } from "./utils/tokens/pickling";
|
||||
import * as StorageManager from "./utils/StorageManager.ts";
|
||||
import type BasePlatform from "./BasePlatform.ts";
|
||||
import * as createMatrixClientModule from "./utils/createMatrixClient";
|
||||
|
||||
const { logout, restoreSessionFromStorage, setLoggedIn } = Lifecycle;
|
||||
|
||||
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>;
|
||||
|
||||
let mockClient!: MockedObject<MatrixJs.MatrixClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlatform = mockPlatformPeg();
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
stopClient: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
clearStores: vi.fn(),
|
||||
getAccountData: vi.fn(),
|
||||
getDeviceId: vi.fn().mockReturnValue(deviceId),
|
||||
isVersionSupported: vi.fn().mockResolvedValue(true),
|
||||
getCrypto: vi.fn(),
|
||||
getClientWellKnown: vi.fn(),
|
||||
waitForClientWellKnown: vi.fn(),
|
||||
getThirdpartyProtocols: vi.fn(),
|
||||
store: {
|
||||
destroy: vi.fn(),
|
||||
},
|
||||
getVersions: vi.fn().mockResolvedValue({ versions: ["v1.1"] }),
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
getAccessToken: vi.fn(),
|
||||
getRefreshToken: vi.fn(),
|
||||
isInitialSyncComplete: vi.fn(),
|
||||
setGuest: vi.fn(),
|
||||
setNotifTimelineSet: vi.fn(),
|
||||
});
|
||||
// stub this
|
||||
vi.spyOn(MatrixClientPeg, "set").mockImplementation(() => {});
|
||||
vi.spyOn(MatrixClientPeg, "start").mockResolvedValue(undefined);
|
||||
|
||||
vi.spyOn(encryptAESSecretStorageItemModule, "default").mockRestore();
|
||||
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const initIdbMock = (mockStore: Record<string, Record<string, unknown>> = {}): void => {
|
||||
vi.spyOn(StorageAccess, "idbLoad")
|
||||
.mockClear()
|
||||
.mockImplementation(
|
||||
// @ts-ignore mock type
|
||||
async (table: string, key: string) => mockStore[table]?.[key] ?? null,
|
||||
);
|
||||
vi.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;
|
||||
},
|
||||
);
|
||||
vi.spyOn(StorageAccess, "idbDelete")
|
||||
.mockClear()
|
||||
.mockImplementation(async (tableKey: string, key: string | string[]) => {
|
||||
const table = mockStore[tableKey];
|
||||
delete table?.[key as string];
|
||||
});
|
||||
vi.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
|
||||
vi.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 () => {
|
||||
vi.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()", () => {
|
||||
const realLocalStorage = localStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
initIdbMock();
|
||||
|
||||
vi.spyOn(logger, "log").mockClear();
|
||||
|
||||
vi.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
|
||||
vi.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient);
|
||||
|
||||
// stub this out
|
||||
vi.spyOn(Modal, "createDialog").mockReturnValue(
|
||||
// @ts-ignore allow bad mock
|
||||
{ finished: Promise.resolve([true]) },
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("localStorage", realLocalStorage);
|
||||
});
|
||||
|
||||
it("should return false when localStorage is not available", async () => {
|
||||
vi.stubGlobal("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 () => {
|
||||
vi.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
|
||||
vi.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.
|
||||
vi.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();
|
||||
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(logger, "log").mockClear();
|
||||
|
||||
vi.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
|
||||
// remove any mock implementations
|
||||
vi.spyOn(mockPlatform, "createPickleKey").mockRestore();
|
||||
// but still spy and call through
|
||||
vi.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(() => {
|
||||
vi.spyOn(mockPlatform, "createPickleKey").mockResolvedValue(null);
|
||||
vi.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 () => {
|
||||
vi.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 () => {
|
||||
vi.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
|
||||
vi.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
|
||||
vi.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 () => {
|
||||
vi.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
|
||||
vi.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 () => {
|
||||
vi.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 () => {
|
||||
vi.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 () => {
|
||||
vi.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
|
||||
});
|
||||
|
||||
it("should replace the current login with a new one", async () => {
|
||||
const stopSpy = vi.spyOn(mockClient, "stopClient").mockReturnValue(undefined);
|
||||
vi.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.
|
||||
vi.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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 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 { describe, it, expect } from "vitest";
|
||||
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
describe("Markdown parser test", () => {
|
||||
describe("fixing HTML links", () => {
|
||||
const testString = [
|
||||
"Test1:",
|
||||
"#_foonetic_xkcd:matrix.org",
|
||||
"http://google.com/_thing_",
|
||||
"https://matrix.org/_matrix/client/foo/123_",
|
||||
"#_foonetic_xkcd:matrix.org",
|
||||
"",
|
||||
"Test1A:",
|
||||
"#_foonetic_xkcd:matrix.org",
|
||||
"http://google.com/_thing_",
|
||||
"https://matrix.org/_matrix/client/foo/123_",
|
||||
"#_foonetic_xkcd:matrix.org",
|
||||
"",
|
||||
"Test2:",
|
||||
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
|
||||
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
|
||||
"",
|
||||
"Test3:",
|
||||
"https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org",
|
||||
"https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org",
|
||||
].join("\n");
|
||||
|
||||
it("tests that links with markdown empasis in them are getting properly HTML formatted", () => {
|
||||
/* eslint-disable max-len */
|
||||
const expectedResult = [
|
||||
"<p>Test1:<br />#_foonetic_xkcd:matrix.org<br />http://google.com/_thing_<br />https://matrix.org/_matrix/client/foo/123_<br />#_foonetic_xkcd:matrix.org</p>",
|
||||
"<p>Test1A:<br />#_foonetic_xkcd:matrix.org<br />http://google.com/_thing_<br />https://matrix.org/_matrix/client/foo/123_<br />#_foonetic_xkcd:matrix.org</p>",
|
||||
"<p>Test2:<br />http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg<br />http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg</p>",
|
||||
"<p>Test3:<br />https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org<br />https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org</p>",
|
||||
"",
|
||||
].join("\n");
|
||||
/* eslint-enable max-len */
|
||||
const md = new Markdown(testString);
|
||||
expect(md.toHTML()).toEqual(expectedResult);
|
||||
});
|
||||
it("tests that links with autolinks are not touched at all and are still properly formatted", () => {
|
||||
const test = [
|
||||
"Test1:",
|
||||
"<#_foonetic_xkcd:matrix.org>",
|
||||
"<http://google.com/_thing_>",
|
||||
"<https://matrix.org/_matrix/client/foo/123_>",
|
||||
"<#_foonetic_xkcd:matrix.org>",
|
||||
"",
|
||||
"Test1A:",
|
||||
"<#_foonetic_xkcd:matrix.org>",
|
||||
"<http://google.com/_thing_>",
|
||||
"<https://matrix.org/_matrix/client/foo/123_>",
|
||||
"<#_foonetic_xkcd:matrix.org>",
|
||||
"",
|
||||
"Test2:",
|
||||
"<http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg>",
|
||||
"<http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg>",
|
||||
"",
|
||||
"Test3:",
|
||||
"<https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org>",
|
||||
"<https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org>",
|
||||
].join("\n");
|
||||
/* eslint-disable max-len */
|
||||
/**
|
||||
* NOTE: I'm not entirely sure if those "<"" and ">" should be visible in here for #_foonetic_xkcd:matrix.org
|
||||
* but it seems to be actually working properly
|
||||
*/
|
||||
const expectedResult = [
|
||||
'<p>Test1:<br /><#_foonetic_xkcd:matrix.org><br /><a href="http://google.com/_thing_">http://google.com/_thing_</a><br /><a href="https://matrix.org/_matrix/client/foo/123_">https://matrix.org/_matrix/client/foo/123_</a><br /><#_foonetic_xkcd:matrix.org></p>',
|
||||
'<p>Test1A:<br /><#_foonetic_xkcd:matrix.org><br /><a href="http://google.com/_thing_">http://google.com/_thing_</a><br /><a href="https://matrix.org/_matrix/client/foo/123_">https://matrix.org/_matrix/client/foo/123_</a><br /><#_foonetic_xkcd:matrix.org></p>',
|
||||
'<p>Test2:<br /><a href="http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg">http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg</a><br /><a href="http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg">http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg</a></p>',
|
||||
'<p>Test3:<br /><a href="https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org">https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org</a><br /><a href="https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org">https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org</a></p>',
|
||||
"",
|
||||
].join("\n");
|
||||
/* eslint-enable max-len */
|
||||
const md = new Markdown(test);
|
||||
expect(md.toHTML()).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it("expects that links in codeblock are not modified", () => {
|
||||
const expectedResult = [
|
||||
'<pre><code class="language-Test1:">#_foonetic_xkcd:matrix.org',
|
||||
"http://google.com/_thing_",
|
||||
"https://matrix.org/_matrix/client/foo/123_",
|
||||
"#_foonetic_xkcd:matrix.org",
|
||||
"",
|
||||
"Test1A:",
|
||||
"#_foonetic_xkcd:matrix.org",
|
||||
"http://google.com/_thing_",
|
||||
"https://matrix.org/_matrix/client/foo/123_",
|
||||
"#_foonetic_xkcd:matrix.org",
|
||||
"",
|
||||
"Test2:",
|
||||
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
|
||||
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
|
||||
"",
|
||||
"Test3:",
|
||||
"https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org",
|
||||
"https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org```",
|
||||
"</code></pre>",
|
||||
"",
|
||||
].join("\n");
|
||||
const md = new Markdown("```" + testString + "```");
|
||||
expect(md.toHTML()).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('expects that links with emphasis are "escaped" correctly', () => {
|
||||
/* eslint-disable max-len */
|
||||
const testString = [
|
||||
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg" +
|
||||
" " +
|
||||
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
|
||||
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg" +
|
||||
" " +
|
||||
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
|
||||
"https://example.com/_test_test2_-test3",
|
||||
"https://example.com/_test_test2_test3_",
|
||||
"https://example.com/_test__test2_test3_",
|
||||
"https://example.com/_test__test2__test3_",
|
||||
"https://example.com/_test__test2_test3__",
|
||||
"https://example.com/_test__test2",
|
||||
].join("\n");
|
||||
const expectedResult = [
|
||||
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
|
||||
"http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg",
|
||||
"https://example.com/_test_test2_-test3",
|
||||
"https://example.com/_test_test2_test3_",
|
||||
"https://example.com/_test__test2_test3_",
|
||||
"https://example.com/_test__test2__test3_",
|
||||
"https://example.com/_test__test2_test3__",
|
||||
"https://example.com/_test__test2",
|
||||
].join("<br />");
|
||||
/* eslint-enable max-len */
|
||||
const md = new Markdown(testString);
|
||||
expect(md.toHTML()).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it("expects that the link part will not be accidentally added to <strong>", () => {
|
||||
/* eslint-disable max-len */
|
||||
const testString = `https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py`;
|
||||
const expectedResult = "https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py";
|
||||
/* eslint-enable max-len */
|
||||
const md = new Markdown(testString);
|
||||
expect(md.toHTML()).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it("expects that the link part will not be accidentally added to <strong> for multiline links", () => {
|
||||
/* eslint-disable max-len */
|
||||
const testString = [
|
||||
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py" +
|
||||
" " +
|
||||
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py",
|
||||
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py" +
|
||||
" " +
|
||||
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py",
|
||||
].join("\n");
|
||||
const expectedResult = [
|
||||
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py" +
|
||||
" " +
|
||||
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py",
|
||||
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py" +
|
||||
" " +
|
||||
"https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py",
|
||||
].join("<br />");
|
||||
/* eslint-enable max-len */
|
||||
const md = new Markdown(testString);
|
||||
expect(md.toHTML()).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it("resumes applying formatting to the rest of a message after a link", () => {
|
||||
const testString = "http://google.com/_thing_ *does* __not__ exist";
|
||||
const expectedResult = "http://google.com/_thing_ <em>does</em> <strong>not</strong> exist";
|
||||
const md = new Markdown(testString);
|
||||
expect(md.toHTML()).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { type PostHog } from "posthog-js";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
||||
import { getMockClientWithEventEmitter } from "test-utils";
|
||||
|
||||
import { Anonymity, getRedactedCurrentLocation, type IPosthogEvent, PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { Layout } from "./settings/enums/Layout";
|
||||
import defaultDispatcher from "./dispatcher/dispatcher";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import { SettingLevel } from "./settings/SettingLevel";
|
||||
|
||||
const getFakePosthog = (): PostHog =>
|
||||
({
|
||||
capture: vi.fn(),
|
||||
init: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
register: vi.fn(),
|
||||
get_distinct_id: vi.fn(),
|
||||
persistence: {
|
||||
get_property: vi.fn(),
|
||||
},
|
||||
identifyUser: vi.fn(),
|
||||
}) as unknown as PostHog;
|
||||
|
||||
interface ITestEvent extends IPosthogEvent {
|
||||
eventName: "TestEvents";
|
||||
foo?: string;
|
||||
}
|
||||
|
||||
describe("PosthogAnalytics", () => {
|
||||
let fakePosthog: PostHog;
|
||||
const shaHashes: Record<string, string> = {
|
||||
"42": "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049",
|
||||
"some": "a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b",
|
||||
"pii": "bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4",
|
||||
"foo": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fakePosthog = getFakePosthog();
|
||||
|
||||
Object.defineProperty(window, "crypto", {
|
||||
value: {
|
||||
subtle: {
|
||||
digest: async (_: AlgorithmIdentifier, encodedMessage: BufferSource) => {
|
||||
const message = new TextDecoder().decode(encodedMessage);
|
||||
const hexHash = shaHashes[message];
|
||||
const bytes: number[] = [];
|
||||
for (let c = 0; c < hexHash.length; c += 2) {
|
||||
bytes.push(parseInt(hexHash.slice(c, c + 2), 16));
|
||||
}
|
||||
return bytes;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "crypto", {
|
||||
value: null,
|
||||
});
|
||||
SdkConfig.reset(); // we touch the config, so clean up
|
||||
});
|
||||
|
||||
describe("Initialisation", () => {
|
||||
it("Should not be enabled without config being set", () => {
|
||||
// force empty/invalid state for posthog options
|
||||
SdkConfig.put({ brand: "Testing" });
|
||||
const analytics = new PosthogAnalytics(fakePosthog);
|
||||
expect(analytics.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("Should be enabled if config is set", () => {
|
||||
SdkConfig.put({
|
||||
brand: "Testing",
|
||||
posthog: {
|
||||
project_api_key: "foo",
|
||||
api_host: "bar",
|
||||
},
|
||||
});
|
||||
const analytics = new PosthogAnalytics(fakePosthog);
|
||||
analytics.setAnonymity(Anonymity.Pseudonymous);
|
||||
expect(analytics.isEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tracking", () => {
|
||||
let analytics: PosthogAnalytics;
|
||||
|
||||
beforeEach(() => {
|
||||
SdkConfig.put({
|
||||
brand: "Testing",
|
||||
posthog: {
|
||||
project_api_key: "foo",
|
||||
api_host: "bar",
|
||||
},
|
||||
});
|
||||
|
||||
analytics = new PosthogAnalytics(fakePosthog);
|
||||
});
|
||||
|
||||
it("Should pass event to posthog", () => {
|
||||
analytics.setAnonymity(Anonymity.Pseudonymous);
|
||||
analytics.trackEvent<ITestEvent>({
|
||||
eventName: "TestEvents",
|
||||
foo: "bar",
|
||||
});
|
||||
expect(vi.mocked(fakePosthog).capture.mock.calls[0][0]).toBe("TestEvents");
|
||||
expect(vi.mocked(fakePosthog).capture.mock.calls[0][1]!["foo"]).toEqual("bar");
|
||||
});
|
||||
|
||||
it("Should not track events if anonymous", async () => {
|
||||
analytics.setAnonymity(Anonymity.Anonymous);
|
||||
await analytics.trackEvent<ITestEvent>({
|
||||
eventName: "TestEvents",
|
||||
foo: "bar",
|
||||
});
|
||||
expect(fakePosthog.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should not track any events if disabled", async () => {
|
||||
analytics.setAnonymity(Anonymity.Disabled);
|
||||
analytics.trackEvent<ITestEvent>({
|
||||
eventName: "TestEvents",
|
||||
foo: "bar",
|
||||
});
|
||||
expect(fakePosthog.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should anonymise location of a known screen", async () => {
|
||||
const location = getRedactedCurrentLocation("https://foo.bar", "#/register/some/pii", "/");
|
||||
expect(location).toBe("https://foo.bar/#/register/<redacted>");
|
||||
});
|
||||
|
||||
it("Should anonymise location of an unknown screen", async () => {
|
||||
const location = getRedactedCurrentLocation("https://foo.bar", "#/not_a_screen_name/some/pii", "/");
|
||||
expect(location).toBe("https://foo.bar/#/<redacted_screen_name>/<redacted>");
|
||||
});
|
||||
|
||||
it("Should handle an empty hash", async () => {
|
||||
const location = getRedactedCurrentLocation("https://foo.bar", "", "/");
|
||||
expect(location).toBe("https://foo.bar/");
|
||||
});
|
||||
|
||||
it("Should identify the user to posthog if pseudonymous", async () => {
|
||||
analytics.setAnonymity(Anonymity.Pseudonymous);
|
||||
const client = getMockClientWithEventEmitter({
|
||||
getAccountDataFromServer: vi.fn().mockResolvedValue(null),
|
||||
setAccountData: vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
await analytics.identifyUser(client, () => "analytics_id");
|
||||
expect(vi.mocked(fakePosthog).identify.mock.calls[0][0]).toBe("analytics_id");
|
||||
});
|
||||
|
||||
it("Should not identify the user to posthog if anonymous", async () => {
|
||||
analytics.setAnonymity(Anonymity.Anonymous);
|
||||
const client = getMockClientWithEventEmitter({});
|
||||
await analytics.identifyUser(client, () => "analytics_id");
|
||||
expect(vi.mocked(fakePosthog).identify.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("Should identify using the server's analytics id if present", async () => {
|
||||
analytics.setAnonymity(Anonymity.Pseudonymous);
|
||||
|
||||
const client = getMockClientWithEventEmitter({
|
||||
getAccountDataFromServer: vi.fn().mockResolvedValue({ id: "existing_analytics_id" }),
|
||||
setAccountData: vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
await analytics.identifyUser(client, () => "new_analytics_id");
|
||||
expect(vi.mocked(fakePosthog).identify.mock.calls[0][0]).toBe("existing_analytics_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebLayout", () => {
|
||||
let analytics: PosthogAnalytics;
|
||||
|
||||
beforeEach(() => {
|
||||
SdkConfig.put({
|
||||
brand: "Testing",
|
||||
posthog: {
|
||||
project_api_key: "foo",
|
||||
api_host: "bar",
|
||||
},
|
||||
});
|
||||
|
||||
analytics = new PosthogAnalytics(fakePosthog);
|
||||
analytics.setAnonymity(Anonymity.Pseudonymous);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
it("should send layout IRC correctly", async () => {
|
||||
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
|
||||
defaultDispatcher.dispatch(
|
||||
{
|
||||
action: Action.SettingUpdated,
|
||||
settingName: "layout",
|
||||
},
|
||||
true,
|
||||
);
|
||||
analytics.trackEvent<ITestEvent>({
|
||||
eventName: "TestEvents",
|
||||
});
|
||||
expect(vi.mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
|
||||
WebLayout: "IRC",
|
||||
});
|
||||
});
|
||||
|
||||
it("should send layout Bubble correctly", async () => {
|
||||
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
|
||||
defaultDispatcher.dispatch(
|
||||
{
|
||||
action: Action.SettingUpdated,
|
||||
settingName: "layout",
|
||||
},
|
||||
true,
|
||||
);
|
||||
analytics.trackEvent<ITestEvent>({
|
||||
eventName: "TestEvents",
|
||||
});
|
||||
expect(vi.mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
|
||||
WebLayout: "Bubble",
|
||||
});
|
||||
});
|
||||
|
||||
it("should send layout Group correctly", async () => {
|
||||
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
|
||||
defaultDispatcher.dispatch(
|
||||
{
|
||||
action: Action.SettingUpdated,
|
||||
settingName: "layout",
|
||||
},
|
||||
true,
|
||||
);
|
||||
analytics.trackEvent<ITestEvent>({
|
||||
eventName: "TestEvents",
|
||||
});
|
||||
expect(vi.mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
|
||||
WebLayout: "Group",
|
||||
});
|
||||
});
|
||||
|
||||
it("should send layout Compact correctly", async () => {
|
||||
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
|
||||
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, true);
|
||||
defaultDispatcher.dispatch(
|
||||
{
|
||||
action: Action.SettingUpdated,
|
||||
settingName: "useCompactLayout",
|
||||
},
|
||||
true,
|
||||
);
|
||||
analytics.trackEvent<ITestEvent>({
|
||||
eventName: "TestEvents",
|
||||
});
|
||||
console.log(vi.mocked(fakePosthog).capture.mock.calls[0]);
|
||||
expect(vi.mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
|
||||
WebLayout: "Compact",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("UrlPreviews", () => {
|
||||
let analytics: PosthogAnalytics;
|
||||
|
||||
beforeEach(() => {
|
||||
SdkConfig.put({
|
||||
brand: "Testing",
|
||||
posthog: {
|
||||
project_api_key: "foo",
|
||||
api_host: "bar",
|
||||
},
|
||||
});
|
||||
|
||||
analytics = new PosthogAnalytics(fakePosthog);
|
||||
analytics.setAnonymity(Anonymity.Pseudonymous);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SdkConfig.reset();
|
||||
});
|
||||
|
||||
it("should set UrlPreviewsEnabled on change", async () => {
|
||||
defaultDispatcher.dispatch(
|
||||
{
|
||||
action: Action.SettingUpdated,
|
||||
settingName: "urlPreviewsEnabled",
|
||||
newValue: true,
|
||||
},
|
||||
true,
|
||||
);
|
||||
analytics.trackEvent<ITestEvent>({
|
||||
eventName: "TestEvents",
|
||||
});
|
||||
expect(vi.mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
|
||||
URLPreviewsEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CryptoSdk", () => {
|
||||
let analytics: PosthogAnalytics;
|
||||
const getFakeClient = (): MatrixClient =>
|
||||
({
|
||||
getCrypto: vi.fn(),
|
||||
setAccountData: vi.fn(),
|
||||
// just fake return an `im.vector.analytics` content
|
||||
getAccountDataFromServer: vi.fn().mockReturnValue({
|
||||
id: "0000000",
|
||||
pseudonymousAnalyticsOptIn: true,
|
||||
}),
|
||||
}) as unknown as MatrixClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
SdkConfig.put({
|
||||
brand: "Testing",
|
||||
posthog: {
|
||||
project_api_key: "foo",
|
||||
api_host: "bar",
|
||||
},
|
||||
});
|
||||
|
||||
analytics = new PosthogAnalytics(fakePosthog);
|
||||
});
|
||||
|
||||
// `updateAnonymityFromSettings` is called On page load / login / account data change.
|
||||
// We manually call it so we can test the behaviour.
|
||||
async function simulateLogin(rustBackend: boolean, pseudonymous = true) {
|
||||
// To simulate a switch we call updateAnonymityFromSettings.
|
||||
// As per documentation this function is called On login.
|
||||
const mockClient = getFakeClient();
|
||||
vi.mocked(mockClient.getCrypto).mockReturnValue({
|
||||
getVersion: () => {
|
||||
return rustBackend ? "Rust SDK 0.6.0 (9c6b550), Vodozemac 0.5.0" : "Olm 3.2.0";
|
||||
},
|
||||
} as unknown as CryptoApi);
|
||||
await analytics.updateAnonymityFromSettings(mockClient, pseudonymous);
|
||||
}
|
||||
|
||||
it("should send rust cryptoSDK superProperty correctly", async () => {
|
||||
analytics.setAnonymity(Anonymity.Pseudonymous);
|
||||
|
||||
await simulateLogin(false);
|
||||
|
||||
expect(vi.mocked(fakePosthog).register.mock.lastCall![0]["cryptoSDK"]).toStrictEqual("Legacy");
|
||||
});
|
||||
|
||||
it("should send Legacy cryptoSDK superProperty correctly", async () => {
|
||||
analytics.setAnonymity(Anonymity.Pseudonymous);
|
||||
|
||||
await simulateLogin(false);
|
||||
|
||||
// Super Properties are properties associated with events that are set once and then sent with every capture call.
|
||||
// They are set using posthog.register
|
||||
expect(vi.mocked(fakePosthog).register.mock.lastCall![0]["cryptoSDK"]).toStrictEqual("Legacy");
|
||||
});
|
||||
|
||||
it("should send cryptoSDK superProperty when enabling analytics", async () => {
|
||||
analytics.setAnonymity(Anonymity.Disabled);
|
||||
|
||||
await simulateLogin(true, false);
|
||||
|
||||
// This initial call is due to the call to register platformSuperProperties
|
||||
// The important thing is that the cryptoSDK superProperty is not set.
|
||||
expect(vi.mocked(fakePosthog).register.mock.lastCall![0]).toStrictEqual({});
|
||||
|
||||
// switching to pseudonymous should ensure that the cryptoSDK superProperty is set correctly
|
||||
analytics.setAnonymity(Anonymity.Pseudonymous);
|
||||
// Super Properties are properties associated with events that are set once and then sent with every capture call.
|
||||
// They are set using posthog.register
|
||||
expect(vi.mocked(fakePosthog).register.mock.lastCall![0]["cryptoSDK"]).toStrictEqual("Rust");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { vi, describe, it, expect, afterEach } from "vitest";
|
||||
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import PosthogTrackers from "./PosthogTrackers";
|
||||
|
||||
describe("PosthogTrackers", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("tracks URL Previews", () => {
|
||||
vi.spyOn(PosthogAnalytics.instance, "trackEvent");
|
||||
const tracker = new PosthogTrackers();
|
||||
tracker.trackUrlPreview("$123456", false, [
|
||||
{
|
||||
image: {},
|
||||
},
|
||||
]);
|
||||
tracker.trackUrlPreview("$123456", false, [{}]);
|
||||
// Ignores subsequent calls.
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
|
||||
eventName: "UrlPreviewRendered",
|
||||
previewKind: "LegacyCard",
|
||||
hasThumbnail: true,
|
||||
previewCount: 1,
|
||||
encryptedRoom: false,
|
||||
});
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022, 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, type Mocked } from "vitest";
|
||||
import {
|
||||
PushRuleActionName,
|
||||
TweakName,
|
||||
NotificationCountType,
|
||||
Room,
|
||||
EventStatus,
|
||||
EventType,
|
||||
type MatrixEvent,
|
||||
PendingEventOrdering,
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { mkEvent, mkRoom, mkRoomMember, muteRoom, stubClient, upsertRoomStateEvents } from "test-utils";
|
||||
import { mkThread } from "test-utils/threads";
|
||||
|
||||
import {
|
||||
getRoomNotifsState,
|
||||
RoomNotifState,
|
||||
getUnreadNotificationCount,
|
||||
determineUnreadState,
|
||||
getUnsentMessages,
|
||||
} from "./RoomNotifs";
|
||||
import { NotificationLevel } from "./stores/notifications/NotificationLevel";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
|
||||
describe("getUnsentMessages", () => {
|
||||
const ROOM_ID = "!roomId";
|
||||
let room: Room;
|
||||
let event: MatrixEvent;
|
||||
let client: MatrixClient;
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
room = new Room(ROOM_ID, client, client.getUserId()!, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
event = mkEvent({
|
||||
event: true,
|
||||
type: "m.room.message",
|
||||
user: "@user1:server",
|
||||
room: "!room1:server",
|
||||
content: {},
|
||||
});
|
||||
event.status = EventStatus.NOT_SENT;
|
||||
});
|
||||
|
||||
it("returns no unsent messages", () => {
|
||||
expect(getUnsentMessages(room)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("checks the event status", () => {
|
||||
room.addPendingEvent(event, "123");
|
||||
|
||||
expect(getUnsentMessages(room)).toHaveLength(1);
|
||||
event.status = EventStatus.SENT;
|
||||
|
||||
expect(getUnsentMessages(room)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("only returns events related to a thread", () => {
|
||||
room.addPendingEvent(event, "123");
|
||||
|
||||
const { rootEvent, events } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: "@alice:example.org",
|
||||
participantUserIds: ["@alice:example.org"],
|
||||
length: 2,
|
||||
});
|
||||
rootEvent.status = EventStatus.NOT_SENT;
|
||||
room.addPendingEvent(rootEvent, rootEvent.getId()!);
|
||||
for (const event of events) {
|
||||
event.status = EventStatus.NOT_SENT;
|
||||
room.addPendingEvent(event, Date.now() + Math.random() + "");
|
||||
}
|
||||
|
||||
const pendingEvents = getUnsentMessages(room, rootEvent.getId());
|
||||
|
||||
expect(pendingEvents[0].threadRootId).toBe(rootEvent.getId());
|
||||
expect(pendingEvents[1].threadRootId).toBe(rootEvent.getId());
|
||||
expect(pendingEvents[2].threadRootId).toBe(rootEvent.getId());
|
||||
|
||||
// Filters out the non thread events
|
||||
expect(pendingEvents.every((ev) => ev.getId() !== event.getId())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RoomNotifs test", () => {
|
||||
let client: Mocked<MatrixClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient() as Mocked<MatrixClient>;
|
||||
});
|
||||
|
||||
it("getRoomNotifsState handles rules with no conditions", () => {
|
||||
vi.mocked(client).pushRules = {
|
||||
global: {
|
||||
override: [
|
||||
{
|
||||
rule_id: "!roomId:server",
|
||||
enabled: true,
|
||||
default: false,
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(getRoomNotifsState(client, "!roomId:server")).toBe(null);
|
||||
});
|
||||
|
||||
it("getRoomNotifsState handles guest users", () => {
|
||||
vi.mocked(client).isGuest.mockReturnValue(true);
|
||||
expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.AllMessages);
|
||||
});
|
||||
|
||||
it("getRoomNotifsState handles mute state", () => {
|
||||
const room = mkRoom(client, "!roomId:server");
|
||||
muteRoom(room);
|
||||
expect(getRoomNotifsState(client, room.roomId)).toBe(RoomNotifState.Mute);
|
||||
});
|
||||
|
||||
it("getRoomNotifsState handles mute state for legacy DontNotify action", () => {
|
||||
const room = mkRoom(client, "!roomId:server");
|
||||
muteRoom(room);
|
||||
client.pushRules!.global.override![0]!.actions = [PushRuleActionName.DontNotify];
|
||||
expect(getRoomNotifsState(client, room.roomId)).toBe(RoomNotifState.Mute);
|
||||
});
|
||||
|
||||
it("getRoomNotifsState handles mentions only", () => {
|
||||
(client as any).getRoomPushRule = () => ({
|
||||
rule_id: "!roomId:server",
|
||||
enabled: true,
|
||||
default: false,
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
});
|
||||
expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.MentionsOnly);
|
||||
});
|
||||
|
||||
it("getRoomNotifsState handles noisy", () => {
|
||||
(client as any).getRoomPushRule = () => ({
|
||||
rule_id: "!roomId:server",
|
||||
enabled: true,
|
||||
default: false,
|
||||
actions: [{ set_tweak: TweakName.Sound, value: "default" }],
|
||||
});
|
||||
expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.AllMessagesLoud);
|
||||
});
|
||||
|
||||
describe("getUnreadNotificationCount", () => {
|
||||
const ROOM_ID = "!roomId:example.org";
|
||||
const THREAD_ID = "$threadId";
|
||||
|
||||
let room: Room;
|
||||
beforeEach(() => {
|
||||
room = new Room(ROOM_ID, client, client.getUserId()!);
|
||||
});
|
||||
|
||||
it("counts room notification type", () => {
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(0);
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts notifications type", () => {
|
||||
room.setUnreadNotificationCount(NotificationCountType.Total, 2);
|
||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
|
||||
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(2);
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(1);
|
||||
});
|
||||
|
||||
describe("when there is a room predecessor", () => {
|
||||
const OLD_ROOM_ID = "!oldRoomId:example.org";
|
||||
const mkCreateEvent = (predecessorId?: string): MatrixEvent => {
|
||||
return mkEvent({
|
||||
event: true,
|
||||
type: "m.room.create",
|
||||
room: ROOM_ID,
|
||||
user: "@zoe:localhost",
|
||||
content: {
|
||||
...(predecessorId ? { predecessor: { room_id: predecessorId, event_id: "$someevent" } } : {}),
|
||||
creator: "@zoe:localhost",
|
||||
room_version: "5",
|
||||
},
|
||||
ts: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
const mkPredecessorEvent = (predecessorId: string): MatrixEvent => {
|
||||
return mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomPredecessor,
|
||||
room: ROOM_ID,
|
||||
user: "@zoe:localhost",
|
||||
skey: "",
|
||||
content: {
|
||||
predecessor_room_id: predecessorId,
|
||||
},
|
||||
ts: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
const itShouldCountPredecessorHighlightWhenThereIsAPredecessorInTheCreateEvent = (): void => {
|
||||
it("and there is a predecessor in the create event, it should count predecessor highlight", () => {
|
||||
room.addLiveEvents([mkCreateEvent(OLD_ROOM_ID)], { addToState: true });
|
||||
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(8);
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(7);
|
||||
});
|
||||
};
|
||||
|
||||
const itShouldCountPredecessorHighlightWhenThereIsAPredecessorEvent = (): void => {
|
||||
it("and there is a predecessor event, it should count predecessor highlight", () => {
|
||||
client.getVisibleRooms();
|
||||
room.addLiveEvents([mkCreateEvent(OLD_ROOM_ID)], { addToState: true });
|
||||
upsertRoomStateEvents(room, [mkPredecessorEvent(OLD_ROOM_ID)]);
|
||||
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(8);
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(7);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
room.setUnreadNotificationCount(NotificationCountType.Total, 2);
|
||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
|
||||
|
||||
const oldRoom = new Room(OLD_ROOM_ID, client, client.getUserId()!);
|
||||
oldRoom.setUnreadNotificationCount(NotificationCountType.Total, 10);
|
||||
oldRoom.setUnreadNotificationCount(NotificationCountType.Highlight, 6);
|
||||
|
||||
client.getRoom.mockImplementation((roomId: string | undefined): Room | null => {
|
||||
if (roomId === room.roomId) return room;
|
||||
if (roomId === OLD_ROOM_ID) return oldRoom;
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
describe("and dynamic room predecessors are disabled", () => {
|
||||
itShouldCountPredecessorHighlightWhenThereIsAPredecessorInTheCreateEvent();
|
||||
itShouldCountPredecessorHighlightWhenThereIsAPredecessorEvent();
|
||||
|
||||
it("and there is only a predecessor event, it should not count predecessor highlight", () => {
|
||||
room.addLiveEvents([mkCreateEvent()], { addToState: true });
|
||||
upsertRoomStateEvents(room, [mkPredecessorEvent(OLD_ROOM_ID)]);
|
||||
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(2);
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("and dynamic room predecessors are enabled", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(SettingsStore, "getValue").mockImplementation(
|
||||
(settingName) => settingName === "feature_dynamic_room_predecessors",
|
||||
);
|
||||
});
|
||||
|
||||
itShouldCountPredecessorHighlightWhenThereIsAPredecessorInTheCreateEvent();
|
||||
itShouldCountPredecessorHighlightWhenThereIsAPredecessorEvent();
|
||||
|
||||
it("and there is only a predecessor event, it should count predecessor highlight", () => {
|
||||
room.addLiveEvents([mkCreateEvent()], { addToState: true });
|
||||
upsertRoomStateEvents(room, [mkPredecessorEvent(OLD_ROOM_ID)]);
|
||||
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(8);
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(7);
|
||||
});
|
||||
|
||||
it("and there is an unknown room in the predecessor event, it should not count predecessor highlight", () => {
|
||||
room.addLiveEvents([mkCreateEvent()], { addToState: true });
|
||||
upsertRoomStateEvents(room, [mkPredecessorEvent("!unknon:example.com")]);
|
||||
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(2);
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("counts thread notification type", () => {
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false, THREAD_ID)).toBe(0);
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false, THREAD_ID)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts thread notifications type", () => {
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 2);
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1);
|
||||
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Total, false, THREAD_ID)).toBe(2);
|
||||
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false, THREAD_ID)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("determineUnreadState", () => {
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
room = new Room("!room-id:example.com", client, "@user:example.com", {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows nothing by default", async () => {
|
||||
const { level, symbol, count } = determineUnreadState(room);
|
||||
|
||||
expect(symbol).toBe(null);
|
||||
expect(level).toBe(NotificationLevel.None);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("indicates if there are unsent messages", async () => {
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
type: "m.message",
|
||||
user: "@user:example.org",
|
||||
content: {},
|
||||
});
|
||||
event.status = EventStatus.NOT_SENT;
|
||||
room.addPendingEvent(event, "txn");
|
||||
|
||||
const { level, symbol, count } = determineUnreadState(room);
|
||||
|
||||
expect(symbol).toBe("!");
|
||||
expect(level).toBe(NotificationLevel.Unsent);
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("indicates the user has been invited to a channel", async () => {
|
||||
room.updateMyMembership(KnownMembership.Invite);
|
||||
|
||||
const { level, symbol, count } = determineUnreadState(room);
|
||||
|
||||
expect(symbol).toBe("!");
|
||||
expect(level).toBe(NotificationLevel.Highlight);
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("indicates the user knock has been denied", async () => {
|
||||
vi.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
|
||||
return name === "feature_ask_to_join";
|
||||
});
|
||||
const roomMember = mkRoomMember(
|
||||
room.roomId,
|
||||
MatrixClientPeg.get()!.getSafeUserId(),
|
||||
KnownMembership.Leave,
|
||||
true,
|
||||
{
|
||||
membership: KnownMembership.Knock,
|
||||
},
|
||||
);
|
||||
vi.spyOn(room, "getMember").mockReturnValue(roomMember);
|
||||
const { level, symbol, count } = determineUnreadState(room);
|
||||
|
||||
expect(symbol).toBe("!");
|
||||
expect(level).toBe(NotificationLevel.Highlight);
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows nothing for muted channels", async () => {
|
||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, 99);
|
||||
room.setUnreadNotificationCount(NotificationCountType.Total, 99);
|
||||
muteRoom(room);
|
||||
|
||||
const { level, count } = determineUnreadState(room);
|
||||
|
||||
expect(level).toBe(NotificationLevel.None);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the correct number of unreads", async () => {
|
||||
room.setUnreadNotificationCount(NotificationCountType.Total, 999);
|
||||
|
||||
const { level, count } = determineUnreadState(room);
|
||||
|
||||
expect(level).toBe(NotificationLevel.Notification);
|
||||
expect(count).toBe(999);
|
||||
});
|
||||
|
||||
it("uses the correct number of highlights", async () => {
|
||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, 888);
|
||||
|
||||
const { level, count } = determineUnreadState(room);
|
||||
|
||||
expect(level).toBe(NotificationLevel.Highlight);
|
||||
expect(count).toBe(888);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import { EventType, type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { mkEvent, stubClient } from "test-utils";
|
||||
|
||||
import { setDMRoom } from "./Rooms";
|
||||
|
||||
describe("setDMRoom", () => {
|
||||
const userId1 = "@user1:example.com";
|
||||
const userId2 = "@user2:example.com";
|
||||
const userId3 = "@user3:example.com";
|
||||
const roomId1 = "!room1:example.com";
|
||||
const roomId2 = "!room2:example.com";
|
||||
const roomId3 = "!room3:example.com";
|
||||
const roomId4 = "!room4:example.com";
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = vi.mocked(stubClient());
|
||||
client.getAccountData = vi.fn().mockImplementation((eventType: string): MatrixEvent | undefined => {
|
||||
if (eventType === EventType.Direct) {
|
||||
return mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
[userId1]: [roomId1, roomId2],
|
||||
[userId2]: [roomId3],
|
||||
},
|
||||
type: EventType.Direct,
|
||||
user: client.getSafeUserId(),
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe("when logged in as a guest and marking a room as DM", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(client.isGuest).mockReturnValue(true);
|
||||
setDMRoom(client, roomId1, userId1);
|
||||
});
|
||||
|
||||
it("should not update the account data", () => {
|
||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when adding a new room to an existing DM relation", () => {
|
||||
beforeEach(() => {
|
||||
setDMRoom(client, roomId4, userId1);
|
||||
});
|
||||
|
||||
it("should update the account data accordingly", () => {
|
||||
expect(client.setAccountData).toHaveBeenCalledWith(EventType.Direct, {
|
||||
[userId1]: [roomId1, roomId2, roomId4],
|
||||
[userId2]: [roomId3],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when adding a new DM room", () => {
|
||||
beforeEach(() => {
|
||||
setDMRoom(client, roomId4, userId3);
|
||||
});
|
||||
|
||||
it("should update the account data accordingly", () => {
|
||||
expect(client.setAccountData).toHaveBeenCalledWith(EventType.Direct, {
|
||||
[userId1]: [roomId1, roomId2],
|
||||
[userId2]: [roomId3],
|
||||
[userId3]: [roomId4],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when removing an existing DM", () => {
|
||||
beforeEach(() => {
|
||||
setDMRoom(client, roomId1, null);
|
||||
});
|
||||
|
||||
it("should update the account data accordingly", () => {
|
||||
expect(client.setAccountData).toHaveBeenCalledWith(EventType.Direct, {
|
||||
[userId1]: [roomId2],
|
||||
[userId2]: [roomId3],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the direct event is undefined", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(client.getAccountData).mockReturnValue(undefined);
|
||||
setDMRoom(client, roomId1, userId1);
|
||||
});
|
||||
|
||||
it("should update the account data accordingly", () => {
|
||||
expect(client.setAccountData).toHaveBeenCalledWith(EventType.Direct, {
|
||||
[userId1]: [roomId1],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the current content is undefined", () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
vi.mocked(client.getAccountData).mockReturnValue({
|
||||
getContent: vi.fn(),
|
||||
});
|
||||
setDMRoom(client, roomId1, userId1);
|
||||
});
|
||||
|
||||
it("should update the account data accordingly", () => {
|
||||
expect(client.setAccountData).toHaveBeenCalledWith(EventType.Direct, {
|
||||
[userId1]: [roomId1],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import fetchMock from "@fetch-mock/vitest";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { stubClient } from "test-utils";
|
||||
|
||||
import ScalarAuthClient from "./ScalarAuthClient";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import { WidgetType } from "./widgets/WidgetType";
|
||||
|
||||
describe("ScalarAuthClient", function () {
|
||||
const apiUrl = "https://test.com/api";
|
||||
const uiUrl = "https:/test.com/app";
|
||||
const tokenObject = {
|
||||
access_token: "token",
|
||||
token_type: "Bearer",
|
||||
matrix_server_name: "localhost",
|
||||
expires_in: 999,
|
||||
};
|
||||
|
||||
let client: MatrixClient;
|
||||
beforeEach(function () {
|
||||
vi.clearAllMocks();
|
||||
client = stubClient();
|
||||
});
|
||||
|
||||
it("should request a new token if the old one fails", async function () {
|
||||
const sac = new ScalarAuthClient(apiUrl + 0, uiUrl);
|
||||
|
||||
fetchMock.get("https://test.com/api0/account?scalar_token=brokentoken&v=1.1", {
|
||||
body: { message: "Invalid token" },
|
||||
});
|
||||
|
||||
fetchMock.get("https://test.com/api0/account?scalar_token=wokentoken&v=1.1", {
|
||||
body: { user_id: client.getUserId() },
|
||||
});
|
||||
|
||||
client.getOpenIdToken = vi.fn().mockResolvedValue(tokenObject);
|
||||
|
||||
sac.exchangeForScalarToken = vi.fn((arg) => {
|
||||
return Promise.resolve(arg === tokenObject ? "wokentoken" : "othertoken");
|
||||
});
|
||||
|
||||
await sac.connect();
|
||||
|
||||
expect(sac.exchangeForScalarToken).toHaveBeenCalledWith(tokenObject);
|
||||
expect(sac.hasCredentials).toBeTruthy();
|
||||
// @ts-ignore private property
|
||||
expect(sac.scalarToken).toEqual("wokentoken");
|
||||
});
|
||||
|
||||
describe("exchangeForScalarToken", () => {
|
||||
it("should return `scalar_token` from API /register", async () => {
|
||||
const sac = new ScalarAuthClient(apiUrl + 1, uiUrl);
|
||||
|
||||
fetchMock.postOnce("https://test.com/api1/register?v=1.1", {
|
||||
body: { scalar_token: "stoken" },
|
||||
});
|
||||
|
||||
await expect(sac.exchangeForScalarToken(tokenObject)).resolves.toBe("stoken");
|
||||
});
|
||||
|
||||
it("should throw upon non-20x code", async () => {
|
||||
const sac = new ScalarAuthClient(apiUrl + 2, uiUrl);
|
||||
|
||||
fetchMock.postOnce("https://test.com/api2/register?v=1.1", {
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await expect(sac.exchangeForScalarToken(tokenObject)).rejects.toThrow("Scalar request failed: 500");
|
||||
});
|
||||
|
||||
it("should throw if scalar_token is missing in response", async () => {
|
||||
const sac = new ScalarAuthClient(apiUrl + 3, uiUrl);
|
||||
|
||||
fetchMock.postOnce("https://test.com/api3/register?v=1.1", {
|
||||
body: {},
|
||||
});
|
||||
|
||||
await expect(sac.exchangeForScalarToken(tokenObject)).rejects.toThrow("Missing scalar_token in response");
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerForToken", () => {
|
||||
it("should call `termsInteractionCallback` upon M_TERMS_NOT_SIGNED error", async () => {
|
||||
const sac = new ScalarAuthClient(apiUrl + 4, uiUrl);
|
||||
const termsInteractionCallback = vi.fn();
|
||||
sac.setTermsInteractionCallback(termsInteractionCallback);
|
||||
fetchMock.get("https://test.com/api4/account?scalar_token=testtoken1&v=1.1", {
|
||||
body: { errcode: "M_TERMS_NOT_SIGNED" },
|
||||
});
|
||||
sac.exchangeForScalarToken = vi.fn(() => Promise.resolve("testtoken1"));
|
||||
vi.mocked(client.getTerms).mockResolvedValue({ policies: {} });
|
||||
|
||||
await expect(sac.registerForToken()).resolves.toBe("testtoken1");
|
||||
});
|
||||
|
||||
it("should throw upon non-20x code", async () => {
|
||||
const sac = new ScalarAuthClient(apiUrl + 5, uiUrl);
|
||||
fetchMock.get("https://test.com/api5/account?scalar_token=testtoken2&v=1.1", {
|
||||
body: { errcode: "SERVER_IS_SAD" },
|
||||
status: 500,
|
||||
});
|
||||
sac.exchangeForScalarToken = vi.fn(() => Promise.resolve("testtoken2"));
|
||||
|
||||
await expect(sac.registerForToken()).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
it("should throw if user_id is missing from response", async () => {
|
||||
const sac = new ScalarAuthClient(apiUrl + 6, uiUrl);
|
||||
fetchMock.get("https://test.com/api6/account?scalar_token=testtoken3&v=1.1", {
|
||||
body: {},
|
||||
});
|
||||
sac.exchangeForScalarToken = vi.fn(() => Promise.resolve("testtoken3"));
|
||||
|
||||
await expect(sac.registerForToken()).rejects.toThrow("Missing user_id in response");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScalarPageTitle", () => {
|
||||
let sac: ScalarAuthClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
SdkConfig.put({
|
||||
integrations_rest_url: apiUrl + 7,
|
||||
integrations_ui_url: uiUrl,
|
||||
});
|
||||
|
||||
window.localStorage.setItem("mx_scalar_token_at_https://test.com/api7", "wokentoken1");
|
||||
fetchMock.get("https://test.com/api7/account?scalar_token=wokentoken1&v=1.1", {
|
||||
body: { user_id: client.getUserId() },
|
||||
});
|
||||
|
||||
sac = new ScalarAuthClient(apiUrl + 7, uiUrl);
|
||||
await sac.connect();
|
||||
});
|
||||
|
||||
it("should return `cached_title` from API /widgets/title_lookup", async () => {
|
||||
const url = "google.com";
|
||||
fetchMock.get("https://test.com/api7/widgets/title_lookup?scalar_token=wokentoken1&curl=" + url, {
|
||||
body: {
|
||||
page_title_cache_item: {
|
||||
cached_title: "Google",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(sac.getScalarPageTitle(url)).resolves.toBe("Google");
|
||||
});
|
||||
|
||||
it("should throw upon non-20x code", async () => {
|
||||
const url = "yahoo.com";
|
||||
fetchMock.get("https://test.com/api7/widgets/title_lookup?scalar_token=wokentoken1&curl=" + url, {
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await expect(sac.getScalarPageTitle(url)).rejects.toThrow("Scalar request failed: 500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disableWidgetAssets", () => {
|
||||
let sac: ScalarAuthClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
SdkConfig.put({
|
||||
integrations_rest_url: apiUrl + 8,
|
||||
integrations_ui_url: uiUrl,
|
||||
});
|
||||
|
||||
window.localStorage.setItem("mx_scalar_token_at_https://test.com/api8", "wokentoken1");
|
||||
fetchMock.get("https://test.com/api8/account?scalar_token=wokentoken1&v=1.1", {
|
||||
body: { user_id: client.getUserId() },
|
||||
});
|
||||
|
||||
sac = new ScalarAuthClient(apiUrl + 8, uiUrl);
|
||||
await sac.connect();
|
||||
});
|
||||
|
||||
it("should send state=disable to API /widgets/set_assets_state", async () => {
|
||||
fetchMock.get(
|
||||
"https://test.com/api8/widgets/set_assets_state?scalar_token=wokentoken1" +
|
||||
"&widget_type=m.custom&widget_id=id1&state=disable",
|
||||
{
|
||||
body: "OK",
|
||||
},
|
||||
);
|
||||
|
||||
await expect(sac.disableWidgetAssets(WidgetType.CUSTOM, "id1")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should throw upon non-20x code", async () => {
|
||||
fetchMock.get(
|
||||
"https://test.com/api8/widgets/set_assets_state?scalar_token=wokentoken1" +
|
||||
"&widget_type=m.custom&widget_id=id2&state=disable",
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
|
||||
await expect(sac.disableWidgetAssets(WidgetType.CUSTOM, "id2")).rejects.toThrow(
|
||||
"Scalar request failed: 500",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 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 { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import SdkConfig, { DEFAULTS } from "./SdkConfig";
|
||||
|
||||
describe("SdkConfig", () => {
|
||||
describe("with default values", () => {
|
||||
it("should return the default config", () => {
|
||||
expect(SdkConfig.get()).toEqual(DEFAULTS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with custom values", () => {
|
||||
beforeEach(() => {
|
||||
SdkConfig.put({
|
||||
feedback: {
|
||||
existing_issues_url: "https://existing",
|
||||
} as any,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the custom config", () => {
|
||||
const customConfig = JSON.parse(JSON.stringify(DEFAULTS));
|
||||
customConfig.feedback.existing_issues_url = "https://existing";
|
||||
expect(SdkConfig.get()).toEqual(customConfig);
|
||||
});
|
||||
|
||||
it("should allow overriding individual fields of sub-objects", () => {
|
||||
const feedback = SdkConfig.getObject("feedback");
|
||||
expect(feedback.get("existing_issues_url")).toMatchInlineSnapshot(`"https://existing"`);
|
||||
expect(feedback.get("new_issue_url")).toMatchInlineSnapshot(
|
||||
`"https://github.com/vector-im/element-web/issues/new/choose"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { getBrowserSupport, checkBrowserSupport, LOCAL_STORAGE_KEY } from "./SupportedBrowser";
|
||||
import ToastStore from "./stores/ToastStore";
|
||||
import GenericToast from "./components/views/toasts/GenericToast";
|
||||
|
||||
vi.mock("matrix-js-sdk/src/logger");
|
||||
|
||||
describe("SupportedBrowser", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
localStorage.clear();
|
||||
getBrowserSupport.clear();
|
||||
});
|
||||
|
||||
const testUserAgentFactory =
|
||||
(expectedWarning?: string) =>
|
||||
async (userAgent: string): Promise<void> => {
|
||||
const toastSpy = vi.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast");
|
||||
const warnLogSpy = vi.spyOn(logger, "warn");
|
||||
Object.defineProperty(window, "navigator", { value: { userAgent: userAgent }, writable: true });
|
||||
checkBrowserSupport();
|
||||
if (expectedWarning) {
|
||||
expect(warnLogSpy).toHaveBeenCalledWith(expectedWarning, expect.any(String));
|
||||
expect(toastSpy).toHaveBeenCalled();
|
||||
} else {
|
||||
expect(warnLogSpy).not.toHaveBeenCalled();
|
||||
expect(toastSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
};
|
||||
|
||||
it.each([
|
||||
// Safari on iOS
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
|
||||
// Firefox on iOS
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/128.0 Mobile/15E148 Safari/605.1.15",
|
||||
// Opera on Samsung
|
||||
"Mozilla/5.0 (Linux; Android 10; SM-G970F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.64 Mobile Safari/537.36 OPR/76.2.4027.73374",
|
||||
])("should warn for mobile browsers", testUserAgentFactory("Browser unsupported, unsupported device type"));
|
||||
|
||||
it.each([
|
||||
// Chrome on Chrome OS
|
||||
"Mozilla/5.0 (X11; CrOS x86_64 15633.69.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.212 Safari/537.36",
|
||||
// Opera on Windows
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 OPR/113.0.0.0",
|
||||
// Vivaldi on Linux
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Vivaldi/6.8.3381.48",
|
||||
// IE11 on Windows 10
|
||||
"Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko",
|
||||
// Firefox 115 on macOS
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_4_5; rv:115.0) Gecko/20000101 Firefox/115.0",
|
||||
])(
|
||||
"should warn for unsupported desktop browsers",
|
||||
testUserAgentFactory("Browser unsupported, unsupported user agent"),
|
||||
);
|
||||
|
||||
// Grabbed from https://www.whatismybrowser.com/guides/the-latest-user-agent/
|
||||
it.each([
|
||||
// Safari 26.0 on macOS
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
|
||||
// Latest Firefox on macOS Sonoma
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15.7; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
// Latest Edge on Windows
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.3856.84",
|
||||
// Latest Edge on macOS
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.3856.84",
|
||||
// Latest Firefox on Windows
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
// Latest Firefox on Linux
|
||||
"Mozilla/5.0 (X11; Linux i686; rv:150.0) Gecko/20100101 Firefox/150.0",
|
||||
// Latest Chrome on Windows
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
|
||||
])("should not warn for supported browsers", testUserAgentFactory());
|
||||
|
||||
it.each([
|
||||
// Element Nightly on macOS
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2024072501 Chrome/126.0.6478.127 Electron/31.2.1 Safari/537.36",
|
||||
])("should not warn for Element Desktop", testUserAgentFactory());
|
||||
|
||||
it.each(["AppleTV11,1/11.1"])(
|
||||
"should handle unknown user agent sanely",
|
||||
testUserAgentFactory("Browser unsupported, unknown client"),
|
||||
);
|
||||
|
||||
it("should not warn for unsupported browser if user accepted already", async () => {
|
||||
const toastSpy = vi.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast");
|
||||
const warnLogSpy = vi.spyOn(logger, "warn");
|
||||
const userAgent =
|
||||
"Mozilla/5.0 (X11; CrOS x86_64 15633.69.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.212 Safari/537.36";
|
||||
Object.defineProperty(window, "navigator", { value: { userAgent: userAgent }, writable: true });
|
||||
|
||||
checkBrowserSupport();
|
||||
expect(warnLogSpy).toHaveBeenCalledWith("Browser unsupported, unsupported user agent", expect.any(String));
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
component: GenericToast,
|
||||
title: "Element does not support this browser",
|
||||
}),
|
||||
);
|
||||
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, String(true));
|
||||
toastSpy.mockClear();
|
||||
warnLogSpy.mockClear();
|
||||
|
||||
getBrowserSupport.clear();
|
||||
checkBrowserSupport();
|
||||
expect(warnLogSpy).toHaveBeenCalledWith("Browser unsupported, but user has previously accepted");
|
||||
expect(toastSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import * as tzh from "./TimezoneHandler";
|
||||
|
||||
describe("TimezoneHandler", () => {
|
||||
it("should support setting a user timezone", async () => {
|
||||
const tz = "Europe/Paris";
|
||||
await tzh.setUserTimezone(tz);
|
||||
expect(tzh.getUserTimezone()).toEqual(tz);
|
||||
});
|
||||
it("Return undefined with an empty TZ", async () => {
|
||||
await tzh.setUserTimezone("");
|
||||
expect(tzh.getUserTimezone()).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Favicon > should clear a badge if called with a zero value 1`] = `
|
||||
[
|
||||
{
|
||||
"props": {
|
||||
"height": 144,
|
||||
"width": 144,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "clearRect",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"dHeight": 144,
|
||||
"dWidth": 144,
|
||||
"dx": 0,
|
||||
"dy": 0,
|
||||
"img": <img
|
||||
height="144"
|
||||
width="144"
|
||||
/>,
|
||||
"sHeight": 144,
|
||||
"sWidth": 144,
|
||||
"sx": 0,
|
||||
"sy": 0,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "drawImage",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"fillRule": "nonzero",
|
||||
"path": [
|
||||
{
|
||||
"props": {},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "beginPath",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"x": 72.72,
|
||||
"y": 57.6,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "moveTo",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"x": 100.79999999999998,
|
||||
"y": 57.6,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "lineTo",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"x": 143.99999999999997,
|
||||
"y": 100.80000000000001,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "lineTo",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"x": 44.64,
|
||||
"y": 144,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "lineTo",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"x": 1.4400000000000048,
|
||||
"y": 100.8,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "lineTo",
|
||||
},
|
||||
],
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "fill",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"path": [
|
||||
{
|
||||
"props": {},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "beginPath",
|
||||
},
|
||||
],
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "stroke",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"maxWidth": null,
|
||||
"text": "123",
|
||||
"x": 72,
|
||||
"y": 131,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "fillText",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"height": 144,
|
||||
"width": 144,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "clearRect",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"dHeight": 144,
|
||||
"dWidth": 144,
|
||||
"dx": 0,
|
||||
"dy": 0,
|
||||
"img": <img
|
||||
height="144"
|
||||
width="144"
|
||||
/>,
|
||||
"sHeight": 144,
|
||||
"sWidth": 144,
|
||||
"sx": 0,
|
||||
"sy": 0,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "drawImage",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`Favicon > should draw a badge if called with a non-zero value 1`] = `
|
||||
[
|
||||
{
|
||||
"props": {
|
||||
"height": 144,
|
||||
"width": 144,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "clearRect",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"dHeight": 144,
|
||||
"dWidth": 144,
|
||||
"dx": 0,
|
||||
"dy": 0,
|
||||
"img": <img
|
||||
height="144"
|
||||
width="144"
|
||||
/>,
|
||||
"sHeight": 144,
|
||||
"sWidth": 144,
|
||||
"sx": 0,
|
||||
"sy": 0,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "drawImage",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"fillRule": "nonzero",
|
||||
"path": [
|
||||
{
|
||||
"props": {},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "beginPath",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"x": 72.72,
|
||||
"y": 57.6,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "moveTo",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"x": 100.79999999999998,
|
||||
"y": 57.6,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "lineTo",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"x": 143.99999999999997,
|
||||
"y": 100.80000000000001,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "lineTo",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"x": 44.64,
|
||||
"y": 144,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "lineTo",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"x": 1.4400000000000048,
|
||||
"y": 100.8,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "lineTo",
|
||||
},
|
||||
],
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "fill",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"path": [
|
||||
{
|
||||
"props": {},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "beginPath",
|
||||
},
|
||||
],
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "stroke",
|
||||
},
|
||||
{
|
||||
"props": {
|
||||
"maxWidth": null,
|
||||
"text": "123",
|
||||
"x": 72,
|
||||
"y": 131,
|
||||
},
|
||||
"transform": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "fillText",
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import "vitest-canvas-mock";
|
||||
|
||||
import Favicon from "./favicon";
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
describe("Favicon", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.getElementsByTagName("head")[0]?.remove();
|
||||
const head = document.createElement("head");
|
||||
window.document.documentElement.prepend(head);
|
||||
});
|
||||
|
||||
it("should create a link element if one doesn't yet exist", () => {
|
||||
const favicon = new Favicon();
|
||||
expect(favicon).toBeTruthy();
|
||||
const link = window.document.querySelector("link")!;
|
||||
expect(link.rel).toContain("icon");
|
||||
});
|
||||
|
||||
it("should draw a badge if called with a non-zero value", () => {
|
||||
const favicon = new Favicon();
|
||||
favicon.badge(123);
|
||||
vi.runAllTimers();
|
||||
expect(favicon["context"].__getDrawCalls()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should clear a badge if called with a zero value", () => {
|
||||
const favicon = new Favicon();
|
||||
favicon.badge(123);
|
||||
vi.runAllTimers();
|
||||
favicon.badge(0);
|
||||
expect(favicon["context"].__getDrawCalls()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should recreate link element for firefox and opera", () => {
|
||||
window["InstallTrigger"] = {};
|
||||
window["opera"] = {};
|
||||
const favicon = new Favicon();
|
||||
const originalLink = window.document.querySelector("link");
|
||||
favicon.badge(123);
|
||||
vi.runAllTimers();
|
||||
const newLink = window.document.querySelector("link");
|
||||
expect(originalLink).not.toStrictEqual(newLink);
|
||||
});
|
||||
|
||||
it("should default to 144px", () => {
|
||||
const head = document.getElementsByTagName("head")[0];
|
||||
const link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
link.href = "favicon.png";
|
||||
head.appendChild(link);
|
||||
|
||||
const spy = vi.spyOn(document, "createElement");
|
||||
const favicon = new Favicon();
|
||||
vi.runAllTimers();
|
||||
|
||||
const img = spy.mock.results[0].value;
|
||||
img.onload();
|
||||
|
||||
expect(favicon["canvas"].height).toBe(144);
|
||||
});
|
||||
|
||||
it("should use the size of the last favicon", () => {
|
||||
const head = document.getElementsByTagName("head")[0];
|
||||
const link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
link.sizes = "512x512";
|
||||
link.href = "favicon.png";
|
||||
head.appendChild(link);
|
||||
|
||||
const spy = vi.spyOn(document, "createElement");
|
||||
const favicon = new Favicon();
|
||||
vi.runAllTimers();
|
||||
|
||||
const img = spy.mock.results[0].value;
|
||||
img.height = 512;
|
||||
img.onload();
|
||||
|
||||
expect(favicon["canvas"].height).toBe(512);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user