Prepare for repo merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-10-15 11:35:21 +01:00
parent 0f670b8dc0
commit b084ff2313
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
807 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,27 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { lerp } from "../../src/utils/AnimationUtils";
describe("lerp", () => {
it("correctly interpolates", () => {
expect(lerp(0, 100, 0.5)).toBe(50);
expect(lerp(50, 100, 0.5)).toBe(75);
expect(lerp(0, 1, 0.1)).toBe(0.1);
});
it("clamps the interpolant", () => {
expect(lerp(0, 100, 50)).toBe(100);
expect(lerp(0, 100, -50)).toBe(0);
});
it("handles negative numbers", () => {
expect(lerp(-100, 0, 0.5)).toBe(-50);
expect(lerp(100, -100, 0.5)).toBe(0);
});
});

View file

@ -0,0 +1,386 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { AutoDiscovery, AutoDiscoveryAction, ClientConfig } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import fetchMock from "fetch-mock-jest";
import AutoDiscoveryUtils from "../../src/utils/AutoDiscoveryUtils";
import { mockOpenIdConfiguration } from "../test-utils/oidc";
describe("AutoDiscoveryUtils", () => {
beforeEach(() => {
fetchMock.catch({
status: 404,
body: '{"errcode": "M_UNRECOGNIZED", "error": "Unrecognized request"}',
headers: { "content-type": "application/json" },
});
});
describe("buildValidatedConfigFromDiscovery()", () => {
const serverName = "my-server";
beforeEach(() => {
// don't litter console with expected errors
jest.spyOn(logger, "error")
.mockClear()
.mockImplementation(() => {});
});
afterAll(() => {
jest.spyOn(logger, "error").mockRestore();
});
const validIsConfig = {
"m.identity_server": {
state: AutoDiscoveryAction.SUCCESS,
base_url: "identity.com",
},
};
const validHsConfig = {
"m.homeserver": {
state: AutoDiscoveryAction.SUCCESS,
base_url: "https://matrix.org",
},
};
const expectedValidatedConfig = {
hsName: serverName,
hsNameIsDifferent: true,
hsUrl: "https://matrix.org",
isDefault: false,
isNameResolvable: true,
isUrl: "identity.com",
};
it("throws an error when discovery result is falsy", async () => {
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, undefined as any),
).rejects.toThrow("Unexpected error resolving homeserver configuration");
expect(logger.error).toHaveBeenCalled();
});
it("throws an error when discovery result does not include homeserver config", async () => {
const discoveryResult = {
...validIsConfig,
} as unknown as ClientConfig;
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Unexpected error resolving homeserver configuration");
expect(logger.error).toHaveBeenCalled();
});
it("throws an error when identity server config has fail error and recognised error string", async () => {
const discoveryResult = {
...validHsConfig,
"m.identity_server": {
state: AutoDiscoveryAction.FAIL_ERROR,
error: "GenericFailure",
},
};
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Unexpected error resolving identity server configuration");
expect(logger.error).toHaveBeenCalled();
});
it("throws an error when homeserver config has fail error and recognised error string", async () => {
const discoveryResult = {
...validIsConfig,
"m.homeserver": {
state: AutoDiscoveryAction.FAIL_ERROR,
error: AutoDiscovery.ERROR_INVALID_HOMESERVER,
},
};
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Homeserver URL does not appear to be a valid Matrix homeserver");
expect(logger.error).toHaveBeenCalled();
});
it("throws an error with fallback message identity server config has fail error", async () => {
const discoveryResult = {
...validHsConfig,
"m.identity_server": {
state: AutoDiscoveryAction.FAIL_ERROR,
},
};
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Unexpected error resolving identity server configuration");
});
it("throws an error when error is ERROR_INVALID_HOMESERVER", async () => {
const discoveryResult = {
...validIsConfig,
"m.homeserver": {
state: AutoDiscoveryAction.FAIL_ERROR,
error: AutoDiscovery.ERROR_INVALID_HOMESERVER,
},
};
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Homeserver URL does not appear to be a valid Matrix homeserver");
});
it("throws an error when homeserver base_url is falsy", async () => {
const discoveryResult = {
...validIsConfig,
"m.homeserver": {
state: AutoDiscoveryAction.SUCCESS,
base_url: "",
},
};
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Unexpected error resolving homeserver configuration");
expect(logger.error).toHaveBeenCalledWith("No homeserver URL configured");
});
it("throws an error when homeserver base_url is not a valid URL", async () => {
const discoveryResult = {
...validIsConfig,
"m.homeserver": {
state: AutoDiscoveryAction.SUCCESS,
base_url: "banana",
},
};
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).rejects.toThrow("Invalid URL: banana");
});
it("uses hs url hostname when serverName is falsy in args and config", async () => {
const discoveryResult = {
...validIsConfig,
...validHsConfig,
};
await expect(AutoDiscoveryUtils.buildValidatedConfigFromDiscovery("", discoveryResult)).resolves.toEqual({
...expectedValidatedConfig,
hsNameIsDifferent: false,
hsName: "matrix.org",
warning: null,
});
});
it("uses serverName from props", async () => {
const discoveryResult = {
...validIsConfig,
"m.homeserver": {
...validHsConfig["m.homeserver"],
server_name: "should not use this name",
},
};
const syntaxOnly = true;
await expect(
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult, syntaxOnly),
).resolves.toEqual({
...expectedValidatedConfig,
hsNameIsDifferent: true,
hsName: serverName,
warning: null,
});
});
it("ignores liveliness error when checking syntax only", async () => {
const discoveryResult = {
...validIsConfig,
"m.homeserver": {
...validHsConfig["m.homeserver"],
state: AutoDiscoveryAction.FAIL_ERROR,
error: AutoDiscovery.ERROR_INVALID_HOMESERVER,
},
};
const syntaxOnly = true;
await expect(
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult, syntaxOnly),
).resolves.toEqual({
...expectedValidatedConfig,
warning: "Homeserver URL does not appear to be a valid Matrix homeserver",
});
});
it("handles homeserver too old error", async () => {
const discoveryResult: ClientConfig = {
...validIsConfig,
"m.homeserver": {
state: AutoDiscoveryAction.FAIL_ERROR,
error: AutoDiscovery.ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION,
base_url: "https://matrix.org",
},
};
const syntaxOnly = true;
await expect(() =>
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult, syntaxOnly),
).rejects.toThrow(
"Your homeserver is too old and does not support the minimum API version required. Please contact your server owner, or upgrade your server.",
);
});
it("should validate delegated oidc auth", async () => {
const issuer = "https://auth.matrix.org/";
fetchMock.get(
`${validHsConfig["m.homeserver"].base_url}/_matrix/client/unstable/org.matrix.msc2965/auth_issuer`,
{
issuer,
},
);
fetchMock.get(`${issuer}.well-known/openid-configuration`, {
...mockOpenIdConfiguration(issuer),
"scopes_supported": ["openid", "email"],
"response_modes_supported": ["form_post", "query", "fragment"],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"none",
],
"token_endpoint_auth_signing_alg_values_supported": [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES256K",
],
"revocation_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"none",
],
"revocation_endpoint_auth_signing_alg_values_supported": [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES256K",
],
"introspection_endpoint": `${issuer}oauth2/introspect`,
"introspection_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"none",
],
"introspection_endpoint_auth_signing_alg_values_supported": [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES256K",
],
"userinfo_endpoint": `${issuer}oauth2/userinfo`,
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": [
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"PS256",
"PS384",
"PS512",
"ES256K",
],
"userinfo_signing_alg_values_supported": [
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"PS256",
"PS384",
"PS512",
"ES256K",
],
"display_values_supported": ["page"],
"claim_types_supported": ["normal"],
"claims_supported": ["iss", "sub", "aud", "iat", "exp", "nonce", "auth_time", "at_hash", "c_hash"],
"claims_parameter_supported": false,
"request_parameter_supported": false,
"request_uri_parameter_supported": false,
"prompt_values_supported": ["none", "login", "create"],
"device_authorization_endpoint": `${issuer}oauth2/device`,
"org.matrix.matrix-authentication-service.graphql_endpoint": `${issuer}graphql`,
"account_management_uri": `${issuer}account/`,
"account_management_actions_supported": [
"org.matrix.profile",
"org.matrix.sessions_list",
"org.matrix.session_view",
"org.matrix.session_end",
"org.matrix.cross_signing_reset",
],
});
fetchMock.get(`${issuer}jwks`, {
keys: [],
});
const discoveryResult = {
...validIsConfig,
...validHsConfig,
};
await expect(
AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult),
).resolves.toEqual({
...expectedValidatedConfig,
hsNameIsDifferent: true,
hsName: serverName,
delegatedAuthentication: expect.objectContaining({
accountManagementActionsSupported: [
"org.matrix.profile",
"org.matrix.sessions_list",
"org.matrix.session_view",
"org.matrix.session_end",
"org.matrix.cross_signing_reset",
],
accountManagementEndpoint: "https://auth.matrix.org/account/",
authorizationEndpoint: "https://auth.matrix.org/auth",
metadata: expect.objectContaining({
issuer,
}),
registrationEndpoint: "https://auth.matrix.org/registration",
signingKeys: [],
tokenEndpoint: "https://auth.matrix.org/token",
}),
warning: null,
});
});
});
describe("authComponentStateForError", () => {
const error = new Error("TEST");
it("should return expected error for the registration page", () => {
expect(AutoDiscoveryUtils.authComponentStateForError(error, "register")).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,185 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked, Mocked } from "jest-mock";
import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent, EventType, IContent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import DMRoomMap from "../../src/utils/DMRoomMap";
import { mkEvent, stubClient } from "../test-utils";
describe("DMRoomMap", () => {
const roomId1 = "!room1:example.com";
const roomId2 = "!room2:example.com";
const roomId3 = "!room3:example.com";
const roomId4 = "!room4:example.com";
const validMDirectContent = {
"user@example.com": [roomId1, roomId2],
"@user:example.com": [roomId1, roomId3, roomId4],
"@user2:example.com": [] as string[],
} as IContent;
let client: Mocked<MatrixClient>;
let dmRoomMap: DMRoomMap;
const mkMDirectEvent = (content: any): MatrixEvent => {
return mkEvent({
event: true,
type: EventType.Direct,
user: client.getSafeUserId(),
content: content,
});
};
beforeEach(() => {
client = mocked(stubClient());
jest.spyOn(logger, "warn");
});
describe("when m.direct has valid content", () => {
beforeEach(() => {
client.getAccountData.mockReturnValue(mkMDirectEvent(validMDirectContent));
dmRoomMap = new DMRoomMap(client);
dmRoomMap.start();
});
it("getRoomIds should return the room Ids", () => {
expect(dmRoomMap.getRoomIds()).toEqual(new Set([roomId1, roomId2, roomId3, roomId4]));
});
describe("and there is an update with valid data", () => {
beforeEach(() => {
client.emit(
ClientEvent.AccountData,
mkMDirectEvent({
"@user:example.com": [roomId1, roomId3],
}),
);
});
it("getRoomIds should return the new room Ids", () => {
expect(dmRoomMap.getRoomIds()).toEqual(new Set([roomId1, roomId3]));
});
});
describe("and there is an update with invalid data", () => {
const partiallyInvalidContent = {
"@user1:example.com": [roomId1, roomId3],
"@user2:example.com": "room2, room3",
};
beforeEach(() => {
client.emit(ClientEvent.AccountData, mkMDirectEvent(partiallyInvalidContent));
});
it("getRoomIds should return the valid room Ids", () => {
expect(dmRoomMap.getRoomIds()).toEqual(new Set([roomId1, roomId3]));
});
it("should log the invalid content", () => {
expect(logger.warn).toHaveBeenCalledWith("Invalid m.direct content occurred", partiallyInvalidContent);
});
});
});
describe("when m.direct content contains the entire event", () => {
const mDirectContentContent = {
type: EventType.Direct,
content: validMDirectContent,
};
beforeEach(() => {
client.getAccountData.mockReturnValue(mkMDirectEvent(mDirectContentContent));
dmRoomMap = new DMRoomMap(client);
});
it("should log the invalid content", () => {
expect(logger.warn).toHaveBeenCalledWith("Invalid m.direct content occurred", mDirectContentContent);
});
it("getRoomIds should return an empty list", () => {
expect(dmRoomMap.getRoomIds()).toEqual(new Set([]));
});
});
describe("when partially crap m.direct content appears", () => {
const partiallyCrapContent = {
"hello": 23,
"@user1:example.com": [] as string[],
"@user2:example.com": [roomId1, roomId2],
"@user3:example.com": "room1, room2, room3",
"@user4:example.com": [roomId4],
};
beforeEach(() => {
client.getAccountData.mockReturnValue(mkMDirectEvent(partiallyCrapContent));
dmRoomMap = new DMRoomMap(client);
});
it("should log the invalid content", () => {
expect(logger.warn).toHaveBeenCalledWith("Invalid m.direct content occurred", partiallyCrapContent);
});
it("getRoomIds should only return the valid items", () => {
expect(dmRoomMap.getRoomIds()).toEqual(new Set([roomId1, roomId2, roomId4]));
});
});
describe("getUniqueRoomsWithIndividuals()", () => {
const bigRoom = {
roomId: "!bigRoom:server.org",
getInvitedAndJoinedMemberCount: jest.fn().mockReturnValue(5000),
} as unknown as Room;
const dmWithBob = {
roomId: "!dmWithBob:server.org",
getInvitedAndJoinedMemberCount: jest.fn().mockReturnValue(2),
} as unknown as Room;
const dmWithCharlie = {
roomId: "!dmWithCharlie:server.org",
getInvitedAndJoinedMemberCount: jest.fn().mockReturnValue(2),
} as unknown as Room;
const smallRoom = {
roomId: "!smallRoom:server.org",
getInvitedAndJoinedMemberCount: jest.fn().mockReturnValue(3),
} as unknown as Room;
const mDirectContent = {
"@bob:server.org": [bigRoom.roomId, dmWithBob.roomId, smallRoom.roomId],
"@charlie:server.org": [dmWithCharlie.roomId, smallRoom.roomId],
};
beforeEach(() => {
client.getAccountData.mockReturnValue(mkMDirectEvent(mDirectContent));
client.getRoom.mockImplementation(
(roomId: string) =>
[bigRoom, smallRoom, dmWithCharlie, dmWithBob].find((room) => room.roomId === roomId) ?? null,
);
});
it("returns an empty object when room map has not been populated", () => {
const instance = new DMRoomMap(client);
expect(instance.getUniqueRoomsWithIndividuals()).toEqual({});
});
it("returns map of users to rooms with 2 members", () => {
const dmRoomMap = new DMRoomMap(client);
dmRoomMap.start();
expect(dmRoomMap.getUniqueRoomsWithIndividuals()).toEqual({
"@bob:server.org": dmWithBob,
"@charlie:server.org": dmWithCharlie,
});
});
it("excludes rooms that are not found by matrixClient", () => {
client.getRoom.mockReset().mockReturnValue(null);
const dmRoomMap = new DMRoomMap(client);
dmRoomMap.start();
expect(dmRoomMap.getUniqueRoomsWithIndividuals()).toEqual({});
});
});
});

View file

@ -0,0 +1,392 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
formatSeconds,
formatRelativeTime,
formatDuration,
formatFullDateNoDayISO,
formatDateForInput,
formatTimeLeft,
formatPreciseDuration,
formatLocalDateShort,
getDaysArray,
getMonthsArray,
formatFullDateNoDayNoTime,
formatTime,
formatFullTime,
formatFullDate,
formatFullDateNoTime,
formatDate,
HOUR_MS,
MINUTE_MS,
DAY_MS,
} from "../../src/DateUtils";
import { REPEATABLE_DATE, mockIntlDateTimeFormat, unmockIntlDateTimeFormat } from "../test-utils";
import * as languageHandler from "../../src/languageHandler";
describe("getDaysArray", () => {
it("should return Sunday-Saturday in long mode", () => {
expect(getDaysArray("long")).toMatchInlineSnapshot(`
[
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
]
`);
});
it("should return Sun-Sat in short mode", () => {
expect(getDaysArray("short")).toMatchInlineSnapshot(`
[
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
]
`);
});
it("should return S-S in narrow mode", () => {
expect(getDaysArray("narrow")).toMatchInlineSnapshot(`
[
"S",
"M",
"T",
"W",
"T",
"F",
"S",
]
`);
});
});
describe("getMonthsArray", () => {
it("should return January-December in long mode", () => {
expect(getMonthsArray("long")).toMatchInlineSnapshot(`
[
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
`);
});
it("should return Jan-Dec in short mode", () => {
expect(getMonthsArray("short")).toMatchInlineSnapshot(`
[
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]
`);
});
it("should return J-D in narrow mode", () => {
expect(getMonthsArray("narrow")).toMatchInlineSnapshot(`
[
"J",
"F",
"M",
"A",
"M",
"J",
"J",
"A",
"S",
"O",
"N",
"D",
]
`);
});
it("should return 1-12 in numeric mode", () => {
expect(getMonthsArray("numeric")).toMatchInlineSnapshot(`
[
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
]
`);
});
it("should return 01-12 in 2-digit mode", () => {
expect(getMonthsArray("2-digit")).toMatchInlineSnapshot(`
[
"01",
"02",
"03",
"04",
"05",
"06",
"07",
"08",
"09",
"10",
"11",
"12",
]
`);
});
});
describe("formatDate", () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(REPEATABLE_DATE);
});
afterAll(() => {
jest.setSystemTime(jest.getRealSystemTime());
jest.useRealTimers();
});
it("should return time string if date is within same day", () => {
const date = new Date(REPEATABLE_DATE.getTime() + 2 * HOUR_MS + 12 * MINUTE_MS);
expect(formatDate(date, false, "en-GB")).toMatchInlineSnapshot(`"19:10"`);
});
it("should return time string with weekday if date is within last 6 days", () => {
const date = new Date(REPEATABLE_DATE.getTime() - 6 * DAY_MS + 2 * HOUR_MS + 12 * MINUTE_MS);
expect(formatDate(date, false, "en-GB")).toMatchInlineSnapshot(`"Fri 19:10"`);
});
it("should return time & date string without year if it is within the same year", () => {
const date = new Date(REPEATABLE_DATE.getTime() - 66 * DAY_MS + 2 * HOUR_MS + 12 * MINUTE_MS);
expect(formatDate(date, false, "en-GB")).toMatchInlineSnapshot(`"Mon, 12 Sept, 19:10"`);
});
it("should return full time & date string otherwise", () => {
const date = new Date(REPEATABLE_DATE.getTime() - 666 * DAY_MS + 2 * HOUR_MS + 12 * MINUTE_MS);
expect(formatDate(date, false, "en-GB")).toMatchInlineSnapshot(`"Wed, 20 Jan 2021, 19:10"`);
});
});
describe("formatFullDateNoTime", () => {
it("should match given locale en-GB", () => {
expect(formatFullDateNoTime(REPEATABLE_DATE, "en-GB")).toMatchInlineSnapshot(`"Thu, 17 Nov 2022"`);
});
});
describe("formatFullDate", () => {
it("correctly formats with seconds", () => {
expect(formatFullDate(REPEATABLE_DATE, true, true, "en-GB")).toMatchInlineSnapshot(
`"Thu, 17 Nov 2022, 4:58:32 pm"`,
);
});
it("correctly formats without seconds", () => {
expect(formatFullDate(REPEATABLE_DATE, false, false, "en-GB")).toMatchInlineSnapshot(
`"Thu, 17 Nov 2022, 16:58"`,
);
});
});
describe("formatFullTime", () => {
it("correctly formats 12 hour mode", () => {
expect(formatFullTime(REPEATABLE_DATE, true, "en-GB")).toMatchInlineSnapshot(`"4:58:32 pm"`);
});
it("correctly formats 24 hour mode", () => {
expect(formatFullTime(REPEATABLE_DATE, false, "en-GB")).toMatchInlineSnapshot(`"16:58:32"`);
});
});
describe("formatTime", () => {
it("correctly formats 12 hour mode", () => {
expect(formatTime(REPEATABLE_DATE, true, "en-GB")).toMatchInlineSnapshot(`"4:58 pm"`);
});
it("correctly formats 24 hour mode", () => {
expect(formatTime(REPEATABLE_DATE, false, "en-GB")).toMatchInlineSnapshot(`"16:58"`);
});
});
describe("formatSeconds", () => {
it("correctly formats time with hours", () => {
expect(formatSeconds(60 * 60 * 3 + 60 * 31 + 55)).toBe("03:31:55");
expect(formatSeconds(60 * 60 * 3 + 60 * 0 + 55)).toBe("03:00:55");
expect(formatSeconds(60 * 60 * 3 + 60 * 31 + 0)).toBe("03:31:00");
expect(formatSeconds(-(60 * 60 * 3 + 60 * 31 + 0))).toBe("-03:31:00");
});
it("correctly formats time without hours", () => {
expect(formatSeconds(60 * 60 * 0 + 60 * 31 + 55)).toBe("31:55");
expect(formatSeconds(60 * 60 * 0 + 60 * 0 + 55)).toBe("00:55");
expect(formatSeconds(60 * 60 * 0 + 60 * 31 + 0)).toBe("31:00");
expect(formatSeconds(-(60 * 60 * 0 + 60 * 31 + 0))).toBe("-31:00");
});
});
describe("formatRelativeTime", () => {
beforeAll(() => {
jest.useFakeTimers();
// Tuesday, 2 November 2021 11:18:03 UTC
jest.setSystemTime(1635851883000);
});
afterAll(() => {
jest.setSystemTime(jest.getRealSystemTime());
jest.useRealTimers();
});
it("returns hour format for events created in the same day", () => {
// Tuesday, 2 November 2021 11:01:00 UTC
const date = new Date(2021, 10, 2, 11, 1, 23, 0);
expect(formatRelativeTime(date)).toBe("11:01");
});
it("returns month and day for events created less than 24h ago but on a different day", () => {
// Monday, 1 November 2021 23:01:00 UTC
const date = new Date(2021, 10, 1, 23, 1, 23, 0);
expect(formatRelativeTime(date)).toBe("Nov 1");
});
it("honours the hour format setting", () => {
const date = new Date(2021, 10, 2, 11, 1, 23, 0);
expect(formatRelativeTime(date)).toBe("11:01");
expect(formatRelativeTime(date, false)).toBe("11:01");
expect(formatRelativeTime(date, true)).toBe("11:01 AM");
});
it("returns month and day for events created in the current year", () => {
const date = new Date(1632567741000);
expect(formatRelativeTime(date, true)).toBe("Sep 25");
});
it("does not return a leading 0 for single digit days", () => {
const date = new Date(1635764541000);
expect(formatRelativeTime(date, true)).toBe("Nov 1");
});
it("appends the year for events created in previous years", () => {
const date = new Date(1604142141000);
expect(formatRelativeTime(date, true)).toBe("Oct 31, 2020");
});
});
describe("formatDuration()", () => {
type TestCase = [string, string, number];
const MINUTE_MS = 60000;
const HOUR_MS = MINUTE_MS * 60;
it.each<TestCase>([
["rounds up to nearest day when more than 24h - 40 hours", "2d", 40 * HOUR_MS],
["rounds down to nearest day when more than 24h - 26 hours", "1d", 26 * HOUR_MS],
["24 hours", "1d", 24 * HOUR_MS],
["rounds to nearest hour when less than 24h - 23h", "23h", 23 * HOUR_MS],
["rounds to nearest hour when less than 24h - 6h and 10min", "6h", 6 * HOUR_MS + 10 * MINUTE_MS],
["rounds to nearest hours when less than 24h", "2h", 2 * HOUR_MS + 124234],
["rounds to nearest minute when less than 1h - 59 minutes", "59m", 59 * MINUTE_MS],
["rounds to nearest minute when less than 1h - 1 minute", "1m", MINUTE_MS],
["rounds to nearest second when less than 1min - 59 seconds", "59s", 59000],
["rounds to 0 seconds when less than a second - 123ms", "0s", 123],
])("%s formats to %s", (_description, expectedResult, input) => {
expect(formatDuration(input)).toEqual(expectedResult);
});
});
describe("formatPreciseDuration", () => {
const MINUTE_MS = 1000 * 60;
const HOUR_MS = MINUTE_MS * 60;
const DAY_MS = HOUR_MS * 24;
it.each<[string, string, number]>([
["3 days, 6 hours, 48 minutes, 59 seconds", "3d 6h 48m 59s", 3 * DAY_MS + 6 * HOUR_MS + 48 * MINUTE_MS + 59000],
["6 hours, 48 minutes, 59 seconds", "6h 48m 59s", 6 * HOUR_MS + 48 * MINUTE_MS + 59000],
["48 minutes, 59 seconds", "48m 59s", 48 * MINUTE_MS + 59000],
["59 seconds", "59s", 59000],
["0 seconds", "0s", 0],
])("%s formats to %s", (_description, expectedResult, input) => {
expect(formatPreciseDuration(input)).toEqual(expectedResult);
});
});
describe("formatFullDateNoDayISO", () => {
it("should return ISO format", () => {
expect(formatFullDateNoDayISO(REPEATABLE_DATE)).toEqual("2022-11-17T16:58:32.517Z");
});
});
describe("formatFullDateNoDayNoTime", () => {
it("should return a date formatted for en-GB locale", () => {
expect(formatFullDateNoDayNoTime(REPEATABLE_DATE, "en-GB")).toMatchInlineSnapshot(`"17/11/2022"`);
});
});
describe("formatDateForInput", () => {
it.each([["1993-11-01"], ["1066-10-14"], ["0571-04-22"], ["0062-02-05"]])(
"should format %s",
(dateString: string) => {
expect(formatDateForInput(new Date(dateString))).toBe(dateString);
},
);
});
describe("formatTimeLeft", () => {
it.each([
[0, "0s left"],
[23, "23s left"],
[60 + 23, "1m 23s left"],
[60 * 60, "1h 0m 0s left"],
[60 * 60 + 23, "1h 0m 23s left"],
[5 * 60 * 60 + 7 * 60 + 23, "5h 7m 23s left"],
])("should format %s to %s", (seconds: number, expected: string) => {
expect(formatTimeLeft(seconds)).toBe(expected);
});
});
describe("formatLocalDateShort()", () => {
afterAll(() => {
unmockIntlDateTimeFormat();
});
const timestamp = new Date("Fri Dec 17 2021 09:09:00 GMT+0100 (Central European Standard Time)").getTime();
it("formats date correctly by locale", () => {
const locale = jest.spyOn(languageHandler, "getUserLanguage");
mockIntlDateTimeFormat();
// format is DD/MM/YY
locale.mockReturnValue("en-GB");
expect(formatLocalDateShort(timestamp)).toEqual("17/12/21");
// US date format is MM/DD/YY
locale.mockReturnValue("en-US");
expect(formatLocalDateShort(timestamp)).toEqual("12/17/21");
locale.mockReturnValue("de-DE");
expect(formatLocalDateShort(timestamp)).toEqual("17.12.21");
});
});

View file

@ -0,0 +1,174 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { ReactElement } from "react";
import { render } from "jest-matrix-react";
import { MatrixError, ConnectionError } from "matrix-js-sdk/src/matrix";
import {
adminContactStrings,
messageForConnectionError,
messageForLoginError,
messageForResourceLimitError,
messageForSyncError,
resourceLimitStrings,
} from "../../src/utils/ErrorUtils";
describe("messageForResourceLimitError", () => {
it("should match snapshot for monthly_active_user", () => {
const { asFragment } = render(
messageForResourceLimitError("monthly_active_user", "some@email", resourceLimitStrings) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for admin contact links", () => {
const { asFragment } = render(
messageForResourceLimitError("", "some@email", adminContactStrings) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
});
describe("messageForSyncError", () => {
it("should match snapshot for M_RESOURCE_LIMIT_EXCEEDED", () => {
const err = new MatrixError({
errcode: "M_RESOURCE_LIMIT_EXCEEDED",
data: {
limit_type: "monthly_active_user",
admin_contact: "some@email",
},
});
const { asFragment } = render(messageForSyncError(err) as ReactElement);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for other errors", () => {
const err = new MatrixError({
errcode: "OTHER_ERROR",
});
const { asFragment } = render(messageForSyncError(err) as ReactElement);
expect(asFragment()).toMatchSnapshot();
});
});
describe("messageForLoginError", () => {
it("should match snapshot for M_RESOURCE_LIMIT_EXCEEDED", () => {
const err = new MatrixError({
errcode: "M_RESOURCE_LIMIT_EXCEEDED",
data: {
limit_type: "monthly_active_user",
admin_contact: "some@email",
},
});
const { asFragment } = render(
messageForLoginError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for M_USER_DEACTIVATED", () => {
const err = new MatrixError(
{
errcode: "M_USER_DEACTIVATED",
},
403,
);
const { asFragment } = render(
messageForLoginError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for 401", () => {
const err = new MatrixError(
{
errcode: "UNKNOWN",
},
401,
);
const { asFragment } = render(
messageForLoginError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for unknown error", () => {
const err = new MatrixError({}, 400);
const { asFragment } = render(
messageForLoginError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
});
describe("messageForConnectionError", () => {
it("should match snapshot for ConnectionError", () => {
const err = new ConnectionError("Internal Server Error", new MatrixError({}, 500));
const { asFragment } = render(
messageForConnectionError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for MatrixError M_NOT_FOUND", () => {
const err = new MatrixError(
{
errcode: "M_NOT_FOUND",
},
404,
);
const { asFragment } = render(
messageForConnectionError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for unknown error", () => {
const err = new Error("What even");
const { asFragment } = render(
messageForConnectionError(err, {
hsUrl: "hsUrl",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
it("should match snapshot for mixed content error", () => {
const err = new ConnectionError("Mixed content maybe?");
Object.defineProperty(window, "location", { value: { protocol: "https:" } });
const { asFragment } = render(
messageForConnectionError(err, {
hsUrl: "http://server.com",
hsName: "hsName",
}) as ReactElement,
);
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,46 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { getEventDisplayInfo } from "../../src/utils/EventRenderingUtils";
import { VoiceBroadcastInfoState } from "../../src/voice-broadcast";
import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-utils";
import { createTestClient } from "../test-utils";
describe("getEventDisplayInfo", () => {
const mkBroadcastInfoEvent = (state: VoiceBroadcastInfoState) => {
return mkVoiceBroadcastInfoStateEvent("!room:example.com", state, "@user:example.com", "ASD123");
};
it("should return the expected value for a broadcast started event", () => {
expect(getEventDisplayInfo(createTestClient(), mkBroadcastInfoEvent(VoiceBroadcastInfoState.Started), false))
.toMatchInlineSnapshot(`
{
"hasRenderer": true,
"isBubbleMessage": false,
"isInfoMessage": false,
"isLeftAlignedBubbleMessage": false,
"isSeeingThroughMessageHiddenForModeration": false,
"noBubbleEvent": true,
}
`);
});
it("should return the expected value for a broadcast stopped event", () => {
expect(getEventDisplayInfo(createTestClient(), mkBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped), false))
.toMatchInlineSnapshot(`
{
"hasRenderer": true,
"isBubbleMessage": false,
"isInfoMessage": true,
"isLeftAlignedBubbleMessage": false,
"isSeeingThroughMessageHiddenForModeration": false,
"noBubbleEvent": true,
}
`);
});
});

View file

@ -0,0 +1,478 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import {
M_LOCATION,
EventStatus,
EventType,
IEvent,
MatrixClient,
MatrixEvent,
MsgType,
PendingEventOrdering,
RelationType,
Room,
Thread,
} from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import {
canCancel,
canEditContent,
canEditOwnEvent,
fetchInitialEvent,
findEditableEvent,
highlightEvent,
isContentActionable,
isLocationEvent,
isVoiceMessage,
} from "../../src/utils/EventUtils";
import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent, stubClient } from "../test-utils";
import dis from "../../src/dispatcher/dispatcher";
import { Action } from "../../src/dispatcher/actions";
import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-utils";
import { VoiceBroadcastInfoState } from "../../src/voice-broadcast/types";
jest.mock("../../src/dispatcher/dispatcher");
describe("EventUtils", () => {
const userId = "@user:server";
const roomId = "!room:server";
const mockClient = getMockClientWithEventEmitter({
getUserId: jest.fn().mockReturnValue(userId),
});
beforeEach(() => {
mockClient.getUserId.mockClear().mockReturnValue(userId);
});
afterAll(() => {
jest.spyOn(MatrixClientPeg, "get").mockRestore();
});
// setup events
const unsentEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
});
unsentEvent.status = EventStatus.ENCRYPTING;
const redactedEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
});
redactedEvent.makeRedacted(
redactedEvent,
new Room(redactedEvent.getRoomId()!, mockClient, mockClient.getUserId()!),
);
const stateEvent = new MatrixEvent({
type: EventType.RoomTopic,
state_key: "",
});
const beaconInfoEvent = makeBeaconInfoEvent(userId, roomId);
const roomMemberEvent = new MatrixEvent({
type: EventType.RoomMember,
sender: userId,
});
const stickerEvent = new MatrixEvent({
type: EventType.Sticker,
sender: userId,
});
const pollStartEvent = makePollStartEvent("What?", userId);
const notDecryptedEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: "m.bad.encrypted",
},
});
const noMsgType = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: undefined,
},
});
const noContentBody = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Image,
},
});
const emptyContentBody = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Text,
body: "",
},
});
const objectContentBody = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.File,
body: {},
},
});
const niceTextMessage = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
const bobsTextMessage = new MatrixEvent({
type: EventType.RoomMessage,
sender: "@bob:server",
content: {
msgtype: MsgType.Text,
body: "Hello from Bob",
},
});
const voiceBroadcastStart = mkVoiceBroadcastInfoStateEvent(
"!room:example.com",
VoiceBroadcastInfoState.Started,
"@user:example.com",
"ABC123",
);
const voiceBroadcastStop = mkVoiceBroadcastInfoStateEvent(
"!room:example.com",
VoiceBroadcastInfoState.Stopped,
"@user:example.com",
"ABC123",
);
describe("isContentActionable()", () => {
type TestCase = [string, MatrixEvent];
it.each<TestCase>([
["unsent event", unsentEvent],
["redacted event", redactedEvent],
["state event", stateEvent],
["undecrypted event", notDecryptedEvent],
["room member event", roomMemberEvent],
["event without msgtype", noMsgType],
["event without content body property", noContentBody],
["broadcast stop event", voiceBroadcastStop],
])("returns false for %s", (_description, event) => {
expect(isContentActionable(event)).toBe(false);
});
it.each<TestCase>([
["sticker event", stickerEvent],
["poll start event", pollStartEvent],
["event with empty content body", emptyContentBody],
["event with a content body", niceTextMessage],
["beacon_info event", beaconInfoEvent],
["broadcast start event", voiceBroadcastStart],
])("returns true for %s", (_description, event) => {
expect(isContentActionable(event)).toBe(true);
});
});
describe("editable content helpers", () => {
const replaceRelationEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Text,
body: "Hello",
["m.relates_to"]: {
rel_type: RelationType.Replace,
event_id: "1",
},
},
});
const referenceRelationEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Text,
body: "Hello",
["m.relates_to"]: {
rel_type: RelationType.Reference,
event_id: "1",
},
},
});
const emoteEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
msgtype: MsgType.Emote,
body: "🧪",
},
});
type TestCase = [string, MatrixEvent];
const uneditableCases: TestCase[] = [
["redacted event", redactedEvent],
["state event", stateEvent],
["event that is not room message", roomMemberEvent],
["event without msgtype", noMsgType],
["event without content body property", noContentBody],
["event with empty content body property", emptyContentBody],
["event with non-string body", objectContentBody],
["event not sent by current user", bobsTextMessage],
["event with a replace relation", replaceRelationEvent],
];
const editableCases: TestCase[] = [
["event with reference relation", referenceRelationEvent],
["emote event", emoteEvent],
["poll start event", pollStartEvent],
["event with a content body", niceTextMessage],
];
describe("canEditContent()", () => {
it.each<TestCase>(uneditableCases)("returns false for %s", (_description, event) => {
expect(canEditContent(mockClient, event)).toBe(false);
});
it.each<TestCase>(editableCases)("returns true for %s", (_description, event) => {
expect(canEditContent(mockClient, event)).toBe(true);
});
});
describe("canEditOwnContent()", () => {
it.each<TestCase>(uneditableCases)("returns false for %s", (_description, event) => {
expect(canEditOwnEvent(mockClient, event)).toBe(false);
});
it.each<TestCase>(editableCases)("returns true for %s", (_description, event) => {
expect(canEditOwnEvent(mockClient, event)).toBe(true);
});
});
});
describe("isVoiceMessage()", () => {
it("returns true for an event with msc2516.voice content", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
["org.matrix.msc2516.voice"]: {},
},
});
expect(isVoiceMessage(event)).toBe(true);
});
it("returns true for an event with msc3245.voice content", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
["org.matrix.msc3245.voice"]: {},
},
});
expect(isVoiceMessage(event)).toBe(true);
});
it("returns false for an event with voice content", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
body: "hello",
},
});
expect(isVoiceMessage(event)).toBe(false);
});
});
describe("isLocationEvent()", () => {
it("returns true for an event with m.location stable type", () => {
const event = new MatrixEvent({
type: M_LOCATION.altName,
});
expect(isLocationEvent(event)).toBe(true);
});
it("returns true for an event with m.location unstable prefixed type", () => {
const event = new MatrixEvent({
type: M_LOCATION.name,
});
expect(isLocationEvent(event)).toBe(true);
});
it("returns true for a room message with stable m.location msgtype", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
msgtype: M_LOCATION.altName,
},
});
expect(isLocationEvent(event)).toBe(true);
});
it("returns true for a room message with unstable m.location msgtype", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
msgtype: M_LOCATION.name,
},
});
expect(isLocationEvent(event)).toBe(true);
});
it("returns false for a non location event", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
body: "Hello",
},
});
expect(isLocationEvent(event)).toBe(false);
});
});
describe("canCancel()", () => {
it.each([[EventStatus.QUEUED], [EventStatus.NOT_SENT], [EventStatus.ENCRYPTING]])(
"return true for status %s",
(status) => {
expect(canCancel(status)).toBe(true);
},
);
it.each([
[EventStatus.SENDING],
[EventStatus.CANCELLED],
[EventStatus.SENT],
["invalid-status" as unknown as EventStatus],
])("return false for status %s", (status) => {
expect(canCancel(status)).toBe(false);
});
});
describe("fetchInitialEvent", () => {
const ROOM_ID = "!roomId:example.org";
let room: Room;
let client: MatrixClient;
const NORMAL_EVENT = "$normalEvent";
const THREAD_ROOT = "$threadRoot";
const THREAD_REPLY = "$threadReply";
const events: Record<string, Partial<IEvent>> = {
[NORMAL_EVENT]: {
event_id: NORMAL_EVENT,
type: EventType.RoomMessage,
content: {
body: "Classic event",
msgtype: MsgType.Text,
},
},
[THREAD_ROOT]: {
event_id: THREAD_ROOT,
type: EventType.RoomMessage,
content: {
body: "Thread root",
msgtype: "m.text",
},
unsigned: {
"m.relations": {
[RelationType.Thread]: {
latest_event: {
event_id: THREAD_REPLY,
type: EventType.RoomMessage,
content: {
"body": "Thread reply",
"msgtype": MsgType.Text,
"m.relates_to": {
event_id: "$threadRoot",
rel_type: RelationType.Thread,
},
},
},
count: 1,
current_user_participated: false,
},
},
},
},
[THREAD_REPLY]: {
event_id: THREAD_REPLY,
type: EventType.RoomMessage,
content: {
"body": "Thread reply",
"msgtype": MsgType.Text,
"m.relates_to": {
event_id: THREAD_ROOT,
rel_type: RelationType.Thread,
},
},
},
};
beforeEach(() => {
jest.clearAllMocks();
stubClient();
client = MatrixClientPeg.safeGet();
room = new Room(ROOM_ID, client, client.getUserId()!, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
jest.spyOn(client, "getRoom").mockReturnValue(room);
jest.spyOn(client, "fetchRoomEvent").mockImplementation(async (roomId, eventId) => {
return events[eventId] ?? Promise.reject();
});
});
it("returns null for unknown events", async () => {
expect(await fetchInitialEvent(client, room.roomId, "$UNKNOWN")).toBeNull();
expect(await fetchInitialEvent(client, room.roomId, NORMAL_EVENT)).toBeInstanceOf(MatrixEvent);
});
it("creates a thread when needed", async () => {
await fetchInitialEvent(client, room.roomId, THREAD_REPLY);
expect(room.getThread(THREAD_ROOT)).toBeInstanceOf(Thread);
});
});
describe("findEditableEvent", () => {
it("should not explode when given empty events array", () => {
expect(
findEditableEvent({
events: [],
isForward: true,
matrixClient: mockClient,
}),
).toBeUndefined();
});
});
describe("highlightEvent", () => {
const eventId = "$zLg9jResFQmMO_UKFeWpgLgOgyWrL8qIgLgZ5VywrCQ";
it("should dispatch an action to view the event", () => {
highlightEvent(roomId, eventId);
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
event_id: eventId,
highlighted: true,
room_id: roomId,
metricsTrigger: undefined,
});
});
});
});

View file

@ -0,0 +1,38 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import SdkConfig from "../../src/SdkConfig";
import { shouldShowFeedback } from "../../src/utils/Feedback";
import SettingsStore from "../../src/settings/SettingsStore";
jest.mock("../../src/SdkConfig");
jest.mock("../../src/settings/SettingsStore");
describe("shouldShowFeedback", () => {
it("should return false if bug_report_endpoint_url is falsey", () => {
mocked(SdkConfig).get.mockReturnValue({
bug_report_endpoint_url: null,
});
expect(shouldShowFeedback()).toBeFalsy();
});
it("should return false if UIFeature.Feedback is disabled", () => {
mocked(SettingsStore).getValue.mockReturnValue(false);
expect(shouldShowFeedback()).toBeFalsy();
});
it("should return true if bug_report_endpoint_url is set and UIFeature.Feedback is true", () => {
mocked(SdkConfig).get.mockReturnValue({
bug_report_endpoint_url: "https://rageshake.server",
});
mocked(SettingsStore).getValue.mockReturnValue(true);
expect(shouldShowFeedback()).toBeTruthy();
});
});

View file

@ -0,0 +1,60 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MediaEventContent } from "matrix-js-sdk/src/types";
import { downloadLabelForFile } from "../../src/utils/FileUtils.ts";
describe("FileUtils", () => {
describe("downloadLabelForFile", () => {
it.each([
[
"File with size",
{
input: {
msgtype: "m.file",
body: "Test",
info: {
size: 102434566,
},
} as MediaEventContent,
output: "Download (97.69 MB)",
},
],
[
"Image",
{
input: {
msgtype: "m.image",
body: "Test",
} as MediaEventContent,
output: "Download",
},
],
[
"Video",
{
input: {
msgtype: "m.video",
body: "Test",
} as MediaEventContent,
output: "Download",
},
],
[
"Audio",
{
input: {
msgtype: "m.audio",
body: "Test",
} as MediaEventContent,
output: "Download",
},
],
])("should correctly label %s", (_d, { input, output }) => expect(downloadLabelForFile(input)).toBe(output));
});
});

View file

@ -0,0 +1,59 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { FixedRollingArray } from "../../src/utils/FixedRollingArray";
describe("FixedRollingArray", () => {
it("should seed the array with the given value", () => {
const seed = "test";
const width = 24;
const array = new FixedRollingArray(width, seed);
expect(array.value.length).toBe(width);
expect(array.value.every((v) => v === seed)).toBe(true);
});
it("should insert at the correct end", () => {
const seed = "test";
const value = "changed";
const width = 24;
const array = new FixedRollingArray(width, seed);
array.pushValue(value);
expect(array.value.length).toBe(width);
expect(array.value[0]).toBe(value);
});
it("should roll over", () => {
const seed = -1;
const width = 24;
const array = new FixedRollingArray(width, seed);
const maxValue = width * 2;
const minValue = width; // because we're forcing a rollover
for (let i = 0; i <= maxValue; i++) {
array.pushValue(i);
}
expect(array.value.length).toBe(width);
for (let i = 1; i < width; i++) {
const current = array.value[i];
const previous = array.value[i - 1];
expect(previous - current).toBe(1);
if (i === 1) {
// eslint-disable-next-line jest/no-conditional-expect
expect(previous).toBe(maxValue);
} else if (i === width) {
// eslint-disable-next-line jest/no-conditional-expect
expect(current).toBe(minValue);
}
}
});
});

View file

@ -0,0 +1,95 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { formatList, formatCount, formatCountLong } from "../../src/utils/FormattingUtils";
import SettingsStore from "../../src/settings/SettingsStore";
jest.mock("../../src/dispatcher/dispatcher");
describe("FormattingUtils", () => {
describe("formatCount", () => {
it.each([
{ count: 999, expectedCount: "999" },
{ count: 9999, expectedCount: "10K" },
{ count: 99999, expectedCount: "100K" },
{ count: 999999, expectedCount: "1M" },
{ count: 9999999, expectedCount: "10M" },
{ count: 99999999, expectedCount: "100M" },
{ count: 999999999, expectedCount: "1B" },
{ count: 9999999999, expectedCount: "10B" },
])("formats $count as $expectedCount", ({ count, expectedCount }) => {
expect(formatCount(count)).toBe(expectedCount);
});
});
describe("formatCountLong", () => {
it("formats numbers according to the locale", () => {
expect(formatCountLong(1000)).toBe("1,000");
});
});
describe("formatList", () => {
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(SettingsStore, "getValue").mockReturnValue("en-GB");
});
it("should return empty string when given empty list", () => {
expect(formatList([])).toEqual("");
});
it("should return only item when given list of length 1", () => {
expect(formatList(["abc"])).toEqual("abc");
});
it("should return expected sentence in English without item limit", () => {
expect(formatList(["abc", "def", "ghi"])).toEqual("abc, def and ghi");
});
it("should return expected sentence in German without item limit", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue("de");
expect(formatList(["abc", "def", "ghi"])).toEqual("abc, def und ghi");
});
it("should return expected sentence in English with item limit", () => {
expect(formatList(["abc", "def", "ghi", "jkl"], 2)).toEqual("abc, def and 2 others");
expect(formatList(["abc", "def", "ghi", "jkl"], 3)).toEqual("abc, def, ghi and one other");
});
it("should return expected sentence in English with item limit and includeCount", () => {
expect(formatList(["abc", "def", "ghi", "jkl"], 3, true)).toEqual("abc, def and 2 others");
expect(formatList(["abc", "def", "ghi", "jkl"], 4, true)).toEqual("abc, def, ghi and jkl");
});
it("should return expected sentence in ReactNode when given 2 React children", () => {
expect(formatList([<span key="a">a</span>, <span key="b">b</span>])).toMatchSnapshot();
});
it("should return expected sentence in ReactNode when given more React children", () => {
expect(
formatList([
<span key="a">a</span>,
<span key="b">b</span>,
<span key="c">c</span>,
<span key="d">d</span>,
]),
).toMatchSnapshot();
});
it("should return expected sentence in ReactNode when using itemLimit", () => {
expect(
formatList(
[<span key="a">a</span>, <span key="b">b</span>, <span key="c">c</span>, <span key="d">d</span>],
2,
),
).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,225 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { LruCache } from "../../src/utils/LruCache";
describe("LruCache", () => {
it("when creating a cache with negative capacity it should raise an error", () => {
expect(() => {
new LruCache(-23);
}).toThrow("Cache capacity must be at least 1");
});
it("when creating a cache with 0 capacity it should raise an error", () => {
expect(() => {
new LruCache(0);
}).toThrow("Cache capacity must be at least 1");
});
describe("when there is a cache with a capacity of 3", () => {
let cache: LruCache<string, string>;
beforeEach(() => {
cache = new LruCache(3);
});
it("has() should return false", () => {
expect(cache.has("a")).toBe(false);
});
it("get() should return undefined", () => {
expect(cache.get("a")).toBeUndefined();
});
it("values() should return an empty iterator", () => {
expect(Array.from(cache.values())).toEqual([]);
});
it("delete() should not raise an error", () => {
cache.delete("a");
});
describe("when the cache contains 2 items", () => {
beforeEach(() => {
cache.set("a", "a value");
cache.set("b", "b value");
});
it("has() should return false for an item not in the cache", () => {
expect(cache.has("c")).toBe(false);
});
it("get() should return undefined for an item not in the cahce", () => {
expect(cache.get("c")).toBeUndefined();
});
it("values() should return the items in the cache", () => {
expect(Array.from(cache.values())).toEqual(["a value", "b value"]);
});
it("clear() should clear the cache", () => {
cache.clear();
expect(cache.has("a")).toBe(false);
expect(cache.has("b")).toBe(false);
expect(Array.from(cache.values())).toEqual([]);
});
it("when an error occurs while setting an item the cache should be cleard", () => {
jest.spyOn(logger, "warn");
const err = new Error("Something weng wrong :(");
// @ts-ignore
cache.safeSet = () => {
throw err;
};
cache.set("c", "c value");
expect(Array.from(cache.values())).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith("LruCache error", err);
});
describe("and adding another item", () => {
beforeEach(() => {
cache.set("c", "c value");
});
it("deleting an unkonwn item should not raise an error", () => {
cache.delete("unknown");
});
it("deleting the first item should work", () => {
cache.delete("a");
expect(Array.from(cache.values())).toEqual(["b value", "c value"]);
// add an item after delete should work work
cache.set("d", "d value");
expect(Array.from(cache.values())).toEqual(["b value", "c value", "d value"]);
});
it("deleting the item in the middle should work", () => {
cache.delete("b");
expect(Array.from(cache.values())).toEqual(["a value", "c value"]);
// add an item after delete should work work
cache.set("d", "d value");
expect(Array.from(cache.values())).toEqual(["a value", "c value", "d value"]);
});
it("deleting the last item should work", () => {
cache.delete("c");
expect(Array.from(cache.values())).toEqual(["a value", "b value"]);
// add an item after delete should work work
cache.set("d", "d value");
expect(Array.from(cache.values())).toEqual(["a value", "b value", "d value"]);
});
it("deleting all items should work", () => {
cache.delete("a");
cache.delete("b");
cache.delete("c");
// should not raise an error
cache.delete("a");
cache.delete("b");
cache.delete("c");
expect(Array.from(cache.values())).toEqual([]);
// add an item after delete should work work
cache.set("d", "d value");
expect(Array.from(cache.values())).toEqual(["d value"]);
});
it("deleting and adding some items should work", () => {
cache.set("d", "d value");
cache.get("b");
cache.delete("b");
cache.set("e", "e value");
expect(Array.from(cache.values())).toEqual(["c value", "d value", "e value"]);
});
describe("and accesing the first added item and adding another item", () => {
beforeEach(() => {
cache.get("a");
cache.set("d", "d value");
});
it("should contain the last recently accessed items", () => {
expect(cache.has("a")).toBe(true);
expect(cache.get("a")).toEqual("a value");
expect(cache.has("c")).toBe(true);
expect(cache.get("c")).toEqual("c value");
expect(cache.has("d")).toBe(true);
expect(cache.get("d")).toEqual("d value");
expect(Array.from(cache.values())).toEqual(["a value", "c value", "d value"]);
});
it("should not contain the least recently accessed items", () => {
expect(cache.has("b")).toBe(false);
expect(cache.get("b")).toBeUndefined();
});
});
describe("and adding 2 additional items", () => {
beforeEach(() => {
cache.set("d", "d value");
cache.set("e", "e value");
});
it("has() should return false for expired items", () => {
expect(cache.has("a")).toBe(false);
expect(cache.has("b")).toBe(false);
});
it("has() should return true for items in the caceh", () => {
expect(cache.has("c")).toBe(true);
expect(cache.has("d")).toBe(true);
expect(cache.has("e")).toBe(true);
});
it("get() should return undefined for expired items", () => {
expect(cache.get("a")).toBeUndefined();
expect(cache.get("b")).toBeUndefined();
});
it("get() should return the items in the cache", () => {
expect(cache.get("c")).toBe("c value");
expect(cache.get("d")).toBe("d value");
expect(cache.get("e")).toBe("e value");
});
it("values() should return the items in the cache", () => {
expect(Array.from(cache.values())).toEqual(["c value", "d value", "e value"]);
});
});
});
});
describe("when the cache contains some items where one of them is a replacement", () => {
beforeEach(() => {
cache.set("a", "a value");
cache.set("b", "b value");
cache.set("c", "c value");
cache.set("a", "a value 2");
cache.set("d", "d value");
});
it("should contain the last recently set items", () => {
expect(cache.has("a")).toBe(true);
expect(cache.get("a")).toEqual("a value 2");
expect(cache.has("c")).toBe(true);
expect(cache.get("c")).toEqual("c value");
expect(cache.has("d")).toBe(true);
expect(cache.get("d")).toEqual("d value");
expect(Array.from(cache.values())).toEqual(["a value 2", "c value", "d value"]);
});
});
});
});

View file

@ -0,0 +1,154 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { TextEncoder } from "util";
import nodeCrypto from "crypto";
import { Crypto } from "@peculiar/webcrypto";
import type * as MegolmExportEncryptionExport from "../../src/utils/MegolmExportEncryption";
const webCrypto = new Crypto();
function getRandomValues<T extends ArrayBufferView | null>(buf: T): T {
// @ts-ignore fussy generics
return nodeCrypto.randomFillSync(buf);
}
const TEST_VECTORS = [
[
"plain",
"password",
"-----BEGIN MEGOLM SESSION DATA-----\n" +
"AXNhbHRzYWx0c2FsdHNhbHSIiIiIiIiIiIiIiIiIiIiIAAAACmIRUW2OjZ3L2l6j9h0lHlV3M2dx\n" +
"cissyYBxjsfsAndErh065A8=\n" +
"-----END MEGOLM SESSION DATA-----",
],
[
"Hello, World",
"betterpassword",
"-----BEGIN MEGOLM SESSION DATA-----\n" +
"AW1vcmVzYWx0bW9yZXNhbHT//////////wAAAAAAAAAAAAAD6KyBpe1Niv5M5NPm4ZATsJo5nghk\n" +
"KYu63a0YQ5DRhUWEKk7CcMkrKnAUiZny\n" +
"-----END MEGOLM SESSION DATA-----",
],
[
"alphanumericallyalphanumericallyalphanumericallyalphanumerically",
"SWORDFISH",
"-----BEGIN MEGOLM SESSION DATA-----\n" +
"AXllc3NhbHR5Z29vZG5lc3P//////////wAAAAAAAAAAAAAD6OIW+Je7gwvjd4kYrb+49gKCfExw\n" +
"MgJBMD4mrhLkmgAngwR1pHjbWXaoGybtiAYr0moQ93GrBQsCzPbvl82rZhaXO3iH5uHo/RCEpOqp\n" +
"Pgg29363BGR+/Ripq/VCLKGNbw==\n" +
"-----END MEGOLM SESSION DATA-----",
],
[
"alphanumericallyalphanumericallyalphanumericallyalphanumerically",
"passwordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpassword" +
"passwordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpassword" +
"passwordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpassword" +
"passwordpasswordpasswordpasswordpassword",
"-----BEGIN MEGOLM SESSION DATA-----\n" +
"Af//////////////////////////////////////////AAAD6IAZJy7IQ7Y0idqSw/bmpngEEVVh\n" +
"gsH+8ptgqxw6ZVWQnohr8JsuwH9SwGtiebZuBu5smPCO+RFVWH2cQYslZijXv/BEH/txvhUrrtCd\n" +
"bWnSXS9oymiqwUIGs08sXI33ZA==\n" +
"-----END MEGOLM SESSION DATA-----",
],
];
function stringToArray(s: string): ArrayBufferLike {
return new TextEncoder().encode(s).buffer;
}
describe("MegolmExportEncryption", function () {
let MegolmExportEncryption: typeof MegolmExportEncryptionExport;
beforeEach(() => {
Object.defineProperty(window, "crypto", {
value: {
getRandomValues,
randomUUID: jest.fn().mockReturnValue("not-random-uuid"),
subtle: webCrypto.subtle,
},
});
MegolmExportEncryption = require("../../src/utils/MegolmExportEncryption");
});
describe("decrypt", function () {
it("should handle missing header", function () {
const input = stringToArray(`-----`);
return MegolmExportEncryption.decryptMegolmKeyFile(input, "").then(
(res) => {
throw new Error("expected to throw");
},
(error) => {
expect(error.message).toEqual("Header line not found");
},
);
});
it("should handle missing trailer", function () {
const input = stringToArray(`-----BEGIN MEGOLM SESSION DATA-----
-----`);
return MegolmExportEncryption.decryptMegolmKeyFile(input, "").then(
(res) => {
throw new Error("expected to throw");
},
(error) => {
expect(error.message).toEqual("Trailer line not found");
},
);
});
it("should handle a too-short body", function () {
const input = stringToArray(`-----BEGIN MEGOLM SESSION DATA-----
AXNhbHRzYWx0c2FsdHNhbHSIiIiIiIiIiIiIiIiIiIiIAAAACmIRUW2OjZ3L2l6j9h0lHlV3M2dx
cissyYBxjsfsAn
-----END MEGOLM SESSION DATA-----
`);
return MegolmExportEncryption.decryptMegolmKeyFile(input, "").then(
(res) => {
throw new Error("expected to throw");
},
(error) => {
expect(error.message).toEqual("Invalid file: too short");
},
);
});
// TODO find a subtlecrypto shim which doesn't break this test
it.skip("should decrypt a range of inputs", function () {
function next(i: number): Promise<string | undefined> | undefined {
if (i >= TEST_VECTORS.length) {
return;
}
const [plain, password, input] = TEST_VECTORS[i];
return MegolmExportEncryption.decryptMegolmKeyFile(stringToArray(input), password).then((decrypted) => {
expect(decrypted).toEqual(plain);
return next(i + 1);
});
}
next(0);
});
});
describe("encrypt", function () {
it("should round-trip", function () {
const input = "words words many words in plain text here".repeat(100);
const password = "my super secret passphrase";
return MegolmExportEncryption.encryptMegolmKeyFile(input, password, { kdf_rounds: 1000 })
.then((ciphertext) => {
return MegolmExportEncryption.decryptMegolmKeyFile(ciphertext, password);
})
.then((plaintext) => {
expect(plaintext).toEqual(input);
});
});
});
});

View file

@ -0,0 +1,83 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { render } from "jest-matrix-react";
import type { IContent } from "matrix-js-sdk/src/matrix";
import type React from "react";
import { editBodyDiffToHtml } from "../../src/utils/MessageDiffUtils";
describe("editBodyDiffToHtml", () => {
function buildContent(message: string): IContent {
return {
body: message,
format: "org.matrix.custom.html",
formatted_body: message,
msgtype: "m.text",
};
}
function renderDiff(before: string, after: string) {
const node = editBodyDiffToHtml(buildContent(before), buildContent(after));
return render(node as React.ReactElement);
}
it.each([
["simple word changes", "hello", "world"],
["central word changes", "beginning middle end", "beginning :smile: end"],
["text deletions", "<b>hello</b> world", "<b>hello</b>"],
["text additions", "<b>hello</b>", "<b>hello</b> world"],
["block element additions", "hello", "hello <p>world</p>"],
["inline element additions", "hello", "hello <q>world</q>"],
["block element deletions", `hi <blockquote>there</blockquote>`, "hi"],
["inline element deletions", `hi <em>there</em>`, "hi"],
["element replacements", `hi <i>there</i>`, "hi <em>there</em>"],
["attribute modifications", `<a href="#hi">hi</a>`, `<a href="#bye">hi</a>`],
["attribute deletions", `<a href="#hi">hi</a>`, `<a>hi</a>`],
["attribute additions", `<a>hi</a>`, `<a href="#/room/!123">hi</a>`],
["handles empty tags", `<a>hi</a>`, `<a><h1></h1></a> hi`],
])("renders %s", (_label, before, after) => {
const { container } = renderDiff(before, after);
expect(container).toMatchSnapshot();
});
// see https://github.com/fiduswriter/diffDOM/issues/90
// fixed in diff-dom in 4.2.2+
it("deduplicates diff steps", () => {
const { container } = renderDiff("<div><em>foo</em> bar baz</div>", "<div><em>foo</em> bar bay</div>");
expect(container).toMatchSnapshot();
});
it("handles non-html input", () => {
const before: IContent = {
body: "who knows what's going on <strong>here</strong>",
format: "org.exotic.encoding",
formatted_body: "who knows what's going on <strong>here</strong>",
msgtype: "m.text",
};
const after: IContent = {
...before,
body: "who knows what's going on <strong>there</strong>",
formatted_body: "who knows what's going on <strong>there</strong>",
};
const { container } = render(editBodyDiffToHtml(before, after) as React.ReactElement);
expect(container).toMatchSnapshot();
});
// see https://github.com/vector-im/element-web/issues/23665
it("handles complex transformations", () => {
const { container } = renderDiff(
'<span data-mx-maths="{☃️}^\\infty"><code>{☃️}^\\infty</code></span>',
'<span data-mx-maths="{😃}^\\infty"><code>{😃}^\\infty</code></span>',
);
expect(container).toMatchSnapshot();
});
});

View file

@ -0,0 +1,235 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { EventType, MatrixClient, MatrixError, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import Modal, { ComponentType, ComponentProps } from "../../src/Modal";
import SettingsStore from "../../src/settings/SettingsStore";
import MultiInviter, { CompletionStates } from "../../src/utils/MultiInviter";
import * as TestUtilsMatrix from "../test-utils";
import AskInviteAnywayDialog from "../../src/components/views/dialogs/AskInviteAnywayDialog";
import ConfirmUserActionDialog from "../../src/components/views/dialogs/ConfirmUserActionDialog";
const ROOMID = "!room:server";
const MXID1 = "@user1:server";
const MXID2 = "@user2:server";
const MXID3 = "@user3:server";
const MXID_PROFILE_STATES: Record<string, Promise<any>> = {
[MXID1]: Promise.resolve({}),
[MXID2]: Promise.reject(new MatrixError({ errcode: "M_FORBIDDEN" })),
[MXID3]: Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
};
jest.mock("../../src/Modal", () => ({
createDialog: jest.fn(),
}));
jest.mock("../../src/settings/SettingsStore", () => ({
getValue: jest.fn(),
monitorSetting: jest.fn(),
watchSetting: jest.fn(),
}));
const mockPromptBeforeInviteUnknownUsers = (value: boolean) => {
mocked(SettingsStore.getValue).mockImplementation(
(settingName: string, roomId: string, _excludeDefault = false): any => {
if (settingName === "promptBeforeInviteUnknownUsers" && roomId === ROOMID) {
return value;
}
},
);
};
const mockCreateTrackedDialog = (callbackName: "onInviteAnyways" | "onGiveUp") => {
mocked(Modal.createDialog).mockImplementation(
(Element: ComponentType, props?: ComponentProps<ComponentType>): any => {
(props as ComponentProps<typeof AskInviteAnywayDialog>)[callbackName]();
},
);
};
const expectAllInvitedResult = (result: CompletionStates) => {
expect(result).toEqual({
[MXID1]: "invited",
[MXID2]: "invited",
[MXID3]: "invited",
});
};
describe("MultiInviter", () => {
let client: jest.Mocked<MatrixClient>;
let inviter: MultiInviter;
beforeEach(() => {
jest.resetAllMocks();
TestUtilsMatrix.stubClient();
client = MatrixClientPeg.safeGet() as jest.Mocked<MatrixClient>;
client.invite = jest.fn();
client.invite.mockResolvedValue({});
client.getProfileInfo = jest.fn();
client.getProfileInfo.mockImplementation((userId: string) => {
return MXID_PROFILE_STATES[userId] || Promise.reject();
});
client.unban = jest.fn();
inviter = new MultiInviter(client, ROOMID);
});
describe("invite", () => {
describe("with promptBeforeInviteUnknownUsers = false", () => {
beforeEach(() => mockPromptBeforeInviteUnknownUsers(false));
it("should invite all users", async () => {
const result = await inviter.invite([MXID1, MXID2, MXID3]);
expect(client.invite).toHaveBeenCalledTimes(3);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined);
expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, undefined);
expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, undefined);
expectAllInvitedResult(result);
});
});
describe("with promptBeforeInviteUnknownUsers = true and", () => {
beforeEach(() => mockPromptBeforeInviteUnknownUsers(true));
describe("confirming the unknown user dialog", () => {
beforeEach(() => mockCreateTrackedDialog("onInviteAnyways"));
it("should invite all users", async () => {
const result = await inviter.invite([MXID1, MXID2, MXID3]);
expect(client.invite).toHaveBeenCalledTimes(3);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined);
expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, undefined);
expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, undefined);
expectAllInvitedResult(result);
});
});
describe("declining the unknown user dialog", () => {
beforeEach(() => mockCreateTrackedDialog("onGiveUp"));
it("should only invite existing users", async () => {
const result = await inviter.invite([MXID1, MXID2, MXID3]);
expect(client.invite).toHaveBeenCalledTimes(1);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined);
// The resolved state is 'invited' for all users.
// With the above client expectations, the test ensures that only the first user is invited.
expectAllInvitedResult(result);
});
});
});
it("should show sensible error when attempting 3pid invite with no identity server", async () => {
client.inviteByEmail = jest.fn().mockRejectedValueOnce(
new MatrixError({
errcode: "ORG.MATRIX.JSSDK_MISSING_PARAM",
}),
);
await inviter.invite(["foo@bar.com"]);
expect(inviter.getErrorText("foo@bar.com")).toMatchInlineSnapshot(
`"Cannot invite user by email without an identity server. You can connect to one under "Settings"."`,
);
});
it("should ask if user wants to unban user if they have permission", async () => {
mocked(Modal.createDialog).mockImplementation(
(Element: ComponentType, props?: ComponentProps<ComponentType>): any => {
// We stub out the modal with an immediate affirmative (proceed) return
return { finished: Promise.resolve([true]) };
},
);
const room = new Room(ROOMID, client, client.getSafeUserId());
mocked(client.getRoom).mockReturnValue(room);
const ourMember = new RoomMember(ROOMID, client.getSafeUserId());
ourMember.membership = KnownMembership.Join;
ourMember.powerLevel = 100;
const member = new RoomMember(ROOMID, MXID1);
member.membership = KnownMembership.Ban;
member.powerLevel = 0;
room.getMember = (userId: string) => {
if (userId === client.getSafeUserId()) return ourMember;
if (userId === MXID1) return member;
return null;
};
await inviter.invite([MXID1]);
expect(Modal.createDialog).toHaveBeenCalledWith(ConfirmUserActionDialog, {
member,
title: "User cannot be invited until they are unbanned",
action: "Unban",
});
expect(client.unban).toHaveBeenCalledWith(ROOMID, MXID1);
});
it("should show sensible error when attempting to invite over federation with m.federate=false", async () => {
mocked(client.invite).mockRejectedValueOnce(
new MatrixError({
errcode: "M_FORBIDDEN",
}),
);
const room = new Room(ROOMID, client, client.getSafeUserId());
room.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomCreate,
state_key: "",
content: {
"m.federate": false,
},
room_id: ROOMID,
}),
]);
mocked(client.getRoom).mockReturnValue(room);
await inviter.invite(["@user:other_server"]);
expect(inviter.getErrorText("@user:other_server")).toMatchInlineSnapshot(
`"This room is unfederated. You cannot invite people from external servers."`,
);
});
it("should show sensible error when attempting to invite over federation with m.federate=false to space", async () => {
mocked(client.invite).mockRejectedValueOnce(
new MatrixError({
errcode: "M_FORBIDDEN",
}),
);
const room = new Room(ROOMID, client, client.getSafeUserId());
room.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomCreate,
state_key: "",
content: {
"m.federate": false,
"type": "m.space",
},
room_id: ROOMID,
}),
]);
mocked(client.getRoom).mockReturnValue(room);
await inviter.invite(["@user:other_server"]);
expect(inviter.getErrorText("@user:other_server")).toMatchInlineSnapshot(
`"This space is unfederated. You cannot invite people from external servers."`,
);
});
});
});

View file

@ -0,0 +1,81 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { PhasedRolloutFeature } from "../../src/utils/PhasedRolloutFeature";
describe("Test PhasedRolloutFeature", () => {
function randomUserId() {
const characters = "abcdefghijklmnopqrstuvwxyz0123456789.=_-/+";
let result = "";
const charactersLength = characters.length;
const idLength = Math.floor(Math.random() * 15) + 6; // Random number between 6 and 20
for (let i = 0; i < idLength; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return "@" + result + ":matrix.org";
}
function randomDeviceId() {
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
let result = "";
const charactersLength = characters.length;
for (let i = 0; i < 10; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
it("should only accept valid percentage", () => {
expect(() => new PhasedRolloutFeature("test", 0.8)).toThrow();
expect(() => new PhasedRolloutFeature("test", -1)).toThrow();
expect(() => new PhasedRolloutFeature("test", 123)).toThrow();
});
it("should enable for all if percentage is 100", () => {
const phasedRolloutFeature = new PhasedRolloutFeature("test", 100);
for (let i = 0; i < 1000; i++) {
expect(phasedRolloutFeature.isFeatureEnabled(randomUserId())).toBeTruthy();
}
});
it("should not enable for anyone if percentage is 0", () => {
const phasedRolloutFeature = new PhasedRolloutFeature("test", 0);
for (let i = 0; i < 1000; i++) {
expect(phasedRolloutFeature.isFeatureEnabled(randomUserId())).toBeFalsy();
}
});
it("should enable for more users if percentage grows", () => {
let rolloutPercentage = 0;
let previousBatch: string[] = [];
const allUsers = new Array(1000).fill(0).map(() => randomDeviceId());
while (rolloutPercentage <= 90) {
rolloutPercentage += 10;
const nextRollout = new PhasedRolloutFeature("test", rolloutPercentage);
const nextBatch = allUsers.filter((userId) => nextRollout.isFeatureEnabled(userId));
expect(previousBatch.length).toBeLessThan(nextBatch.length);
expect(previousBatch.every((user) => nextBatch.includes(user))).toBeTruthy();
previousBatch = nextBatch;
}
});
it("should distribute differently depending on the feature name", () => {
const allUsers = new Array(1000).fill(0).map(() => randomUserId());
const featureARollout = new PhasedRolloutFeature("FeatureA", 50);
const featureBRollout = new PhasedRolloutFeature("FeatureB", 50);
const featureAUsers = allUsers.filter((userId) => featureARollout.isFeatureEnabled(userId));
const featureBUsers = allUsers.filter((userId) => featureBRollout.isFeatureEnabled(userId));
expect(featureAUsers).not.toEqual(featureBUsers);
});
});

View file

@ -0,0 +1,295 @@
/*
* 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
* Please see LICENSE files in the repository root for full details.
*/
import { EventTimeline, EventType, IEvent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { createTestClient } from "../test-utils";
import PinningUtils from "../../src/utils/PinningUtils";
import SettingsStore from "../../src/settings/SettingsStore";
import { isContentActionable } from "../../src/utils/EventUtils";
import { ReadPinsEventId } from "../../src/components/views/right_panel/types";
jest.mock("../../src/utils/EventUtils", () => {
return {
isContentActionable: jest.fn(),
canPinEvent: jest.fn(),
};
});
describe("PinningUtils", () => {
const roomId = "!room:example.org";
const userId = "@alice:example.org";
const mockedIsContentActionable = mocked(isContentActionable);
let matrixClient: MatrixClient;
let room: Room;
/**
* Create a pinned event with the given content.
* @param content
*/
function makePinEvent(content?: Partial<IEvent>) {
return new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
body: "First pinned message",
msgtype: "m.text",
},
room_id: roomId,
origin_server_ts: 0,
event_id: "$eventId",
...content,
});
}
beforeEach(() => {
// Enable feature pinning
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
mockedIsContentActionable.mockImplementation(() => true);
matrixClient = createTestClient();
room = new Room(roomId, matrixClient, userId);
matrixClient.getRoom = jest.fn().mockReturnValue(room);
jest.spyOn(
matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"mayClientSendStateEvent",
).mockReturnValue(true);
});
describe("isUnpinnable", () => {
test.each(PinningUtils.PINNABLE_EVENT_TYPES)("should return true for pinnable event types", (eventType) => {
const event = makePinEvent({ type: eventType });
expect(PinningUtils.isUnpinnable(event)).toBe(true);
});
test("should return false for a non pinnable event type", () => {
const event = makePinEvent({ type: EventType.RoomCreate });
expect(PinningUtils.isUnpinnable(event)).toBe(false);
});
test("should return true for a redacted event", () => {
const event = makePinEvent({ unsigned: { redacted_because: "because" as unknown as IEvent } });
expect(PinningUtils.isUnpinnable(event)).toBe(true);
});
});
describe("isPinnable", () => {
test.each(PinningUtils.PINNABLE_EVENT_TYPES)("should return true for pinnable event types", (eventType) => {
const event = makePinEvent({ type: eventType });
expect(PinningUtils.isPinnable(event)).toBe(true);
});
test("should return false for a redacted event", () => {
const event = makePinEvent({ unsigned: { redacted_because: "because" as unknown as IEvent } });
expect(PinningUtils.isPinnable(event)).toBe(false);
});
});
describe("isPinned", () => {
test("should return false if no room", () => {
matrixClient.getRoom = jest.fn().mockReturnValue(undefined);
const event = makePinEvent();
expect(PinningUtils.isPinned(matrixClient, event)).toBe(false);
});
test("should return false if no pinned event", () => {
jest.spyOn(
matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"getStateEvents",
).mockReturnValue(null);
const event = makePinEvent();
expect(PinningUtils.isPinned(matrixClient, event)).toBe(false);
});
test("should return false if pinned events do not contain the event id", () => {
jest.spyOn(
matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"getStateEvents",
).mockReturnValue({
// @ts-ignore
getContent: () => ({ pinned: ["$otherEventId"] }),
});
const event = makePinEvent();
expect(PinningUtils.isPinned(matrixClient, event)).toBe(false);
});
test("should return true if pinned events contains the event id", () => {
const event = makePinEvent();
jest.spyOn(
matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"getStateEvents",
).mockReturnValue({
// @ts-ignore
getContent: () => ({ pinned: [event.getId()] }),
});
expect(PinningUtils.isPinned(matrixClient, event)).toBe(true);
});
});
describe("canPin & canUnpin", () => {
describe("canPin", () => {
test("should return false if event is not actionable", () => {
mockedIsContentActionable.mockImplementation(() => false);
const event = makePinEvent();
expect(PinningUtils.canPin(matrixClient, event)).toBe(false);
});
test("should return false if no room", () => {
matrixClient.getRoom = jest.fn().mockReturnValue(undefined);
const event = makePinEvent();
expect(PinningUtils.canPin(matrixClient, event)).toBe(false);
});
test("should return false if client cannot send state event", () => {
jest.spyOn(
matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"mayClientSendStateEvent",
).mockReturnValue(false);
const event = makePinEvent();
expect(PinningUtils.canPin(matrixClient, event)).toBe(false);
});
test("should return false if event is not pinnable", () => {
const event = makePinEvent({ type: EventType.RoomCreate });
expect(PinningUtils.canPin(matrixClient, event)).toBe(false);
});
test("should return true if all conditions are met", () => {
const event = makePinEvent();
expect(PinningUtils.canPin(matrixClient, event)).toBe(true);
});
});
describe("canUnpin", () => {
test("should return false if event is not unpinnable", () => {
const event = makePinEvent({ type: EventType.RoomCreate });
expect(PinningUtils.canUnpin(matrixClient, event)).toBe(false);
});
test("should return true if all conditions are met", () => {
const event = makePinEvent();
expect(PinningUtils.canUnpin(matrixClient, event)).toBe(true);
});
test("should return true if the event is redacted", () => {
const event = makePinEvent({ unsigned: { redacted_because: "because" as unknown as IEvent } });
expect(PinningUtils.canUnpin(matrixClient, event)).toBe(true);
});
});
});
describe("pinOrUnpinEvent", () => {
test("should do nothing if no room", async () => {
matrixClient.getRoom = jest.fn().mockReturnValue(undefined);
const event = makePinEvent();
await PinningUtils.pinOrUnpinEvent(matrixClient, event);
expect(matrixClient.sendStateEvent).not.toHaveBeenCalled();
});
test("should do nothing if no event id", async () => {
const event = makePinEvent({ event_id: undefined });
await PinningUtils.pinOrUnpinEvent(matrixClient, event);
expect(matrixClient.sendStateEvent).not.toHaveBeenCalled();
});
test("should pin the event if not pinned", async () => {
jest.spyOn(
matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"getStateEvents",
).mockReturnValue({
// @ts-ignore
getContent: () => ({ pinned: ["$otherEventId"] }),
});
jest.spyOn(room, "getAccountData").mockReturnValue({
getContent: jest.fn().mockReturnValue({
event_ids: ["$otherEventId"],
}),
} as unknown as MatrixEvent);
const event = makePinEvent();
await PinningUtils.pinOrUnpinEvent(matrixClient, event);
expect(matrixClient.setRoomAccountData).toHaveBeenCalledWith(roomId, ReadPinsEventId, {
event_ids: ["$otherEventId", event.getId()],
});
expect(matrixClient.sendStateEvent).toHaveBeenCalledWith(
roomId,
EventType.RoomPinnedEvents,
{ pinned: ["$otherEventId", event.getId()] },
"",
);
});
test("should unpin the event if already pinned", async () => {
const event = makePinEvent();
jest.spyOn(
matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"getStateEvents",
).mockReturnValue({
// @ts-ignore
getContent: () => ({ pinned: [event.getId(), "$otherEventId"] }),
});
await PinningUtils.pinOrUnpinEvent(matrixClient, event);
expect(matrixClient.sendStateEvent).toHaveBeenCalledWith(
roomId,
EventType.RoomPinnedEvents,
{ pinned: ["$otherEventId"] },
"",
);
});
});
describe("userHasPinOrUnpinPermission", () => {
test("should return true if user can pin or unpin", () => {
expect(PinningUtils.userHasPinOrUnpinPermission(matrixClient, room)).toBe(true);
});
test("should return false if client cannot send state event", () => {
jest.spyOn(
matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"mayClientSendStateEvent",
).mockReturnValue(false);
expect(PinningUtils.userHasPinOrUnpinPermission(matrixClient, room)).toBe(false);
});
});
describe("unpinAllEvents", () => {
it("should unpin all events in the given room", async () => {
await PinningUtils.unpinAllEvents(matrixClient, roomId);
expect(matrixClient.sendStateEvent).toHaveBeenCalledWith(
roomId,
EventType.RoomPinnedEvents,
{ pinned: [] },
"",
);
});
});
});

View file

@ -0,0 +1,52 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Boluwatife Omosowon <boluomosowon@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { parsePermalink } from "../../src/utils/permalinks/Permalinks";
import { transformSearchTerm } from "../../src/utils/SearchInput";
jest.mock("../../src/utils/permalinks/Permalinks");
jest.mock("../../src/stores/WidgetStore");
jest.mock("../../src/stores/widgets/WidgetLayoutStore");
describe("transforming search term", () => {
it("should return the primaryEntityId if the search term was a permalink", () => {
const roomLink = "https://matrix.to/#/#element-dev:matrix.org";
const parsedPermalink = "#element-dev:matrix.org";
mocked(parsePermalink).mockReturnValue({
primaryEntityId: parsedPermalink,
roomIdOrAlias: parsedPermalink,
eventId: "",
userId: "",
viaServers: [],
});
expect(transformSearchTerm(roomLink)).toBe(parsedPermalink);
});
it("should return the original search term if the search term is a permalink and the primaryEntityId is null", () => {
const searchTerm = "https://matrix.to/#/#random-link:matrix.org";
mocked(parsePermalink).mockReturnValue({
primaryEntityId: null,
roomIdOrAlias: null,
eventId: null,
userId: null,
viaServers: null,
});
expect(transformSearchTerm(searchTerm)).toBe(searchTerm);
});
it("should return the original search term if the search term was not a permalink", () => {
const searchTerm = "search term";
mocked(parsePermalink).mockReturnValue(null);
expect(transformSearchTerm(searchTerm)).toBe(searchTerm);
});
});

View file

@ -0,0 +1,244 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { checkSessionLockFree, getSessionLock, SESSION_LOCK_CONSTANTS } from "../../src/utils/SessionLock";
import { resetJsDomAfterEach } from "../test-utils";
describe("SessionLock", () => {
const otherWindows: Array<Window> = [];
beforeEach(() => {
jest.useFakeTimers({ now: 1000 });
});
afterEach(() => {
// shut down other windows created by `createWindow`
otherWindows.forEach((window) => window.close());
otherWindows.splice(0);
});
resetJsDomAfterEach();
it("A single instance starts up normally", async () => {
const onNewInstance = jest.fn();
const result = await getSessionLock(onNewInstance);
expect(result).toBe(true);
expect(onNewInstance).not.toHaveBeenCalled();
});
it("A second instance starts up normally when the first shut down cleanly", async () => {
// first instance starts...
const onNewInstance1 = jest.fn();
expect(await getSessionLock(onNewInstance1)).toBe(true);
expect(onNewInstance1).not.toHaveBeenCalled();
// ... and navigates away
window.dispatchEvent(new Event("pagehide", {}));
// second instance starts as normal
expect(checkSessionLockFree()).toBe(true);
const onNewInstance2 = jest.fn();
expect(await getSessionLock(onNewInstance2)).toBe(true);
expect(onNewInstance1).not.toHaveBeenCalled();
expect(onNewInstance2).not.toHaveBeenCalled();
});
it("A second instance starts up *eventually* when the first terminated uncleanly", async () => {
// first instance starts...
const onNewInstance1 = jest.fn();
expect(await getSessionLock(onNewInstance1)).toBe(true);
expect(onNewInstance1).not.toHaveBeenCalled();
expect(checkSessionLockFree()).toBe(false);
// and pings the timer after 5 seconds
jest.advanceTimersByTime(5000);
expect(checkSessionLockFree()).toBe(false);
// oops, now it dies. We simulate this by forcibly clearing the timers.
// For some reason `jest.clearAllTimers` also resets the simulated time, so preserve that
const time = Date.now();
jest.clearAllTimers();
jest.setSystemTime(time);
expect(checkSessionLockFree()).toBe(false);
// time advances a bit more
jest.advanceTimersByTime(5000);
expect(checkSessionLockFree()).toBe(false);
// second instance tries to start. This should block for 25 more seconds
const onNewInstance2 = jest.fn();
let session2Result: boolean | undefined;
getSessionLock(onNewInstance2).then((res) => {
session2Result = res;
});
// after another 24.5 seconds, we are still waiting
jest.advanceTimersByTime(24500);
expect(session2Result).toBe(undefined);
expect(checkSessionLockFree()).toBe(false);
// another 500ms and we get the lock
await jest.advanceTimersByTimeAsync(500);
expect(session2Result).toBe(true);
expect(checkSessionLockFree()).toBe(false); // still false, because the new session has claimed it
expect(onNewInstance1).not.toHaveBeenCalled();
expect(onNewInstance2).not.toHaveBeenCalled();
});
it("A second instance waits for the first to shut down", async () => {
// first instance starts. Once it gets the shutdown signal, it will wait two seconds and then release the lock.
await getSessionLock(
() =>
new Promise<void>((resolve) => {
setTimeout(resolve, 2000, 0);
}),
);
// second instance tries to start, but should block
const { window: window2, getSessionLock: getSessionLock2 } = buildNewContext();
let session2Result: boolean | undefined;
getSessionLock2(async () => {}).then((res) => {
session2Result = res;
});
await jest.advanceTimersByTimeAsync(100);
// should still be blocking
expect(session2Result).toBe(undefined);
await jest.advanceTimersByTimeAsync(2000);
await jest.advanceTimersByTimeAsync(0);
// session 2 now gets the lock
expect(session2Result).toBe(true);
window2.close();
});
it("If a third instance starts while we are waiting, we give up immediately", async () => {
// first instance starts. It will never release the lock.
await getSessionLock(() => new Promise(() => {}));
// first instance should ping the timer after 5 seconds
jest.advanceTimersByTime(5000);
// second instance starts
const { getSessionLock: getSessionLock2 } = buildNewContext();
let session2Result: boolean | undefined;
const onNewInstance2 = jest.fn();
getSessionLock2(onNewInstance2).then((res) => {
session2Result = res;
});
await jest.advanceTimersByTimeAsync(100);
// should still be blocking
expect(session2Result).toBe(undefined);
// third instance starts
const { getSessionLock: getSessionLock3 } = buildNewContext();
getSessionLock3(async () => {});
await jest.advanceTimersByTimeAsync(0);
// session 2 should have given up
expect(session2Result).toBe(false);
expect(onNewInstance2).toHaveBeenCalled();
});
it("If two new instances start concurrently, only one wins", async () => {
// first instance starts. Once it gets the shutdown signal, it will wait two seconds and then release the lock.
await getSessionLock(async () => {
await new Promise<void>((resolve) => {
setTimeout(resolve, 2000, 0);
});
});
// first instance should ping the timer after 5 seconds
jest.advanceTimersByTime(5000);
// two new instances start at once
const { getSessionLock: getSessionLock2 } = buildNewContext();
let session2Result: boolean | undefined;
getSessionLock2(async () => {}).then((res) => {
session2Result = res;
});
const { getSessionLock: getSessionLock3 } = buildNewContext();
let session3Result: boolean | undefined;
getSessionLock3(async () => {}).then((res) => {
session3Result = res;
});
await jest.advanceTimersByTimeAsync(100);
// session 3 still be blocking. Session 2 should have given up.
expect(session2Result).toBe(false);
expect(session3Result).toBe(undefined);
await jest.advanceTimersByTimeAsync(2000);
await jest.advanceTimersByTimeAsync(0);
// session 3 now gets the lock
expect(session2Result).toBe(false);
expect(session3Result).toBe(true);
});
/** build a new Window in the same domain as the current one.
*
* We do this by constructing an iframe, which gets its own Window object.
*/
function createWindow() {
const iframe = window.document.createElement("iframe");
window.document.body.appendChild(iframe);
const window2: any = iframe.contentWindow;
otherWindows.push(window2);
// make the new Window use the same jest fake timers as us
for (const m of ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"]) {
// @ts-ignore
window2[m] = global[m];
}
return window2;
}
/**
* Instantiate `getSessionLock` in a new context (ie, using a different global `window`).
*
* The new window will share the same fake timer impl as the current context.
*
* @returns the new window and (a wrapper for) getSessionLock in the new context.
*/
function buildNewContext(): {
window: Window;
getSessionLock: (onNewInstance: () => Promise<void>) => Promise<boolean>;
} {
const window2 = createWindow();
// import the dependencies of getSessionLock into the new context
window2._uuid = require("uuid");
window2._logger = require("matrix-js-sdk/src/logger");
window2.SESSION_LOCK_CONSTANTS = SESSION_LOCK_CONSTANTS;
// now, define getSessionLock as a global
window2.eval(String(getSessionLock));
// return a function that will call it
function callGetSessionLock(onNewInstance: () => Promise<void>): Promise<boolean> {
// import the callback into the context
window2._getSessionLockCallback = onNewInstance;
// start the function
try {
return window2.eval(`getSessionLock(_getSessionLockCallback)`);
} finally {
// we can now clear the callback
delete window2._getSessionLockCallback;
}
}
return { window: window2, getSessionLock: callGetSessionLock };
}
});

View file

@ -0,0 +1,222 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { shieldStatusForRoom } from "../../src/utils/ShieldUtils";
import DMRoomMap from "../../src/utils/DMRoomMap";
function mkClient(selfTrust = false) {
return {
getUserId: () => "@self:localhost",
getCrypto: () => ({
getDeviceVerificationStatus: (userId: string, deviceId: string) =>
Promise.resolve({
isVerified: () => (userId === "@self:localhost" ? selfTrust : userId[2] == "T"),
}),
getUserDeviceInfo: async (userIds: string[]) => {
return new Map(userIds.map((u) => [u, new Map([["DEVICE", {}]])]));
},
getUserVerificationStatus: async (userId: string): Promise<UserVerificationStatus> =>
new UserVerificationStatus(userId[1] == "T", userId[1] == "T" || userId[1] == "W", false),
}),
} as unknown as MatrixClient;
}
describe("mkClient self-test", function () {
test.each([true, false])("behaves well for self-trust=%s", async (v) => {
const client = mkClient(v);
const status = await client.getCrypto()!.getDeviceVerificationStatus("@self:localhost", "DEVICE");
expect(status?.isVerified()).toBe(v);
});
test.each([
["@TT:h", true],
["@TF:h", true],
["@FT:h", false],
["@FF:h", false],
])("behaves well for user trust %s", async (userId, trust) => {
const status = await mkClient().getCrypto()?.getUserVerificationStatus(userId);
expect(status!.isCrossSigningVerified()).toBe(trust);
});
test.each([
["@TT:h", true],
["@TF:h", false],
["@FT:h", true],
["@FF:h", false],
])("behaves well for device trust %s", async (userId, trust) => {
const status = await mkClient().getCrypto()!.getDeviceVerificationStatus(userId, "device");
expect(status?.isVerified()).toBe(trust);
});
});
describe("shieldStatusForMembership self-trust behaviour", function () {
beforeAll(() => {
const mockInstance = {
getUserIdForRoomId: (roomId: string) => (roomId === "DM" ? "@any:h" : null),
} as unknown as DMRoomMap;
jest.spyOn(DMRoomMap, "shared").mockReturnValue(mockInstance);
});
afterAll(() => {
jest.spyOn(DMRoomMap, "shared").mockRestore();
});
it.each([
[true, true],
[true, false],
[false, true],
[false, false],
])("2 unverified: returns 'normal', self-trust = %s, DM = %s", async (trusted, dm) => {
const client = mkClient(trusted);
const room = {
roomId: dm ? "DM" : "other",
getEncryptionTargetMembers: () => ["@self:localhost", "@FF1:h", "@FF2:h"].map((userId) => ({ userId })),
} as unknown as Room;
const status = await shieldStatusForRoom(client, room);
expect(status).toEqual("normal");
});
it.each([
["verified", true, true],
["verified", true, false],
["verified", false, true],
["warning", false, false],
])("2 verified: returns '%s', self-trust = %s, DM = %s", async (result, trusted, dm) => {
const client = mkClient(trusted);
const room = {
roomId: dm ? "DM" : "other",
getEncryptionTargetMembers: () => ["@self:localhost", "@TT1:h", "@TT2:h"].map((userId) => ({ userId })),
} as unknown as Room;
const status = await shieldStatusForRoom(client, room);
expect(status).toEqual(result);
});
it.each([
["normal", true, true],
["normal", true, false],
["normal", false, true],
["warning", false, false],
])("2 mixed: returns '%s', self-trust = %s, DM = %s", async (result, trusted, dm) => {
const client = mkClient(trusted);
const room = {
roomId: dm ? "DM" : "other",
getEncryptionTargetMembers: () => ["@self:localhost", "@TT1:h", "@FF2:h"].map((userId) => ({ userId })),
} as unknown as Room;
const status = await shieldStatusForRoom(client, room);
expect(status).toEqual(result);
});
it.each([
["verified", true, true],
["verified", true, false],
["warning", false, true],
["warning", false, false],
])("0 others: returns '%s', self-trust = %s, DM = %s", async (result, trusted, dm) => {
const client = mkClient(trusted);
const room = {
roomId: dm ? "DM" : "other",
getEncryptionTargetMembers: () => ["@self:localhost"].map((userId) => ({ userId })),
} as unknown as Room;
const status = await shieldStatusForRoom(client, room);
expect(status).toEqual(result);
});
it.each([
["verified", true, true],
["verified", true, false],
["verified", false, true],
["verified", false, false],
])("1 verified: returns '%s', self-trust = %s, DM = %s", async (result, trusted, dm) => {
const client = mkClient(trusted);
const room = {
roomId: dm ? "DM" : "other",
getEncryptionTargetMembers: () => ["@self:localhost", "@TT:h"].map((userId) => ({ userId })),
} as unknown as Room;
const status = await shieldStatusForRoom(client, room);
expect(status).toEqual(result);
});
it.each([
["normal", true, true],
["normal", true, false],
["normal", false, true],
["normal", false, false],
])("1 unverified: returns '%s', self-trust = %s, DM = %s", async (result, trusted, dm) => {
const client = mkClient(trusted);
const room = {
roomId: dm ? "DM" : "other",
getEncryptionTargetMembers: () => ["@self:localhost", "@FF:h"].map((userId) => ({ userId })),
} as unknown as Room;
const status = await shieldStatusForRoom(client, room);
expect(status).toEqual(result);
});
});
describe("shieldStatusForMembership other-trust behaviour", function () {
beforeAll(() => {
const mockInstance = {
getUserIdForRoomId: (roomId: string) => (roomId === "DM" ? "@any:h" : null),
} as unknown as DMRoomMap;
jest.spyOn(DMRoomMap, "shared").mockReturnValue(mockInstance);
});
it.each([
["warning", true],
["warning", false],
])("1 verified/untrusted: returns '%s', DM = %s", async (result, dm) => {
const client = mkClient(true);
const room = {
roomId: dm ? "DM" : "other",
getEncryptionTargetMembers: () => ["@self:localhost", "@TF:h"].map((userId) => ({ userId })),
} as unknown as Room;
const status = await shieldStatusForRoom(client, room);
expect(status).toEqual(result);
});
it.each([
["warning", true],
["warning", false],
])("2 verified/untrusted: returns '%s', DM = %s", async (result, dm) => {
const client = mkClient(true);
const room = {
roomId: dm ? "DM" : "other",
getEncryptionTargetMembers: () => ["@self:localhost", "@TF:h", "@TT:h"].map((userId) => ({ userId })),
} as unknown as Room;
const status = await shieldStatusForRoom(client, room);
expect(status).toEqual(result);
});
it.each([
["normal", true],
["normal", false],
])("2 unverified/untrusted: returns '%s', DM = %s", async (result, dm) => {
const client = mkClient(true);
const room = {
roomId: dm ? "DM" : "other",
getEncryptionTargetMembers: () => ["@self:localhost", "@FF:h", "@FT:h"].map((userId) => ({ userId })),
} as unknown as Room;
const status = await shieldStatusForRoom(client, room);
expect(status).toEqual(result);
});
it.each([
["warning", true],
["warning", false],
])("2 was verified: returns '%s', DM = %s", async (result, dm) => {
const client = mkClient(true);
const room = {
roomId: dm ? "DM" : "other",
getEncryptionTargetMembers: () => ["@self:localhost", "@WF:h", "@FT:h"].map((userId) => ({ userId })),
} as unknown as Room;
const status = await shieldStatusForRoom(client, room);
expect(status).toEqual(result);
});
});

View file

@ -0,0 +1,100 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { Singleflight } from "../../src/utils/Singleflight";
describe("Singleflight", () => {
afterEach(() => {
Singleflight.forgetAll();
});
it("should throw for bad context variables", () => {
const permutations: [Object | null, string | null][] = [
[null, null],
[{}, null],
[null, "test"],
];
for (const p of permutations) {
expect(() => Singleflight.for(p[0], p[1])).toThrow("An instance and key must be supplied");
}
});
it("should execute the function once", () => {
const instance = {};
const key = "test";
const val = {}; // unique object for reference check
const fn = jest.fn().mockReturnValue(val);
const sf = Singleflight.for(instance, key);
const r1 = sf.do(fn);
expect(r1).toBe(val);
expect(fn.mock.calls.length).toBe(1);
const r2 = sf.do(fn);
expect(r2).toBe(val);
expect(fn.mock.calls.length).toBe(1);
});
it("should execute the function once, even with new contexts", () => {
const instance = {};
const key = "test";
const val = {}; // unique object for reference check
const fn = jest.fn().mockReturnValue(val);
let sf = Singleflight.for(instance, key);
const r1 = sf.do(fn);
expect(r1).toBe(val);
expect(fn.mock.calls.length).toBe(1);
sf = Singleflight.for(instance, key); // RESET FOR TEST
const r2 = sf.do(fn);
expect(r2).toBe(val);
expect(fn.mock.calls.length).toBe(1);
});
it("should execute the function twice if the result was forgotten", () => {
const instance = {};
const key = "test";
const val = {}; // unique object for reference check
const fn = jest.fn().mockReturnValue(val);
const sf = Singleflight.for(instance, key);
const r1 = sf.do(fn);
expect(r1).toBe(val);
expect(fn.mock.calls.length).toBe(1);
sf.forget();
const r2 = sf.do(fn);
expect(r2).toBe(val);
expect(fn.mock.calls.length).toBe(2);
});
it("should execute the function twice if the instance was forgotten", () => {
const instance = {};
const key = "test";
const val = {}; // unique object for reference check
const fn = jest.fn().mockReturnValue(val);
const sf = Singleflight.for(instance, key);
const r1 = sf.do(fn);
expect(r1).toBe(val);
expect(fn.mock.calls.length).toBe(1);
Singleflight.forgetAllFor(instance);
const r2 = sf.do(fn);
expect(r2).toBe(val);
expect(fn.mock.calls.length).toBe(2);
});
it("should execute the function twice if everything was forgotten", () => {
const instance = {};
const key = "test";
const val = {}; // unique object for reference check
const fn = jest.fn().mockReturnValue(val);
const sf = Singleflight.for(instance, key);
const r1 = sf.do(fn);
expect(r1).toBe(val);
expect(fn.mock.calls.length).toBe(1);
Singleflight.forgetAll();
const r2 = sf.do(fn);
expect(r2).toBe(val);
expect(fn.mock.calls.length).toBe(2);
});
});

View file

@ -0,0 +1,55 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { SnakedObject, snakeToCamel } from "../../src/utils/SnakedObject";
describe("snakeToCamel", () => {
it("should convert snake_case to camelCase in simple scenarios", () => {
expect(snakeToCamel("snake_case")).toBe("snakeCase");
expect(snakeToCamel("snake_case_but_longer")).toBe("snakeCaseButLonger");
expect(snakeToCamel("numbered_123")).toBe("numbered123"); // not a thing we would see normally
});
// Not really something we expect to see, but it's defined behaviour of the function
it("should not camelCase a trailing or leading underscore", () => {
expect(snakeToCamel("_snake")).toBe("_snake");
expect(snakeToCamel("snake_")).toBe("snake_");
expect(snakeToCamel("_snake_case")).toBe("_snakeCase");
expect(snakeToCamel("snake_case_")).toBe("snakeCase_");
});
// Another thing we don't really expect to see, but is "defined behaviour"
it("should be predictable with double underscores", () => {
expect(snakeToCamel("__snake__")).toBe("_Snake_");
expect(snakeToCamel("snake__case")).toBe("snake_case");
});
});
describe("SnakedObject", () => {
/* eslint-disable camelcase*/
const input = {
snake_case: "woot",
snakeCase: "oh no", // ensure different value from snake_case for tests
camelCase: "fallback",
};
const snake = new SnakedObject(input);
/* eslint-enable camelcase*/
it("should prefer snake_case keys", () => {
expect(snake.get("snake_case")).toBe(input.snake_case);
expect(snake.get("snake_case", "camelCase")).toBe(input.snake_case);
});
it("should fall back to camelCase keys when needed", () => {
// @ts-ignore - we're deliberately supplying a key that doesn't exist
expect(snake.get("camel_case")).toBe(input.camelCase);
// @ts-ignore - we're deliberately supplying a key that doesn't exist
expect(snake.get("e_no_exist", "camelCase")).toBe(input.camelCase);
});
});

View file

@ -0,0 +1,47 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import "core-js/stable/structured-clone"; // for idb access
import "fake-indexeddb/auto";
import { idbDelete, idbLoad, idbSave } from "../../src/utils/StorageAccess";
const NONEXISTENT_TABLE = "this_is_not_a_table_we_use_ever_and_so_we_can_use_it_in_tests";
const KNOWN_TABLES = ["account", "pickleKey"];
describe("StorageAccess", () => {
it.each(KNOWN_TABLES)("should save, load, and delete from known table '%s'", async (tableName: string) => {
const key = ["a", "b"];
const data = { hello: "world" };
// Should start undefined
let loaded = await idbLoad(tableName, key);
expect(loaded).toBeUndefined();
// ... then define a value
await idbSave(tableName, key, data);
// ... then check that value
loaded = await idbLoad(tableName, key);
expect(loaded).toEqual(data);
// ... then set it back to undefined
await idbDelete(tableName, key);
// ... which we then check again
loaded = await idbLoad(tableName, key);
expect(loaded).toBeUndefined();
});
it("should fail to save, load, and delete from a non-existent table", async () => {
// Regardless of validity on the key/data, or write order, these should all fail.
await expect(() => idbSave(NONEXISTENT_TABLE, "whatever", "value")).rejects.toThrow();
await expect(() => idbLoad(NONEXISTENT_TABLE, "whatever")).rejects.toThrow();
await expect(() => idbDelete(NONEXISTENT_TABLE, "whatever")).rejects.toThrow();
});
});

View file

@ -0,0 +1,122 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/matrix";
import * as StorageManager from "../../src/utils/StorageManager";
const LEGACY_CRYPTO_STORE_NAME = "matrix-js-sdk:crypto";
const RUST_CRYPTO_STORE_NAME = "matrix-js-sdk::matrix-sdk-crypto";
describe("StorageManager", () => {
async function createDB(name: string, withStores: string[] | undefined = undefined): Promise<IDBDatabase> {
const request = indexedDB.open(name);
return new Promise((resolve, reject) => {
request.onupgradeneeded = function (event) {
const db = request.result;
if (withStores) {
withStores.forEach((storeName) => {
db.createObjectStore(storeName);
});
}
};
request.onsuccess = function (event) {
const db = request.result;
resolve(db);
};
request.onerror = function (event) {
reject(event);
};
});
}
async function populateLegacyStore(migrationState: number | undefined) {
const db = await createDB(LEGACY_CRYPTO_STORE_NAME, [IndexedDBCryptoStore.STORE_ACCOUNT]);
if (migrationState) {
const transaction = db.transaction([IndexedDBCryptoStore.STORE_ACCOUNT], "readwrite");
const store = transaction.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
store.put(migrationState, "migrationState");
await new Promise((resolve, reject) => {
transaction.oncomplete = resolve;
transaction.onerror = reject;
});
}
}
beforeEach(() => {
global.structuredClone = (v) => JSON.parse(JSON.stringify(v));
});
describe("Crypto store checks", () => {
async function populateHealthySession() {
// Storage manager only check for the existence of the `riot-web-sync` store, so just create one.
await createDB("riot-web-sync");
}
beforeEach(async () => {
await populateHealthySession();
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
it("should not be ok if sync store but no crypto store", async () => {
const result = await StorageManager.checkConsistency();
expect(result.healthy).toBe(true);
expect(result.dataInCryptoStore).toBe(false);
});
it("should be ok if sync store and a rust crypto store", async () => {
await createDB(RUST_CRYPTO_STORE_NAME);
const result = await StorageManager.checkConsistency();
expect(result.healthy).toBe(true);
expect(result.dataInCryptoStore).toBe(true);
});
describe("without rust store", () => {
it("should be ok if there is non migrated legacy crypto store", async () => {
await populateLegacyStore(undefined);
const result = await StorageManager.checkConsistency();
expect(result.healthy).toBe(true);
expect(result.dataInCryptoStore).toBe(true);
});
it("should be ok if legacy store in MigrationState `NOT_STARTED`", async () => {
await populateLegacyStore(0 /* MigrationState.NOT_STARTED*/);
const result = await StorageManager.checkConsistency();
expect(result.healthy).toBe(true);
expect(result.dataInCryptoStore).toBe(true);
});
it("should not be ok if MigrationState greater than `NOT_STARTED`", async () => {
await populateLegacyStore(1 /*INITIAL_DATA_MIGRATED*/);
const result = await StorageManager.checkConsistency();
expect(result.healthy).toBe(true);
expect(result.dataInCryptoStore).toBe(false);
});
it("should not be healthy if no indexeddb", async () => {
// eslint-disable-next-line no-global-assign
indexedDB = {} as IDBFactory;
const result = await StorageManager.checkConsistency();
expect(result.healthy).toBe(false);
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
});
});
});

View file

@ -0,0 +1,43 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { abbreviateUrl, parseUrl, unabbreviateUrl } from "../../src/utils/UrlUtils";
describe("abbreviateUrl", () => {
it("should return empty string if passed falsey", () => {
expect(abbreviateUrl(undefined)).toEqual("");
});
it("should abbreviate to host if empty pathname", () => {
expect(abbreviateUrl("https://foo/")).toEqual("foo");
});
it("should not abbreviate if has path parts", () => {
expect(abbreviateUrl("https://foo/path/parts")).toEqual("https://foo/path/parts");
});
});
describe("unabbreviateUrl", () => {
it("should return empty string if passed falsey", () => {
expect(unabbreviateUrl(undefined)).toEqual("");
});
it("should prepend https to input if it lacks it", () => {
expect(unabbreviateUrl("element.io")).toEqual("https://element.io");
});
it("should not prepend https to input if it has it", () => {
expect(unabbreviateUrl("https://element.io")).toEqual("https://element.io");
});
});
describe("parseUrl", () => {
it("should not throw on no proto", () => {
expect(() => parseUrl("test")).not.toThrow();
});
});

View file

@ -0,0 +1,44 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Oliver Sand
Copyright 2022 Nordeck IT + Consulting GmbH.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import WidgetUtils from "../../src/utils/WidgetUtils";
import { mockPlatformPeg } from "../test-utils";
describe("getLocalJitsiWrapperUrl", () => {
beforeEach(() => {
Object.defineProperty(window, "location", {
value: {
origin: "https://app.element.io",
pathname: "",
},
});
});
it("should generate jitsi URL (for defaults)", () => {
mockPlatformPeg();
expect(WidgetUtils.getLocalJitsiWrapperUrl()).toEqual(
"https://app.element.io/jitsi.html" +
"#conferenceDomain=$domain" +
"&conferenceId=$conferenceId" +
"&isAudioOnly=$isAudioOnly" +
"&startWithAudioMuted=$startWithAudioMuted" +
"&startWithVideoMuted=$startWithVideoMuted" +
"&isVideoChannel=$isVideoChannel" +
"&displayName=$matrix_display_name" +
"&avatarUrl=$matrix_avatar_url" +
"&userId=$matrix_user_id" +
"&roomId=$matrix_room_id" +
"&theme=$theme" +
"&roomName=$roomName" +
"&supportsScreensharing=true" +
"&language=$org.matrix.msc2873.client_language",
);
});
});

View file

@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AutoDiscoveryUtils authComponentStateForError should return expected error for the registration page 1`] = `
{
"serverDeadError": <div>
<strong>
Your Element is misconfigured
</strong>
<div>
<span>
Ask your Element admin to check
<a
href="https://github.com/vector-im/element-web/blob/master/docs/config.md"
rel="noreferrer noopener"
target="_blank"
>
your config
</a>
for incorrect or duplicate entries.
</span>
</div>
</div>,
"serverErrorIsFatal": true,
"serverIsAlive": false,
}
`;

View file

@ -0,0 +1,129 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`messageForConnectionError should match snapshot for ConnectionError 1`] = `
<DocumentFragment>
<span>
<span>
Can't connect to homeserver - please check your connectivity, ensure your
<a
class="mx_ExternalLink"
href="hsUrl"
rel="noreferrer noopener"
target="_blank"
>
homeserver's SSL certificate
<i
class="mx_ExternalLink_icon"
/>
</a>
is trusted, and that a browser extension is not blocking requests.
</span>
</span>
</DocumentFragment>
`;
exports[`messageForConnectionError should match snapshot for MatrixError M_NOT_FOUND 1`] = `
<DocumentFragment>
There was a problem communicating with the homeserver, please try again later.(M_NOT_FOUND)
</DocumentFragment>
`;
exports[`messageForConnectionError should match snapshot for mixed content error 1`] = `
<DocumentFragment>
<span>
<span>
Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or
<a
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
rel="noreferrer noopener"
target="_blank"
>
enable unsafe scripts
</a>
.
</span>
</span>
</DocumentFragment>
`;
exports[`messageForConnectionError should match snapshot for unknown error 1`] = `
<DocumentFragment>
There was a problem communicating with the homeserver, please try again later.
</DocumentFragment>
`;
exports[`messageForLoginError should match snapshot for 401 1`] = `
<DocumentFragment>
Incorrect username and/or password.
</DocumentFragment>
`;
exports[`messageForLoginError should match snapshot for M_RESOURCE_LIMIT_EXCEEDED 1`] = `
<DocumentFragment>
<div>
<div>
This homeserver has exceeded one of its resource limits.
</div>
<div
class="mx_Login_smallError"
>
Please contact your service administrator to continue using this service.
</div>
</div>
</DocumentFragment>
`;
exports[`messageForLoginError should match snapshot for M_USER_DEACTIVATED 1`] = `
<DocumentFragment>
This account has been deactivated.
</DocumentFragment>
`;
exports[`messageForLoginError should match snapshot for unknown error 1`] = `
<DocumentFragment>
There was a problem communicating with the homeserver, please try again later. (HTTP 400)
</DocumentFragment>
`;
exports[`messageForResourceLimitError should match snapshot for admin contact links 1`] = `
<DocumentFragment>
<span>
Please
<a
href="some@email"
rel="noreferrer noopener"
target="_blank"
>
contact your service administrator
</a>
to continue using this service.
</span>
</DocumentFragment>
`;
exports[`messageForResourceLimitError should match snapshot for monthly_active_user 1`] = `
<DocumentFragment>
This homeserver has hit its Monthly Active User limit.
</DocumentFragment>
`;
exports[`messageForSyncError should match snapshot for M_RESOURCE_LIMIT_EXCEEDED 1`] = `
<DocumentFragment>
<div>
<div>
This homeserver has exceeded one of its resource limits.
</div>
<div>
Please contact your service administrator to continue using this service.
</div>
</div>
</DocumentFragment>
`;
exports[`messageForSyncError should match snapshot for other errors 1`] = `
<DocumentFragment>
<div>
Unable to connect to Homeserver. Retrying…
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FormattingUtils formatList should return expected sentence in ReactNode when given 2 React children 1`] = `
<span>
<span>
a
</span>
and
<span>
b
</span>
</span>
`;
exports[`FormattingUtils formatList should return expected sentence in ReactNode when given more React children 1`] = `
<span>
<span>
a
</span>
,
<span>
b
</span>
,
<span>
c
</span>
and
<span>
d
</span>
</span>
`;
exports[`FormattingUtils formatList should return expected sentence in ReactNode when using itemLimit 1`] = `
<span>
<span>
<span>
a
</span>
,
<span>
b
</span>
</span>
and 2 others
</span>
`;

View file

@ -0,0 +1,500 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`editBodyDiffToHtml deduplicates diff steps 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<div>
<em>
foo
</em>
<span>
bar ba
<span
class="mx_EditHistoryMessage_deletion"
>
z
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
y
</span>
</span>
</div>
</span>
</div>
`;
exports[`editBodyDiffToHtml handles complex transformations 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<span
data-mx-maths="{<span class='mx_Emoji' title=':snowman:'>☃️</span>}^\\infty"
>
<code>
{
<span
class="mx_Emoji"
title=":snowman:"
>
☃️
</span>
}^\\infty
</code>
</span>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<span
data-mx-maths="{<span class='mx_Emoji' title=':smiley:'>😃</span>}^\\infty"
>
<code>
{
<span
class="mx_Emoji"
title=":snowman:"
>
☃️
</span>
}^\\infty
</code>
</span>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml handles non-html input 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
who knows what's going on &lt;strong&gt;
<span
class="mx_EditHistoryMessage_insertion"
>
t
</span>
here&lt;/strong&gt;
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders attribute additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
rel="noreferrer noopener"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
href="undefined"
rel="noreferrer noopener"
>
hi
</a>
</span>
</span>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<span
target="undefined"
>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
rel="noreferrer noopener"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
href="undefined"
rel="noreferrer noopener"
>
hi
</a>
</span>
</span>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders attribute deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
href="#hi"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
</span>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
href="#hi"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
</span>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders attribute modifications 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
href="#hi"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
href="#bye"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders block element additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hello
<span
class="mx_EditHistoryMessage_insertion"
>
</span>
</span>
<div
class="mx_EditHistoryMessage_insertion"
>
<p>
world
</p>
</div>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders block element deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hi
<span
class="mx_EditHistoryMessage_deletion"
>
</span>
</span>
<div
class="mx_EditHistoryMessage_deletion"
>
<blockquote>
there
</blockquote>
</div>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders central word changes 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
beginning
<span
class="mx_EditHistoryMessage_insertion"
>
:s
</span>
mi
<span
class="mx_EditHistoryMessage_deletion"
>
dd
</span>
le
<span
class="mx_EditHistoryMessage_insertion"
>
:
</span>
end
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders element replacements 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
hi
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<i>
there
</i>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<em>
there
</em>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders handles empty tags 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<a
rel="noreferrer noopener"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
hi
</span>
<div
class="mx_EditHistoryMessage_insertion"
>
<h1 />
</div>
</span>
</a>
<span
class="mx_EditHistoryMessage_insertion"
>
hi
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders inline element additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hello
<span
class="mx_EditHistoryMessage_insertion"
>
world
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders inline element deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hi
<span
class="mx_EditHistoryMessage_deletion"
>
</span>
</span>
<span
class="mx_EditHistoryMessage_deletion"
>
<em>
there
</em>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders simple word changes 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
hello
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
world
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders text additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<b>
hello
</b>
<span
class="mx_EditHistoryMessage_insertion"
>
world
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders text deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<b>
hello
</b>
<span
class="mx_EditHistoryMessage_deletion"
>
world
</span>
</span>
</div>
`;

View file

@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createVoiceMessageContent should create a voice message content 1`] = `
{
"body": "Voice message",
"file": {},
"info": {
"duration": 23000,
"mimetype": "ogg/opus",
"size": 42000,
},
"msgtype": "m.audio",
"org.matrix.msc1767.audio": {
"duration": 23000,
"waveform": [
1,
2,
3,
],
},
"org.matrix.msc1767.file": {
"file": {},
"mimetype": "ogg/opus",
"name": "Voice message.ogg",
"size": 42000,
"url": "mxc://example.com/file",
},
"org.matrix.msc1767.text": "Voice message",
"org.matrix.msc3245.voice": {},
"url": "mxc://example.com/file",
}
`;

View file

@ -0,0 +1,463 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import {
arrayDiff,
arrayFastClone,
arrayFastResample,
arrayHasDiff,
arrayHasOrderChange,
arrayUnion,
arrayRescale,
arraySeed,
arraySmoothingResample,
arrayTrimFill,
arrayIntersection,
ArrayUtil,
GroupedArray,
concat,
asyncEvery,
asyncSome,
} from "../../src/utils/arrays";
type TestParams = { input: number[]; output: number[] };
type TestCase = [string, TestParams];
function expectSample(input: number[], expected: number[], smooth = false) {
const result = (smooth ? arraySmoothingResample : arrayFastResample)(input, expected.length);
expect(result).toBeDefined();
expect(result).toHaveLength(expected.length);
expect(result).toEqual(expected);
}
describe("arrays", () => {
describe("arrayFastResample", () => {
const downsampleCases: TestCase[] = [
["Odd -> Even", { input: [1, 2, 3, 4, 5], output: [1, 4] }],
["Odd -> Odd", { input: [1, 2, 3, 4, 5], output: [1, 3, 5] }],
["Even -> Odd", { input: [1, 2, 3, 4], output: [1, 2, 3] }],
["Even -> Even", { input: [1, 2, 3, 4], output: [1, 3] }],
];
it.each(downsampleCases)("downsamples correctly from %s", (_d, { input, output }) =>
expectSample(input, output),
);
const upsampleCases: TestCase[] = [
["Odd -> Even", { input: [1, 2, 3], output: [1, 1, 2, 2, 3, 3] }],
["Odd -> Odd", { input: [1, 2, 3], output: [1, 1, 2, 2, 3] }],
["Even -> Odd", { input: [1, 2], output: [1, 1, 1, 2, 2] }],
["Even -> Even", { input: [1, 2], output: [1, 1, 1, 2, 2, 2] }],
];
it.each(upsampleCases)("upsamples correctly from %s", (_d, { input, output }) => expectSample(input, output));
const maintainSampleCases: TestCase[] = [
["Odd", { input: [1, 2, 3], output: [1, 2, 3] }], // Odd
["Even", { input: [1, 2], output: [1, 2] }], // Even
];
it.each(maintainSampleCases)("maintains samples for %s", (_d, { input, output }) =>
expectSample(input, output),
);
});
describe("arraySmoothingResample", () => {
// Dev note: these aren't great samples, but they demonstrate the bare minimum. Ideally
// we'd be feeding a thousand values in and seeing what a curve of 250 values looks like,
// but that's not really feasible to manually verify accuracy.
const downsampleCases: TestCase[] = [
["Odd -> Even", { input: [4, 4, 1, 4, 4, 1, 4, 4, 1], output: [3, 3, 3, 3] }],
["Odd -> Odd", { input: [4, 4, 1, 4, 4, 1, 4, 4, 1], output: [3, 3, 3] }],
["Even -> Odd", { input: [4, 4, 1, 4, 4, 1, 4, 4], output: [3, 3, 3] }],
["Even -> Even", { input: [4, 4, 1, 4, 4, 1, 4, 4], output: [3, 3] }],
];
it.each(downsampleCases)("downsamples correctly from %s", (_d, { input, output }) =>
expectSample(input, output, true),
);
const upsampleCases: TestCase[] = [
["Odd -> Even", { input: [2, 0, 2], output: [2, 2, 0, 0, 2, 2] }],
["Odd -> Odd", { input: [2, 0, 2], output: [2, 2, 0, 0, 2] }],
["Even -> Odd", { input: [2, 0], output: [2, 2, 2, 0, 0] }],
["Even -> Even", { input: [2, 0], output: [2, 2, 2, 0, 0, 0] }],
];
it.each(upsampleCases)("upsamples correctly from %s", (_d, { input, output }) =>
expectSample(input, output, true),
);
const maintainCases: TestCase[] = [
["Odd", { input: [2, 0, 2], output: [2, 0, 2] }],
["Even", { input: [2, 0], output: [2, 0] }],
];
it.each(maintainCases)("maintains samples for %s", (_d, { input, output }) => expectSample(input, output));
});
describe("arrayRescale", () => {
it("should rescale", () => {
const input = [8, 9, 1, 0, 2, 7, 10];
const output = [80, 90, 10, 0, 20, 70, 100];
const result = arrayRescale(input, 0, 100);
expect(result).toBeDefined();
expect(result).toHaveLength(output.length);
expect(result).toEqual(output);
});
});
describe("arrayTrimFill", () => {
it("should shrink arrays", () => {
const input = [1, 2, 3];
const output = [1, 2];
const seed = [4, 5, 6];
const result = arrayTrimFill(input, output.length, seed);
expect(result).toBeDefined();
expect(result).toHaveLength(output.length);
expect(result).toEqual(output);
});
it("should expand arrays", () => {
const input = [1, 2, 3];
const output = [1, 2, 3, 4, 5];
const seed = [4, 5, 6];
const result = arrayTrimFill(input, output.length, seed);
expect(result).toBeDefined();
expect(result).toHaveLength(output.length);
expect(result).toEqual(output);
});
it("should keep arrays the same", () => {
const input = [1, 2, 3];
const output = [1, 2, 3];
const seed = [4, 5, 6];
const result = arrayTrimFill(input, output.length, seed);
expect(result).toBeDefined();
expect(result).toHaveLength(output.length);
expect(result).toEqual(output);
});
});
describe("arraySeed", () => {
it("should create an array of given length", () => {
const val = 1;
const output = [val, val, val];
const result = arraySeed(val, output.length);
expect(result).toBeDefined();
expect(result).toHaveLength(output.length);
expect(result).toEqual(output);
});
it("should maintain pointers", () => {
const val = {}; // this works because `{} !== {}`, which is what toEqual checks
const output = [val, val, val];
const result = arraySeed(val, output.length);
expect(result).toBeDefined();
expect(result).toHaveLength(output.length);
expect(result).toEqual(output);
});
});
describe("arrayFastClone", () => {
it("should break pointer reference on source array", () => {
const val = {}; // we'll test to make sure the values maintain pointers too
const input = [val, val, val];
const result = arrayFastClone(input);
expect(result).toBeDefined();
expect(result).toHaveLength(input.length);
expect(result).toEqual(input); // we want the array contents to match...
expect(result).not.toBe(input); // ... but be a different reference
});
});
describe("arrayHasOrderChange", () => {
it("should flag true on B ordering difference", () => {
const a = [1, 2, 3];
const b = [3, 2, 1];
const result = arrayHasOrderChange(a, b);
expect(result).toBe(true);
});
it("should flag false on no ordering difference", () => {
const a = [1, 2, 3];
const b = [1, 2, 3];
const result = arrayHasOrderChange(a, b);
expect(result).toBe(false);
});
it("should flag true on A length > B length", () => {
const a = [1, 2, 3, 4];
const b = [1, 2, 3];
const result = arrayHasOrderChange(a, b);
expect(result).toBe(true);
});
it("should flag true on A length < B length", () => {
const a = [1, 2, 3];
const b = [1, 2, 3, 4];
const result = arrayHasOrderChange(a, b);
expect(result).toBe(true);
});
});
describe("arrayHasDiff", () => {
it("should flag true on A length > B length", () => {
const a = [1, 2, 3, 4];
const b = [1, 2, 3];
const result = arrayHasDiff(a, b);
expect(result).toBe(true);
});
it("should flag true on A length < B length", () => {
const a = [1, 2, 3];
const b = [1, 2, 3, 4];
const result = arrayHasDiff(a, b);
expect(result).toBe(true);
});
it("should flag true on element differences", () => {
const a = [1, 2, 3];
const b = [4, 5, 6];
const result = arrayHasDiff(a, b);
expect(result).toBe(true);
});
it("should flag false if same but order different", () => {
const a = [1, 2, 3];
const b = [3, 1, 2];
const result = arrayHasDiff(a, b);
expect(result).toBe(false);
});
it("should flag false if same", () => {
const a = [1, 2, 3];
const b = [1, 2, 3];
const result = arrayHasDiff(a, b);
expect(result).toBe(false);
});
});
describe("arrayDiff", () => {
it("should see added from A->B", () => {
const a = [1, 2, 3];
const b = [1, 2, 3, 4];
const result = arrayDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(0);
expect(result.added).toEqual([4]);
});
it("should see removed from A->B", () => {
const a = [1, 2, 3];
const b = [1, 2];
const result = arrayDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(1);
expect(result.removed).toEqual([3]);
});
it("should see added and removed in the same set", () => {
const a = [1, 2, 3];
const b = [1, 2, 4]; // note diff
const result = arrayDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(1);
expect(result.added).toEqual([4]);
expect(result.removed).toEqual([3]);
});
});
describe("arrayIntersection", () => {
it("should return the intersection", () => {
const a = [1, 2, 3];
const b = [1, 2, 4]; // note diff
const result = arrayIntersection(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(2);
expect(result).toEqual([1, 2]);
});
it("should return an empty array on no matches", () => {
const a = [1, 2, 3];
const b = [4, 5, 6];
const result = arrayIntersection(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(0);
});
});
describe("arrayUnion", () => {
it("should union 3 arrays with deduplication", () => {
const a = [1, 2, 3];
const b = [1, 2, 4, 5]; // note missing 3
const c = [6, 7, 8, 9];
const result = arrayUnion(a, b, c);
expect(result).toBeDefined();
expect(result).toHaveLength(9);
expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it("should deduplicate a single array", () => {
// dev note: this is technically an edge case, but it is described behaviour if the
// function is only provided one array (it'll merge the array against itself)
const a = [1, 1, 2, 2, 3, 3];
const result = arrayUnion(a);
expect(result).toBeDefined();
expect(result).toHaveLength(3);
expect(result).toEqual([1, 2, 3]);
});
});
describe("ArrayUtil", () => {
it("should maintain the pointer to the given array", () => {
const input = [1, 2, 3];
const result = new ArrayUtil(input);
expect(result.value).toBe(input);
});
it("should group appropriately", () => {
const input = [
["a", 1],
["b", 2],
["c", 3],
["a", 4],
["a", 5],
["b", 6],
];
const output = {
a: [
["a", 1],
["a", 4],
["a", 5],
],
b: [
["b", 2],
["b", 6],
],
c: [["c", 3]],
};
const result = new ArrayUtil(input).groupBy((p) => p[0]);
expect(result).toBeDefined();
expect(result.value).toBeDefined();
const asObject = Object.fromEntries(result.value.entries());
expect(asObject).toMatchObject(output);
});
});
describe("GroupedArray", () => {
it("should maintain the pointer to the given map", () => {
const input = new Map([
["a", [1, 2, 3]],
["b", [7, 8, 9]],
["c", [4, 5, 6]],
]);
const result = new GroupedArray(input);
expect(result.value).toBe(input);
});
it("should ordering by the provided key order", () => {
const input = new Map([
["a", [1, 2, 3]],
["b", [7, 8, 9]], // note counting diff
["c", [4, 5, 6]],
]);
const output = [4, 5, 6, 1, 2, 3, 7, 8, 9];
const keyOrder = ["c", "a", "b"]; // note weird order to cause the `output` to be strange
const result = new GroupedArray(input).orderBy(keyOrder);
expect(result).toBeDefined();
expect(result.value).toBeDefined();
expect(result.value).toEqual(output);
});
});
describe("concat", () => {
const emptyArray = () => new Uint8Array(0);
const array1 = () => new Uint8Array([1, 2, 3]);
const array2 = () => new Uint8Array([4, 5, 6]);
const array3 = () => new Uint8Array([7, 8, 9]);
it("should work for empty arrays", () => {
expect(concat(emptyArray(), emptyArray())).toEqual(emptyArray());
});
it("should concat an empty and non-empty array", () => {
expect(concat(emptyArray(), array1())).toEqual(array1());
});
it("should concat an non-empty and empty array", () => {
expect(concat(array1(), emptyArray())).toEqual(array1());
});
it("should concat two arrays", () => {
expect(concat(array1(), array2())).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6]));
});
it("should concat three arrays", () => {
expect(concat(array1(), array2(), array3())).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]));
});
});
describe("asyncEvery", () => {
it("when called with an empty array, it should return true", async () => {
expect(await asyncEvery([], jest.fn().mockResolvedValue(true))).toBe(true);
});
it("when called with some items and the predicate resolves to true for all of them, it should return true", async () => {
const predicate = jest.fn().mockResolvedValue(true);
expect(await asyncEvery([1, 2, 3], predicate)).toBe(true);
expect(predicate).toHaveBeenCalledTimes(3);
expect(predicate).toHaveBeenCalledWith(1);
expect(predicate).toHaveBeenCalledWith(2);
expect(predicate).toHaveBeenCalledWith(3);
});
it("when called with some items and the predicate resolves to false for all of them, it should return false", async () => {
const predicate = jest.fn().mockResolvedValue(false);
expect(await asyncEvery([1, 2, 3], predicate)).toBe(false);
expect(predicate).toHaveBeenCalledTimes(1);
expect(predicate).toHaveBeenCalledWith(1);
});
it("when called with some items and the predicate resolves to false for one of them, it should return false", async () => {
const predicate = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false);
expect(await asyncEvery([1, 2, 3], predicate)).toBe(false);
expect(predicate).toHaveBeenCalledTimes(2);
expect(predicate).toHaveBeenCalledWith(1);
expect(predicate).toHaveBeenCalledWith(2);
});
});
describe("asyncSome", () => {
it("when called with an empty array, it should return false", async () => {
expect(await asyncSome([], jest.fn().mockResolvedValue(true))).toBe(false);
});
it("when called with some items and the predicate resolves to false for all of them, it should return false", async () => {
const predicate = jest.fn().mockResolvedValue(false);
expect(await asyncSome([1, 2, 3], predicate)).toBe(false);
expect(predicate).toHaveBeenCalledTimes(3);
expect(predicate).toHaveBeenCalledWith(1);
expect(predicate).toHaveBeenCalledWith(2);
expect(predicate).toHaveBeenCalledWith(3);
});
it("when called with some items and the predicate resolves to true, it should short-circuit and return true", async () => {
const predicate = jest.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
expect(await asyncSome([1, 2, 3], predicate)).toBe(true);
expect(predicate).toHaveBeenCalledTimes(2);
expect(predicate).toHaveBeenCalledWith(1);
expect(predicate).toHaveBeenCalledWith(2);
});
});
});

View file

@ -0,0 +1,93 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { Beacon } from "matrix-js-sdk/src/matrix";
import { Bounds, getBeaconBounds } from "../../../src/utils/beacon/bounds";
import { makeBeaconEvent, makeBeaconInfoEvent } from "../../test-utils";
describe("getBeaconBounds()", () => {
const userId = "@user:server";
const roomId = "!room:server";
const makeBeaconWithLocation = (latLon: { lat: number; lon: number }) => {
const geoUri = `geo:${latLon.lat},${latLon.lon}`;
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true }));
// @ts-ignore private prop, sets internal live property so addLocations works
beacon.checkLiveness();
const location = makeBeaconEvent(userId, {
beaconInfoId: beacon.beaconInfoId,
geoUri,
timestamp: Date.now() + 1,
});
beacon.addLocations([location]);
return beacon;
};
const geo = {
// northern hemi
// west of greenwich
london: { lat: 51.5, lon: -0.14 },
reykjavik: { lat: 64.08, lon: -21.82 },
// east of greenwich
paris: { lat: 48.85, lon: 2.29 },
// southern hemi
// east
auckland: { lat: -36.85, lon: 174.76 }, // nz
// west
lima: { lat: -12.013843, lon: -77.008388 }, // peru
};
const london = makeBeaconWithLocation(geo.london);
const reykjavik = makeBeaconWithLocation(geo.reykjavik);
const paris = makeBeaconWithLocation(geo.paris);
const auckland = makeBeaconWithLocation(geo.auckland);
const lima = makeBeaconWithLocation(geo.lima);
it("should return undefined when there are no beacons", () => {
expect(getBeaconBounds([])).toBeUndefined();
});
it("should return undefined when no beacons have locations", () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId));
expect(getBeaconBounds([beacon])).toBeUndefined();
});
type TestCase = [string, Beacon[], Bounds];
it.each<TestCase>([
[
"one beacon",
[london],
{ north: geo.london.lat, south: geo.london.lat, east: geo.london.lon, west: geo.london.lon },
],
[
"beacons in the northern hemisphere, west of meridian",
[london, reykjavik],
{ north: geo.reykjavik.lat, south: geo.london.lat, east: geo.london.lon, west: geo.reykjavik.lon },
],
[
"beacons in the northern hemisphere, both sides of meridian",
[london, reykjavik, paris],
// reykjavik northmost and westmost, paris southmost and eastmost
{ north: geo.reykjavik.lat, south: geo.paris.lat, east: geo.paris.lon, west: geo.reykjavik.lon },
],
[
"beacons in the southern hemisphere",
[auckland, lima],
// lima northmost and westmost, auckland southmost and eastmost
{ north: geo.lima.lat, south: geo.auckland.lat, east: geo.auckland.lon, west: geo.lima.lon },
],
[
"beacons in both hemispheres",
[auckland, lima, paris],
{ north: geo.paris.lat, south: geo.auckland.lat, east: geo.auckland.lon, west: geo.lima.lon },
],
])("gets correct bounds for %s", (_description, beacons, expectedBounds) => {
expect(getBeaconBounds(beacons)).toEqual(expectedBounds);
});
});

View file

@ -0,0 +1,106 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { M_TIMESTAMP, Beacon } from "matrix-js-sdk/src/matrix";
import { msUntilExpiry, sortBeaconsByLatestExpiry, sortBeaconsByLatestCreation } from "../../../src/utils/beacon";
import { makeBeaconInfoEvent } from "../../test-utils";
describe("beacon utils", () => {
// 14.03.2022 16:15
const now = 1647270879403;
const HOUR_MS = 3600000;
beforeEach(() => {
jest.spyOn(global.Date, "now").mockReturnValue(now);
});
afterAll(() => {
jest.spyOn(global.Date, "now").mockRestore();
});
describe("msUntilExpiry", () => {
it("returns remaining duration", () => {
const start = now - HOUR_MS;
const durationMs = HOUR_MS * 3;
expect(msUntilExpiry(start, durationMs)).toEqual(HOUR_MS * 2);
});
it("returns 0 when expiry has already passed", () => {
// created 3h ago
const start = now - HOUR_MS * 3;
// 1h durations
const durationMs = HOUR_MS;
expect(msUntilExpiry(start, durationMs)).toEqual(0);
});
});
describe("sortBeaconsByLatestExpiry()", () => {
const roomId = "!room:server";
const aliceId = "@alive:server";
// 12h old, 12h left
const beacon1 = new Beacon(
makeBeaconInfoEvent(aliceId, roomId, { timeout: HOUR_MS * 24, timestamp: now - 12 * HOUR_MS }, "$1"),
);
// 10h left
const beacon2 = new Beacon(
makeBeaconInfoEvent(aliceId, roomId, { timeout: HOUR_MS * 10, timestamp: now }, "$2"),
);
// 1ms left
const beacon3 = new Beacon(
makeBeaconInfoEvent(aliceId, roomId, { timeout: HOUR_MS + 1, timestamp: now - HOUR_MS }, "$3"),
);
const noTimestampEvent = makeBeaconInfoEvent(
aliceId,
roomId,
{ timeout: HOUR_MS + 1, timestamp: undefined },
"$3",
);
// beacon info helper defaults to date when timestamp is falsy
// hard set it to undefined
// @ts-ignore
noTimestampEvent.event.content[M_TIMESTAMP.name] = undefined;
const beaconNoTimestamp = new Beacon(noTimestampEvent);
it("sorts beacons by descending expiry time", () => {
expect([beacon2, beacon3, beacon1].sort(sortBeaconsByLatestExpiry)).toEqual([beacon1, beacon2, beacon3]);
});
it("sorts beacons with timestamps before beacons without", () => {
expect([beaconNoTimestamp, beacon3].sort(sortBeaconsByLatestExpiry)).toEqual([beacon3, beaconNoTimestamp]);
});
});
describe("sortBeaconsByLatestCreation()", () => {
const roomId = "!room:server";
const aliceId = "@alive:server";
// 12h old, 12h left
const beacon1 = new Beacon(
makeBeaconInfoEvent(aliceId, roomId, { timeout: HOUR_MS * 24, timestamp: now - 12 * HOUR_MS }, "$1"),
);
// 10h left
const beacon2 = new Beacon(
makeBeaconInfoEvent(aliceId, roomId, { timeout: HOUR_MS * 10, timestamp: now }, "$2"),
);
// 1ms left
const beacon3 = new Beacon(
makeBeaconInfoEvent(aliceId, roomId, { timeout: HOUR_MS + 1, timestamp: now - HOUR_MS }, "$3"),
);
it("sorts beacons by descending creation time", () => {
expect([beacon1, beacon2, beacon3].sort(sortBeaconsByLatestCreation)).toEqual([beacon2, beacon3, beacon1]);
});
});
});

View file

@ -0,0 +1,229 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { Mocked } from "jest-mock";
import {
GenericPosition,
GeolocationError,
getGeoUri,
mapGeolocationError,
mapGeolocationPositionToTimedGeo,
watchPosition,
} from "../../../src/utils/beacon";
import { getCurrentPosition } from "../../../src/utils/beacon/geolocation";
import { makeGeolocationPosition, mockGeolocation, getMockGeolocationPositionError } from "../../test-utils";
describe("geolocation utilities", () => {
let geolocation: Mocked<Geolocation>;
const defaultPosition = makeGeolocationPosition({});
// 14.03.2022 16:15
const now = 1647270879403;
beforeEach(() => {
geolocation = mockGeolocation();
jest.spyOn(Date, "now").mockReturnValue(now);
});
afterEach(() => {
jest.spyOn(Date, "now").mockRestore();
jest.spyOn(logger, "error").mockRestore();
});
describe("getGeoUri", () => {
it("Renders a URI with only lat and lon", () => {
const pos: GenericPosition = {
latitude: 43.2,
longitude: 12.4,
altitude: undefined,
accuracy: undefined,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4");
});
it("Nulls in location are not shown in URI", () => {
const pos: GenericPosition = {
latitude: 43.2,
longitude: 12.4,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4");
});
it("Renders a URI with 3 coords", () => {
const pos: GenericPosition = {
latitude: 43.2,
longitude: 12.4,
altitude: 332.54,
accuracy: undefined,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,332.54");
});
it("Renders a URI with accuracy", () => {
const pos: GenericPosition = {
latitude: 43.2,
longitude: 12.4,
altitude: undefined,
accuracy: 21,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4;u=21");
});
it("Renders a URI with accuracy and altitude", () => {
const pos = {
latitude: 43.2,
longitude: 12.4,
altitude: 12.3,
accuracy: 21,
timestamp: 12334,
};
expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,12.3;u=21");
});
});
describe("mapGeolocationError", () => {
beforeEach(() => {
// suppress expected errors from test log
jest.spyOn(logger, "error").mockImplementation(() => {});
});
it("returns default for other error", () => {
const error = new Error("oh no..");
expect(mapGeolocationError(error)).toEqual(GeolocationError.Default);
});
it("returns unavailable for unavailable error", () => {
const error = new Error(GeolocationError.Unavailable);
expect(mapGeolocationError(error)).toEqual(GeolocationError.Unavailable);
});
it("maps geo error permissiondenied correctly", () => {
const error = getMockGeolocationPositionError(1, "message");
expect(mapGeolocationError(error)).toEqual(GeolocationError.PermissionDenied);
});
it("maps geo position unavailable error correctly", () => {
const error = getMockGeolocationPositionError(2, "message");
expect(mapGeolocationError(error)).toEqual(GeolocationError.PositionUnavailable);
});
it("maps geo timeout error correctly", () => {
const error = getMockGeolocationPositionError(3, "message");
expect(mapGeolocationError(error)).toEqual(GeolocationError.Timeout);
});
});
describe("mapGeolocationPositionToTimedGeo()", () => {
it("maps geolocation position correctly", () => {
expect(mapGeolocationPositionToTimedGeo(defaultPosition)).toEqual({
timestamp: now,
geoUri: "geo:54.001927,-8.253491;u=1",
});
});
});
describe("watchPosition()", () => {
it("throws with unavailable error when geolocation is not available", () => {
// suppress expected errors from test log
jest.spyOn(logger, "error").mockImplementation(() => {});
// remove the mock we added
// @ts-ignore illegal assignment to readonly property
navigator.geolocation = undefined;
const positionHandler = jest.fn();
const errorHandler = jest.fn();
expect(() => watchPosition(positionHandler, errorHandler)).toThrow(GeolocationError.Unavailable);
});
it("sets up position handler with correct options", () => {
const positionHandler = jest.fn();
const errorHandler = jest.fn();
watchPosition(positionHandler, errorHandler);
const [, , options] = geolocation.watchPosition.mock.calls[0];
expect(options).toEqual({
maximumAge: 60000,
timeout: 10000,
});
});
it("returns clearWatch function", () => {
const watchId = 1;
geolocation.watchPosition.mockReturnValue(watchId);
const positionHandler = jest.fn();
const errorHandler = jest.fn();
const clearWatch = watchPosition(positionHandler, errorHandler);
clearWatch();
expect(geolocation.clearWatch).toHaveBeenCalledWith(watchId);
});
it("calls position handler with position", () => {
const positionHandler = jest.fn();
const errorHandler = jest.fn();
watchPosition(positionHandler, errorHandler);
expect(positionHandler).toHaveBeenCalledWith(defaultPosition);
});
it("maps geolocation position error and calls error handler", () => {
// suppress expected errors from test log
jest.spyOn(logger, "error").mockImplementation(() => {});
geolocation.watchPosition.mockImplementation((_callback, error) => {
error!(getMockGeolocationPositionError(1, "message"));
return -1;
});
const positionHandler = jest.fn();
const errorHandler = jest.fn();
watchPosition(positionHandler, errorHandler);
expect(errorHandler).toHaveBeenCalledWith(GeolocationError.PermissionDenied);
});
});
describe("getCurrentPosition()", () => {
it("throws with unavailable error when geolocation is not available", async () => {
// suppress expected errors from test log
jest.spyOn(logger, "error").mockImplementation(() => {});
// remove the mock we added
// @ts-ignore illegal assignment to readonly property
navigator.geolocation = undefined;
await expect(() => getCurrentPosition()).rejects.toThrow(GeolocationError.Unavailable);
});
it("throws with geolocation error when geolocation.getCurrentPosition fails", async () => {
// suppress expected errors from test log
jest.spyOn(logger, "error").mockImplementation(() => {});
const timeoutError = getMockGeolocationPositionError(3, "message");
geolocation.getCurrentPosition.mockImplementation((callback, error) => error!(timeoutError));
await expect(() => getCurrentPosition()).rejects.toThrow(GeolocationError.Timeout);
});
it("resolves with current location", async () => {
geolocation.getCurrentPosition.mockImplementation((callback, error) => callback(defaultPosition));
const result = await getCurrentPosition();
expect(result).toEqual(defaultPosition);
});
});
});

View file

@ -0,0 +1,38 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { shouldDisplayAsBeaconTile } from "../../../src/utils/beacon/timeline";
import { makeBeaconInfoEvent, stubClient } from "../../test-utils";
describe("shouldDisplayAsBeaconTile", () => {
const userId = "@user:server";
const roomId = "!room:server";
const liveBeacon = makeBeaconInfoEvent(userId, roomId, { isLive: true });
const notLiveBeacon = makeBeaconInfoEvent(userId, roomId, { isLive: false });
const memberEvent = new MatrixEvent({ type: EventType.RoomMember });
const redactedBeacon = makeBeaconInfoEvent(userId, roomId, { isLive: false });
redactedBeacon.makeRedacted(redactedBeacon, new Room(roomId, stubClient(), userId));
it("returns true for a beacon with live property set to true", () => {
expect(shouldDisplayAsBeaconTile(liveBeacon)).toBe(true);
});
it("returns true for a redacted beacon", () => {
expect(shouldDisplayAsBeaconTile(redactedBeacon)).toBe(true);
});
it("returns false for a beacon with live property set to false", () => {
expect(shouldDisplayAsBeaconTile(notLiveBeacon)).toBe(false);
});
it("returns false for a non beacon event", () => {
expect(shouldDisplayAsBeaconTile(memberEvent)).toBe(false);
});
});

View file

@ -0,0 +1,16 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Emmanuel Ezeka <eec.studies@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { textToHtmlRainbow } from "../../src/utils/colour";
describe("textToHtmlRainbow", () => {
it("correctly transform text to html without splitting the emoji in two", () => {
expect(textToHtmlRainbow("🐻")).toBe('<span data-mx-color="#ff00be">🐻</span>');
expect(textToHtmlRainbow("🐕‍🦺")).toBe('<span data-mx-color="#ff00be">🐕‍🦺</span>');
});
});

View file

@ -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
Please see LICENSE files in the repository root for full details.
*/
import { ClientEvent, ClientEventHandlerMap, SyncState } from "matrix-js-sdk/src/matrix";
import { createReconnectedListener } from "../../src/utils/connection";
describe("createReconnectedListener", () => {
let reconnectedListener: ClientEventHandlerMap[ClientEvent.Sync];
let onReconnect: jest.Mock;
beforeEach(() => {
onReconnect = jest.fn();
reconnectedListener = createReconnectedListener(onReconnect);
});
[
[SyncState.Prepared, SyncState.Syncing],
[SyncState.Syncing, SyncState.Reconnecting],
[SyncState.Reconnecting, SyncState.Syncing],
].forEach(([from, to]) => {
it(`should invoke the callback on a transition from ${from} to ${to}`, () => {
reconnectedListener(to, from);
expect(onReconnect).toHaveBeenCalled();
});
});
[
[SyncState.Syncing, SyncState.Syncing],
[SyncState.Catchup, SyncState.Error],
[SyncState.Reconnecting, SyncState.Error],
].forEach(([from, to]) => {
it(`should not invoke the callback on a transition from ${from} to ${to}`, () => {
reconnectedListener(to, from);
expect(onReconnect).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,26 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { EncryptedFile } from "matrix-js-sdk/src/types";
import { createVoiceMessageContent } from "../../src/utils/createVoiceMessageContent";
describe("createVoiceMessageContent", () => {
it("should create a voice message content", () => {
expect(
createVoiceMessageContent(
"mxc://example.com/file",
"ogg/opus",
23000,
42000,
{} as unknown as EncryptedFile,
[1, 2, 3],
),
).toMatchSnapshot();
});
});

View file

@ -0,0 +1,72 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { Mocked, mocked } from "jest-mock";
import { Device, MatrixClient } from "matrix-js-sdk/src/matrix";
import { getDeviceCryptoInfo, getUserDeviceIds } from "../../../src/utils/crypto/deviceInfo";
import { getMockClientWithEventEmitter, mockClientMethodsCrypto } from "../../test-utils";
describe("getDeviceCryptoInfo()", () => {
let mockClient: Mocked<MatrixClient>;
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsCrypto() });
});
it("should return undefined on clients with no crypto", async () => {
jest.spyOn(mockClient, "getCrypto").mockReturnValue(undefined);
await expect(getDeviceCryptoInfo(mockClient, "@user:id", "device_id")).resolves.toBeUndefined();
});
it("should return undefined for unknown users", async () => {
mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(new Map());
await expect(getDeviceCryptoInfo(mockClient, "@user:id", "device_id")).resolves.toBeUndefined();
});
it("should return undefined for unknown devices", async () => {
mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(new Map([["@user:id", new Map()]]));
await expect(getDeviceCryptoInfo(mockClient, "@user:id", "device_id")).resolves.toBeUndefined();
});
it("should return the right result for known devices", async () => {
const mockDevice = { deviceId: "device_id" } as Device;
mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(
new Map([["@user:id", new Map([["device_id", mockDevice]])]]),
);
await expect(getDeviceCryptoInfo(mockClient, "@user:id", "device_id")).resolves.toBe(mockDevice);
expect(mockClient.getCrypto()!.getUserDeviceInfo).toHaveBeenCalledWith(["@user:id"], undefined);
});
});
describe("getUserDeviceIds", () => {
let mockClient: Mocked<MatrixClient>;
beforeEach(() => {
mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsCrypto() });
});
it("should return empty set on clients with no crypto", async () => {
jest.spyOn(mockClient, "getCrypto").mockReturnValue(undefined);
await expect(getUserDeviceIds(mockClient, "@user:id")).resolves.toEqual(new Set());
});
it("should return empty set for unknown users", async () => {
mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(new Map());
await expect(getUserDeviceIds(mockClient, "@user:id")).resolves.toEqual(new Set());
});
it("should return the right result for known users", async () => {
const mockDevice = { deviceId: "device_id" } as Device;
mocked(mockClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(
new Map([["@user:id", new Map([["device_id", mockDevice]])]]),
);
await expect(getUserDeviceIds(mockClient, "@user:id")).resolves.toEqual(new Set(["device_id"]));
expect(mockClient.getCrypto()!.getUserDeviceInfo).toHaveBeenCalledWith(["@user:id"]);
});
});

View file

@ -0,0 +1,60 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { shouldForceDisableEncryption } from "../../../src/utils/crypto/shouldForceDisableEncryption";
import { getMockClientWithEventEmitter } from "../../test-utils";
describe("shouldForceDisableEncryption()", () => {
const mockClient = getMockClientWithEventEmitter({
getClientWellKnown: jest.fn(),
});
beforeEach(() => {
mockClient.getClientWellKnown.mockReturnValue(undefined);
});
it("should return false when there is no e2ee well known", () => {
expect(shouldForceDisableEncryption(mockClient)).toEqual(false);
});
it("should return false when there is no force_disable property", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
// empty
},
});
expect(shouldForceDisableEncryption(mockClient)).toEqual(false);
});
it("should return false when force_disable property is falsy", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
force_disable: false,
},
});
expect(shouldForceDisableEncryption(mockClient)).toEqual(false);
});
it("should return false when force_disable property is not equal to true", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
force_disable: 1,
},
});
expect(shouldForceDisableEncryption(mockClient)).toEqual(false);
});
it("should return true when force_disable property is true", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
force_disable: true,
},
});
expect(shouldForceDisableEncryption(mockClient)).toEqual(true);
});
});

View file

@ -0,0 +1,121 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import BasePlatform from "../../../src/BasePlatform";
import { IConfigOptions } from "../../../src/IConfigOptions";
import { getDeviceClientInformation, recordClientInformation } from "../../../src/utils/device/clientInformation";
import { getMockClientWithEventEmitter } from "../../test-utils";
import { DEFAULTS } from "../../../src/SdkConfig";
import { DeepReadonly } from "../../../src/@types/common";
describe("recordClientInformation()", () => {
const deviceId = "my-device-id";
const version = "1.2.3";
const isElectron = window.electron;
const mockClient = getMockClientWithEventEmitter({
getDeviceId: jest.fn().mockReturnValue(deviceId),
setAccountData: jest.fn(),
});
const sdkConfig: DeepReadonly<IConfigOptions> = {
...DEFAULTS,
brand: "Test Brand",
element_call: { url: "", use_exclusively: false, brand: "Element Call" },
};
const platform = {
getAppVersion: jest.fn().mockResolvedValue(version),
} as unknown as BasePlatform;
beforeEach(() => {
jest.clearAllMocks();
window.electron = false;
});
afterAll(() => {
// restore global
window.electron = isElectron;
});
it("saves client information without url for electron clients", async () => {
window.electron = true;
await recordClientInformation(mockClient, sdkConfig, platform);
expect(mockClient.setAccountData).toHaveBeenCalledWith(`io.element.matrix_client_information.${deviceId}`, {
name: sdkConfig.brand,
version,
url: undefined,
});
});
it("saves client information with url for non-electron clients", async () => {
await recordClientInformation(mockClient, sdkConfig, platform);
expect(mockClient.setAccountData).toHaveBeenCalledWith(`io.element.matrix_client_information.${deviceId}`, {
name: sdkConfig.brand,
version,
url: "localhost",
});
});
});
describe("getDeviceClientInformation()", () => {
const deviceId = "my-device-id";
const mockClient = getMockClientWithEventEmitter({
getAccountData: jest.fn(),
});
beforeEach(() => {
jest.resetAllMocks();
});
it("returns an empty object when no event exists for the device", () => {
expect(getDeviceClientInformation(mockClient, deviceId)).toEqual({});
expect(mockClient.getAccountData).toHaveBeenCalledWith(`io.element.matrix_client_information.${deviceId}`);
});
it("returns client information for the device", () => {
const eventContent = {
name: "Element Web",
version: "1.2.3",
url: "test.com",
};
const event = new MatrixEvent({
type: `io.element.matrix_client_information.${deviceId}`,
content: eventContent,
});
mockClient.getAccountData.mockReturnValue(event);
expect(getDeviceClientInformation(mockClient, deviceId)).toEqual(eventContent);
});
it("excludes values with incorrect types", () => {
const eventContent = {
extraField: "hello",
name: "Element Web",
// wrong format
version: { value: "1.2.3" },
url: "test.com",
};
const event = new MatrixEvent({
type: `io.element.matrix_client_information.${deviceId}`,
content: eventContent,
});
mockClient.getAccountData.mockReturnValue(event);
// invalid fields excluded
expect(getDeviceClientInformation(mockClient, deviceId)).toEqual({
name: eventContent.name,
url: eventContent.url,
});
});
});

View file

@ -0,0 +1,135 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { DeviceType, ExtendedDeviceInformation, parseUserAgent } from "../../../src/utils/device/parseUserAgent";
const makeDeviceExtendedInfo = (
deviceType: DeviceType,
deviceModel?: string,
deviceOperatingSystem?: string,
clientName?: string,
clientVersion?: string,
): ExtendedDeviceInformation => ({
deviceType,
deviceModel,
deviceOperatingSystem,
client: clientName && [clientName, clientVersion].filter(Boolean).join(" "),
});
/* eslint-disable max-len */
const ANDROID_UA = [
// New User Agent Implementation
"Element dbg/1.5.0-dev (Xiaomi Mi 9T; Android 11; RKQ1.200826.002 test-keys; Flavour GooglePlay; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Samsung SM-G960F; Android 6.0.1; RKQ1.200826.002; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google Nexus 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google (Nexus) 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google (Nexus) (5); Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
// Legacy User Agent Implementation
"Element/1.0.0 (Linux; U; Android 6.0.1; SM-A510F Build/MMB29; Flavour GPlay; MatrixAndroidSdk2 1.0)",
"Element/1.0.0 (Linux; Android 7.0; SM-G610M Build/NRD90M; Flavour GPlay; MatrixAndroidSdk2 1.0)",
"Mozilla/5.0 (Linux; Android 9; SM-G973U Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36",
];
const ANDROID_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Mobile, "Xiaomi Mi 9T", "Android 11"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-G960F", "Android 6.0.1"),
makeDeviceExtendedInfo(DeviceType.Mobile, "LG Nexus 5", "Android 7.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Google (Nexus) 5", "Android 7.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Google (Nexus) (5)", "Android 7.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-A510F", "Android 6.0.1"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-G610M", "Android 7.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-G973U", "Android 9", "Chrome", "69.0.3497.100"),
];
const IOS_UA = [
"Element/1.8.21 (iPhone; iOS 15.2; Scale/3.00)",
"Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00)",
"Element/1.8.21 (iPad Pro (11-inch); iOS 15.2; Scale/3.00)",
"Element/1.8.21 (iPad Pro (12.9-inch) (3rd generation); iOS 15.2; Scale/3.00)",
"Mozilla/5.0 (iPad; CPU OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4",
"Mozilla/5.0 (iPhone; CPU iPhone OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4",
];
const IOS_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Mobile, "Apple iPhone", "iOS 15.2"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Apple iPhone XS Max", "iOS 15.2"),
makeDeviceExtendedInfo(DeviceType.Mobile, "iPad Pro (11-inch)", "iOS 15.2"),
makeDeviceExtendedInfo(DeviceType.Mobile, "iPad Pro (12.9-inch) (3rd generation)", "iOS 15.2"),
makeDeviceExtendedInfo(DeviceType.Web, "Apple iPad", "iOS", "Mobile Safari", "8.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Apple iPhone", "iOS 8.4.1", "Mobile Safari", "8.0"),
];
const DESKTOP_UA = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102" +
" Electron/20.1.1 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102 Electron/20.1.1 Safari/537.36",
];
const DESKTOP_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Desktop, "Apple Macintosh", "Mac OS", "Electron", "20.1.1"),
makeDeviceExtendedInfo(DeviceType.Desktop, undefined, "Windows", "Electron", "20.1.1"),
];
const WEB_UA = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:39.0) Gecko/20100101 Firefox/39.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/600.3.18 (KHTML, like Gecko) Version/8.0.3 Safari/600.3.18",
"Mozilla/5.0 (Windows NT 6.0; rv:40.0) Gecko/20100101 Firefox/40.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246",
];
const WEB_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Web, "Apple Macintosh", "Mac OS", "Chrome", "104.0.5112.102"),
makeDeviceExtendedInfo(DeviceType.Web, undefined, "Windows", "Chrome", "104.0.5112.102"),
makeDeviceExtendedInfo(DeviceType.Web, "Apple Macintosh", "Mac OS", "Firefox", "39.0"),
makeDeviceExtendedInfo(DeviceType.Web, "Apple Macintosh", "Mac OS", "Safari", "8.0.3"),
makeDeviceExtendedInfo(DeviceType.Web, undefined, "Windows", "Firefox", "40.0"),
makeDeviceExtendedInfo(DeviceType.Web, undefined, "Windows", "Edge", "12.246"),
];
const MISC_UA = [
"AppleTV11,1/11.1",
"Curl Client/1.0",
"banana",
"",
// fluffy chat ios
"Dart/2.18 (dart:io)",
];
const MISC_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Unknown, "Apple Apple TV", undefined, undefined, undefined),
makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined),
makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined),
makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined),
makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined),
];
/* eslint-disable max-len */
describe("parseUserAgent()", () => {
it("returns deviceType unknown when user agent is falsy", () => {
expect(parseUserAgent(undefined)).toEqual({
deviceType: DeviceType.Unknown,
});
});
type TestCase = [string, ExtendedDeviceInformation];
const testPlatform = (platform: string, userAgents: string[], results: ExtendedDeviceInformation[]): void => {
const testCases: TestCase[] = userAgents.map((userAgent, index) => [userAgent, results[index]]);
describe(`on platform ${platform}`, () => {
it.each(testCases)("should parse the user agent correctly - %s", (userAgent, expectedResult) => {
expect(parseUserAgent(userAgent)).toEqual(expectedResult);
});
});
};
testPlatform("Android", ANDROID_UA, ANDROID_EXPECTED_RESULT);
testPlatform("iOS", IOS_UA, IOS_EXPECTED_RESULT);
testPlatform("Desktop", DESKTOP_UA, DESKTOP_EXPECTED_RESULT);
testPlatform("Web", WEB_UA, WEB_EXPECTED_RESULT);
testPlatform("Misc", MISC_UA, MISC_EXPECTED_RESULT);
});

View file

@ -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
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import {
isBulkUnverifiedDeviceReminderSnoozed,
snoozeBulkUnverifiedDeviceReminder,
} from "../../../src/utils/device/snoozeBulkUnverifiedDeviceReminder";
const SNOOZE_KEY = "mx_snooze_bulk_unverified_device_nag";
describe("snooze bulk unverified device nag", () => {
const localStorageSetSpy = jest.spyOn(localStorage.__proto__, "setItem");
const localStorageGetSpy = jest.spyOn(localStorage.__proto__, "getItem");
const localStorageRemoveSpy = jest.spyOn(localStorage.__proto__, "removeItem");
// 14.03.2022 16:15
const now = 1647270879403;
beforeEach(() => {
localStorageSetSpy.mockClear().mockImplementation(() => {});
localStorageGetSpy.mockClear().mockReturnValue(null);
localStorageRemoveSpy.mockClear().mockImplementation(() => {});
jest.spyOn(Date, "now").mockReturnValue(now);
});
afterAll(() => {
jest.restoreAllMocks();
});
describe("snoozeBulkUnverifiedDeviceReminder()", () => {
it("sets the current time in local storage", () => {
snoozeBulkUnverifiedDeviceReminder();
expect(localStorageSetSpy).toHaveBeenCalledWith(SNOOZE_KEY, now.toString());
});
it("catches an error from localstorage", () => {
const loggerErrorSpy = jest.spyOn(logger, "error");
localStorageSetSpy.mockImplementation(() => {
throw new Error("oups");
});
snoozeBulkUnverifiedDeviceReminder();
expect(loggerErrorSpy).toHaveBeenCalled();
});
});
describe("isBulkUnverifiedDeviceReminderSnoozed()", () => {
it("returns false when there is no snooze in storage", () => {
const result = isBulkUnverifiedDeviceReminderSnoozed();
expect(localStorageGetSpy).toHaveBeenCalledWith(SNOOZE_KEY);
expect(result).toBe(false);
});
it("catches an error from localstorage and returns false", () => {
const loggerErrorSpy = jest.spyOn(logger, "error");
localStorageGetSpy.mockImplementation(() => {
throw new Error("oups");
});
const result = isBulkUnverifiedDeviceReminderSnoozed();
expect(result).toBe(false);
expect(loggerErrorSpy).toHaveBeenCalled();
});
it("returns false when snooze timestamp in storage is not a number", () => {
localStorageGetSpy.mockReturnValue("test");
const result = isBulkUnverifiedDeviceReminderSnoozed();
expect(result).toBe(false);
});
it("returns false when snooze timestamp in storage is over a week ago", () => {
const msDay = 1000 * 60 * 60 * 24;
// snoozed 8 days ago
localStorageGetSpy.mockReturnValue(now - msDay * 8);
const result = isBulkUnverifiedDeviceReminderSnoozed();
expect(result).toBe(false);
});
it("returns true when snooze timestamp in storage is less than a week ago", () => {
const msDay = 1000 * 60 * 60 * 24;
// snoozed 8 days ago
localStorageGetSpy.mockReturnValue(now - msDay * 6);
const result = isBulkUnverifiedDeviceReminderSnoozed();
expect(result).toBe(true);
});
});
});

View file

@ -0,0 +1,200 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { ClientEvent, MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import DMRoomMap from "../../src/utils/DMRoomMap";
import { createTestClient } from "../test-utils";
import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom";
import * as dmModule from "../../src/utils/direct-messages";
import dis from "../../src/dispatcher/dispatcher";
import { Action } from "../../src/dispatcher/actions";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import { waitForRoomReadyAndApplyAfterCreateCallbacks } from "../../src/utils/local-room";
import { findDMRoom } from "../../src/utils/dm/findDMRoom";
import { createDmLocalRoom } from "../../src/utils/dm/createDmLocalRoom";
import { startDm } from "../../src/utils/dm/startDm";
import { Member } from "../../src/utils/direct-messages";
import { resolveThreePids } from "../../src/utils/threepids";
jest.mock("../../src/utils/rooms", () => ({
...(jest.requireActual("../../src/utils/rooms") as object),
privateShouldBeEncrypted: jest.fn(),
}));
jest.mock("../../src/createRoom", () => ({
...(jest.requireActual("../../src/createRoom") as object),
canEncryptToAllUsers: jest.fn(),
}));
jest.mock("../../src/utils/local-room", () => ({
waitForRoomReadyAndApplyAfterCreateCallbacks: jest.fn(),
}));
jest.mock("../../src/utils/dm/findDMForUser", () => ({
findDMForUser: jest.fn(),
}));
jest.mock("../../src/utils/dm/findDMRoom", () => ({
findDMRoom: jest.fn(),
}));
jest.mock("../../src/utils/dm/createDmLocalRoom", () => ({
createDmLocalRoom: jest.fn(),
}));
jest.mock("../../src/utils/dm/startDm", () => ({
startDm: jest.fn(),
}));
jest.mock("../../src/utils/threepids", () => ({
resolveThreePids: jest.fn().mockImplementation(async (members: Member[]) => {
return members;
}),
}));
describe("direct-messages", () => {
const userId1 = "@user1:example.com";
const member1 = new dmModule.DirectoryMember({ user_id: userId1 });
let room1: Room;
let localRoom: LocalRoom;
let dmRoomMap: DMRoomMap;
let mockClient: MatrixClient;
let roomEvents: Room[];
beforeEach(() => {
mockClient = createTestClient();
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
roomEvents = [];
mockClient.on(ClientEvent.Room, (room: Room) => {
roomEvents.push(room);
});
room1 = new Room("!room1:example.com", mockClient, userId1);
room1.getMyMembership = () => KnownMembership.Join;
localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", mockClient, userId1);
dmRoomMap = {
getDMRoomForIdentifiers: jest.fn(),
getDMRoomsForUserId: jest.fn(),
} as unknown as DMRoomMap;
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
jest.spyOn(dis, "dispatch");
jest.spyOn(logger, "warn");
jest.useFakeTimers();
jest.setSystemTime(new Date(2022, 7, 4, 11, 12, 30, 42));
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe("startDmOnFirstMessage", () => {
describe("if no room exists", () => {
beforeEach(() => {
mocked(findDMRoom).mockReturnValue(null);
});
it("should create a local room and dispatch a view room event", async () => {
mocked(createDmLocalRoom).mockResolvedValue(localRoom);
const members = [member1];
const roomId = await dmModule.startDmOnFirstMessage(mockClient, members);
expect(roomId).toBe(localRoom.roomId);
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: roomId,
joining: false,
targets: [member1],
});
// assert, that startDmOnFirstMessage tries to resolve 3rd-party IDs
expect(resolveThreePids).toHaveBeenCalledWith(members, mockClient);
});
it("should work when resolveThreePids raises an error", async () => {
const error = new Error("error 4711");
mocked(resolveThreePids).mockRejectedValue(error);
mocked(createDmLocalRoom).mockResolvedValue(localRoom);
const members = [member1];
const roomId = await dmModule.startDmOnFirstMessage(mockClient, members);
expect(roomId).toBe(localRoom.roomId);
// ensure that startDmOnFirstMessage tries to resolve 3rd-party IDs
expect(resolveThreePids).toHaveBeenCalledWith(members, mockClient);
// ensure that the error is logged
expect(logger.warn).toHaveBeenCalledWith("Error resolving 3rd-party members", error);
});
});
describe("if a room exists", () => {
beforeEach(() => {
mocked(findDMRoom).mockReturnValue(room1);
});
it("should return the room and dispatch a view room event", async () => {
const roomId = await dmModule.startDmOnFirstMessage(mockClient, [member1]);
expect(roomId).toBe(room1.roomId);
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room1.roomId,
should_peek: false,
joining: false,
metricsTrigger: "MessageUser",
});
});
});
});
describe("createRoomFromLocalRoom", () => {
[LocalRoomState.CREATING, LocalRoomState.CREATED, LocalRoomState.ERROR].forEach((state: LocalRoomState) => {
it(`should do nothing for room in state ${state}`, async () => {
localRoom.state = state;
await dmModule.createRoomFromLocalRoom(mockClient, localRoom);
expect(localRoom.state).toBe(state);
expect(startDm).not.toHaveBeenCalled();
});
});
describe("on startDm error", () => {
beforeEach(() => {
mocked(startDm).mockRejectedValue(true);
});
it("should set the room state to error", async () => {
await dmModule.createRoomFromLocalRoom(mockClient, localRoom);
expect(localRoom.state).toBe(LocalRoomState.ERROR);
});
});
describe("on startDm success", () => {
beforeEach(() => {
mocked(waitForRoomReadyAndApplyAfterCreateCallbacks).mockResolvedValue(room1.roomId);
mocked(startDm).mockResolvedValue(room1.roomId);
});
it("should set the room into creating state and call waitForRoomReadyAndApplyAfterCreateCallbacks", async () => {
const result = await dmModule.createRoomFromLocalRoom(mockClient, localRoom);
expect(result).toBe(room1.roomId);
expect(localRoom.state).toBe(LocalRoomState.CREATING);
expect(waitForRoomReadyAndApplyAfterCreateCallbacks).toHaveBeenCalledWith(
mockClient,
localRoom,
room1.roomId,
);
});
});
});
});

View file

@ -0,0 +1,107 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { EventType, KNOWN_SAFE_ROOM_VERSION, MatrixClient } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { canEncryptToAllUsers } from "../../../src/createRoom";
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../src/models/LocalRoom";
import { DirectoryMember, Member, ThreepidMember } from "../../../src/utils/direct-messages";
import { createDmLocalRoom } from "../../../src/utils/dm/createDmLocalRoom";
import { privateShouldBeEncrypted } from "../../../src/utils/rooms";
import { createTestClient } from "../../test-utils";
jest.mock("../../../src/utils/rooms", () => ({
privateShouldBeEncrypted: jest.fn(),
}));
jest.mock("../../../src/createRoom", () => ({
canEncryptToAllUsers: jest.fn(),
}));
function assertLocalRoom(room: LocalRoom, targets: Member[], encrypted: boolean) {
expect(room.roomId).toBe(LOCAL_ROOM_ID_PREFIX + "t1");
expect(room.name).toBe(targets.length ? targets[0].name : "Empty Room");
expect(room.encrypted).toBe(encrypted);
expect(room.targets).toEqual(targets);
expect(room.getMyMembership()).toBe(KnownMembership.Join);
const roomCreateEvent = room.currentState.getStateEvents(EventType.RoomCreate)[0];
expect(roomCreateEvent).toBeDefined();
expect(roomCreateEvent.getContent()["room_version"]).toBe(KNOWN_SAFE_ROOM_VERSION);
// check that the user and all targets are joined
expect(room.getMember("@userId:matrix.org")?.membership).toBe(KnownMembership.Join);
targets.forEach((target: Member) => {
expect(room.getMember(target.userId)?.membership).toBe(KnownMembership.Join);
});
if (encrypted) {
const encryptionEvent = room.currentState.getStateEvents(EventType.RoomEncryption)[0];
expect(encryptionEvent).toBeDefined();
}
}
describe("createDmLocalRoom", () => {
let mockClient: MatrixClient;
const userId1 = "@user1:example.com";
const member1 = new DirectoryMember({ user_id: userId1 });
const member2 = new ThreepidMember("user2");
beforeEach(() => {
mockClient = createTestClient();
});
describe("when rooms should be encrypted", () => {
beforeEach(() => {
mocked(privateShouldBeEncrypted).mockReturnValue(true);
});
it("should create an encrytped room for 3PID targets", async () => {
const room = await createDmLocalRoom(mockClient, [member2]);
expect(mockClient.store.storeRoom).toHaveBeenCalledWith(room);
assertLocalRoom(room, [member2], true);
});
describe("for MXID targets with encryption available", () => {
beforeEach(() => {
mocked(canEncryptToAllUsers).mockResolvedValue(true);
});
it("should create an encrypted room", async () => {
const room = await createDmLocalRoom(mockClient, [member1]);
expect(mockClient.store.storeRoom).toHaveBeenCalledWith(room);
assertLocalRoom(room, [member1], true);
});
});
describe("for MXID targets with encryption unavailable", () => {
beforeEach(() => {
mocked(canEncryptToAllUsers).mockResolvedValue(false);
});
it("should create an unencrypted room", async () => {
const room = await createDmLocalRoom(mockClient, [member1]);
expect(mockClient.store.storeRoom).toHaveBeenCalledWith(room);
assertLocalRoom(room, [member1], false);
});
});
});
describe("if rooms should not be encrypted", () => {
beforeEach(() => {
mocked(privateShouldBeEncrypted).mockReturnValue(false);
});
it("should create an unencrypted room", async () => {
const room = await createDmLocalRoom(mockClient, [member1]);
assertLocalRoom(room, [member1], false);
});
});
});

View file

@ -0,0 +1,69 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { filterValidMDirect } from "../../../src/utils/dm/filterValidMDirect";
const roomId1 = "!room1:example.com";
const roomId2 = "!room2:example.com";
const userId1 = "@user1:example.com";
const userId2 = "@user2:example.com";
const userId3 = "@user3:example.com";
describe("filterValidMDirect", () => {
it("should return an empty object as valid content", () => {
expect(filterValidMDirect({})).toEqual({
valid: true,
filteredContent: {},
});
});
it("should return valid content", () => {
expect(
filterValidMDirect({
[userId1]: [roomId1, roomId2],
[userId2]: [roomId1],
}),
).toEqual({
valid: true,
filteredContent: {
[userId1]: [roomId1, roomId2],
[userId2]: [roomId1],
},
});
});
it("should return an empy object for null", () => {
expect(filterValidMDirect(null)).toEqual({
valid: false,
filteredContent: {},
});
});
it("should return an empy object for a non-object", () => {
expect(filterValidMDirect(23)).toEqual({
valid: false,
filteredContent: {},
});
});
it("should only return valid content", () => {
const invalidContent = {
[userId1]: [23],
[userId2]: [roomId2],
[userId3]: "room1",
};
expect(filterValidMDirect(invalidContent)).toEqual({
valid: false,
filteredContent: {
[userId1]: [],
[userId2]: [roomId2],
},
});
});
});

View file

@ -0,0 +1,178 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { createTestClient, makeMembershipEvent, mkThirdPartyInviteEvent } from "../../test-utils";
import { LocalRoom } from "../../../src/models/LocalRoom";
import { findDMForUser } from "../../../src/utils/dm/findDMForUser";
import { getFunctionalMembers } from "../../../src/utils/room/getFunctionalMembers";
jest.mock("../../../src/utils/room/getFunctionalMembers", () => ({
getFunctionalMembers: jest.fn(),
}));
describe("findDMForUser", () => {
const userId1 = "@user1:example.com";
const userId2 = "@user2:example.com";
const userId3 = "@user3:example.com";
const botId = "@bot:example.com";
const thirdPartyId = "party@example.com";
let room1: Room;
let room2: LocalRoom;
let room3: Room;
let room4: Room;
let room5: Room;
let room6: Room;
let room7: Room;
const unknownRoomId = "!unknown:example.com";
let dmRoomMap: DMRoomMap;
let mockClient: MatrixClient;
beforeEach(() => {
mockClient = createTestClient();
// always return the bot user as functional member
mocked(getFunctionalMembers).mockReturnValue([botId]);
room1 = new Room("!room1:example.com", mockClient, userId1);
room1.getMyMembership = () => KnownMembership.Join;
room1.currentState.setStateEvents([
makeMembershipEvent(room1.roomId, userId1, KnownMembership.Join),
makeMembershipEvent(room1.roomId, userId2, KnownMembership.Join),
]);
// this should not be a DM room because it is a local room
room2 = new LocalRoom("!room2:example.com", mockClient, userId1);
room2.getMyMembership = () => KnownMembership.Join;
room2.getLastActiveTimestamp = () => 100;
room3 = new Room("!room3:example.com", mockClient, userId1);
room3.getMyMembership = () => KnownMembership.Join;
room3.currentState.setStateEvents([
makeMembershipEvent(room3.roomId, userId1, KnownMembership.Join),
makeMembershipEvent(room3.roomId, userId2, KnownMembership.Join),
// Adding the bot user here. Should be excluded when determining if the room is a DM.
makeMembershipEvent(room3.roomId, botId, KnownMembership.Join),
]);
// this should not be a DM room because it has only one joined user
room4 = new Room("!room4:example.com", mockClient, userId1);
room4.getMyMembership = () => KnownMembership.Join;
room4.currentState.setStateEvents([
makeMembershipEvent(room4.roomId, userId1, KnownMembership.Invite),
makeMembershipEvent(room4.roomId, userId2, KnownMembership.Join),
]);
// this should not be a DM room because it has no users
room5 = new Room("!room5:example.com", mockClient, userId1);
room5.getLastActiveTimestamp = () => 100;
// room not correctly stored in userId → room map; should be found by the "all rooms" fallback
room6 = new Room("!room6:example.com", mockClient, userId1);
room6.getMyMembership = () => KnownMembership.Join;
room6.currentState.setStateEvents([
makeMembershipEvent(room6.roomId, userId1, KnownMembership.Join),
makeMembershipEvent(room6.roomId, userId3, KnownMembership.Join),
]);
// room with pending third-party invite
room7 = new Room("!room7:example.com", mockClient, userId1);
room7.getMyMembership = () => KnownMembership.Join;
room7.currentState.setStateEvents([
makeMembershipEvent(room7.roomId, userId1, KnownMembership.Join),
mkThirdPartyInviteEvent(thirdPartyId, "third-party", room7.roomId),
]);
mocked(mockClient.getRoom).mockImplementation((roomId: string) => {
return (
{
[room1.roomId]: room1,
[room2.roomId]: room2,
[room3.roomId]: room3,
[room4.roomId]: room4,
[room5.roomId]: room5,
[room6.roomId]: room6,
[room7.roomId]: room7,
}[roomId] || null
);
});
dmRoomMap = {
getDMRoomForIdentifiers: jest.fn(),
getDMRoomsForUserId: jest.fn(),
getRoomIds: jest.fn().mockReturnValue(
new Set([
room1.roomId,
room2.roomId,
room3.roomId,
room4.roomId,
room5.roomId,
room6.roomId,
room7.roomId,
unknownRoomId, // this room does not exist in client
]),
),
} as unknown as DMRoomMap;
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
mocked(dmRoomMap.getDMRoomsForUserId).mockImplementation((userId: string) => {
if (userId === userId1) {
return [room1.roomId, room2.roomId, room3.roomId, room4.roomId, room5.roomId, unknownRoomId];
}
if (userId === thirdPartyId) {
return [room7.roomId];
}
return [];
});
});
describe("for an empty DM room list", () => {
beforeEach(() => {
mocked(dmRoomMap.getDMRoomsForUserId).mockReturnValue([]);
mocked(dmRoomMap.getRoomIds).mockReturnValue(new Set());
});
it("should return undefined", () => {
expect(findDMForUser(mockClient, userId1)).toBeUndefined();
});
});
it("should find a room ordered by last activity 1", () => {
room1.getLastActiveTimestamp = () => 2;
room3.getLastActiveTimestamp = () => 1;
expect(findDMForUser(mockClient, userId1)).toBe(room1);
});
it("should find a room ordered by last activity 2", () => {
room1.getLastActiveTimestamp = () => 1;
room3.getLastActiveTimestamp = () => 2;
expect(findDMForUser(mockClient, userId1)).toBe(room3);
});
it("should find a room by the 'all rooms' fallback", () => {
room1.getLastActiveTimestamp = () => 1;
room6.getLastActiveTimestamp = () => 2;
expect(findDMForUser(mockClient, userId3)).toBe(room6);
});
it("should find a room with a pending third-party invite", () => {
expect(findDMForUser(mockClient, thirdPartyId)).toBe(room7);
});
it("should not find a room for an unknown Id", () => {
expect(findDMForUser(mockClient, "@unknown:example.com")).toBe(undefined);
});
});

View file

@ -0,0 +1,62 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { DirectoryMember, ThreepidMember } from "../../../src/utils/direct-messages";
import { findDMForUser } from "../../../src/utils/dm/findDMForUser";
import { findDMRoom } from "../../../src/utils/dm/findDMRoom";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { createTestClient } from "../../test-utils";
jest.mock("../../../src/utils/dm/findDMForUser", () => ({
findDMForUser: jest.fn(),
}));
describe("findDMRoom", () => {
const userId1 = "@user1:example.com";
const member1 = new DirectoryMember({ user_id: userId1 });
const member2 = new ThreepidMember("user2");
let mockClient: MatrixClient;
let room1: Room;
let dmRoomMap: DMRoomMap;
beforeEach(() => {
mockClient = createTestClient();
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
room1 = new Room("!room1:example.com", mockClient, userId1);
dmRoomMap = {
getDMRoomForIdentifiers: jest.fn(),
getDMRoomsForUserId: jest.fn(),
} as unknown as DMRoomMap;
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
});
it("should return the room for a single target with a room", () => {
mocked(findDMForUser).mockReturnValue(room1);
expect(findDMRoom(mockClient, [member1])).toBe(room1);
});
it("should return undefined for a single target without a room", () => {
mocked(findDMForUser).mockReturnValue(undefined);
expect(findDMRoom(mockClient, [member1])).toBeNull();
});
it("should return the room for 2 targets with a room", () => {
mocked(dmRoomMap.getDMRoomForIdentifiers).mockReturnValue(room1);
expect(findDMRoom(mockClient, [member1, member2])).toBe(room1);
});
it("should return null for 2 targets without a room", () => {
mocked(dmRoomMap.getDMRoomForIdentifiers).mockReturnValue(null);
expect(findDMRoom(mockClient, [member1, member2])).toBeNull();
});
});

View file

@ -0,0 +1,59 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { getEnumValues, isEnumValue } from "../../src/utils/enums";
enum TestStringEnum {
First = "__first__",
Second = "__second__",
}
enum TestNumberEnum {
FirstKey = 10,
SecondKey = 20,
}
describe("enums", () => {
describe("getEnumValues", () => {
it("should work on string enums", () => {
const result = getEnumValues(TestStringEnum);
expect(result).toBeDefined();
expect(result).toHaveLength(2);
expect(result).toEqual(["__first__", "__second__"]);
});
it("should work on number enums", () => {
const result = getEnumValues(TestNumberEnum);
expect(result).toBeDefined();
expect(result).toHaveLength(2);
expect(result).toEqual([10, 20]);
});
});
describe("isEnumValue", () => {
it("should return true on values in a string enum", () => {
const result = isEnumValue(TestStringEnum, "__first__");
expect(result).toBe(true);
});
it("should return false on values not in a string enum", () => {
const result = isEnumValue(TestStringEnum, "not a value");
expect(result).toBe(false);
});
it("should return true on values in a number enum", () => {
const result = isEnumValue(TestNumberEnum, 10);
expect(result).toBe(true);
});
it("should return false on values not in a number enum", () => {
const result = isEnumValue(TestStringEnum, 99);
expect(result).toBe(false);
});
});
});

View file

@ -0,0 +1,341 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { render } from "jest-matrix-react";
import {
IContent,
MatrixClient,
MatrixEvent,
Room,
RoomMember,
RelationType,
EventType,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import { IExportOptions, ExportType, ExportFormat } from "../../src/utils/exportUtils/exportUtils";
import PlainTextExporter from "../../src/utils/exportUtils/PlainTextExport";
import HTMLExporter from "../../src/utils/exportUtils/HtmlExport";
import * as TestUtilsMatrix from "../test-utils";
import { stubClient } from "../test-utils";
let client: MatrixClient;
const MY_USER_ID = "@me:here";
function generateRoomId() {
return "!" + Math.random().toString().slice(2, 10) + ":domain";
}
interface ITestContent extends IContent {
expectedText: string;
}
describe("export", function () {
const setProgressText = jest.fn();
let mockExportOptions: IExportOptions;
let mockRoom: Room;
let ts0: number;
let events: MatrixEvent[];
beforeEach(() => {
stubClient();
client = MatrixClientPeg.safeGet();
client.getUserId = () => {
return MY_USER_ID;
};
mockExportOptions = {
numberOfMessages: 5,
maxSize: 100 * 1024 * 1024,
attachmentsIncluded: false,
};
function createRoom() {
const room = new Room(generateRoomId(), client, client.getUserId()!);
return room;
}
mockRoom = createRoom();
ts0 = Date.now();
events = mkEvents();
jest.spyOn(client, "getRoom").mockReturnValue(mockRoom);
});
function mkRedactedEvent(i = 0) {
return new MatrixEvent({
type: "m.room.message",
sender: MY_USER_ID,
content: {},
unsigned: {
age: 72,
transaction_id: "m1212121212.23",
redacted_because: {
content: {},
origin_server_ts: ts0 + i * 1000,
redacts: "$9999999999999999999999999999999999999999998",
sender: "@me:here",
type: EventType.RoomRedaction,
unsigned: {
age: 94,
transaction_id: "m1111111111.1",
},
event_id: "$9999999999999999999999999999999999999999998",
room_id: mockRoom.roomId,
},
},
event_id: "$9999999999999999999999999999999999999999999",
room_id: mockRoom.roomId,
});
}
function mkFileEvent() {
return new MatrixEvent({
content: {
body: "index.html",
info: {
mimetype: "text/html",
size: 31613,
},
msgtype: "m.file",
url: "mxc://test.org",
},
origin_server_ts: 1628872988364,
sender: MY_USER_ID,
type: "m.room.message",
unsigned: {
age: 266,
transaction_id: "m99999999.2",
},
event_id: "$99999999999999999999",
room_id: mockRoom.roomId,
});
}
function mkImageEvent() {
return new MatrixEvent({
content: {
body: "image.png",
info: {
mimetype: "image/png",
size: 31613,
},
msgtype: "m.image",
url: "mxc://test.org",
},
origin_server_ts: 1628872988364,
sender: MY_USER_ID,
type: "m.room.message",
unsigned: {
age: 266,
transaction_id: "m99999999.2",
},
event_id: "$99999999999999999999",
room_id: mockRoom.roomId,
});
}
function mkEvents() {
const matrixEvents: MatrixEvent[] = [];
let i: number;
// plain text
for (i = 0; i < 10; i++) {
matrixEvents.push(
TestUtilsMatrix.mkMessage({
event: true,
room: "!room:id",
user: "@user:id",
ts: ts0 + i * 1000,
}),
);
}
// reply events
for (i = 0; i < 10; i++) {
const eventId = "$" + Math.random() + "-" + Math.random();
matrixEvents.push(
TestUtilsMatrix.mkEvent({
content: {
"body": "> <@me:here> Hi\n\nTest",
"format": "org.matrix.custom.html",
"m.relates_to": {
"rel_type": RelationType.Reference,
"event_id": eventId,
"m.in_reply_to": {
event_id: eventId,
},
},
"msgtype": "m.text",
},
user: "@me:here",
type: "m.room.message",
room: mockRoom.roomId,
event: true,
}),
);
}
// membership events
for (i = 0; i < 10; i++) {
matrixEvents.push(
TestUtilsMatrix.mkMembership({
event: true,
room: "!room:id",
user: "@user:id",
target: {
userId: "@user:id",
name: "Bob",
getAvatarUrl: () => {
return "avatar.jpeg";
},
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
} as unknown as RoomMember,
ts: ts0 + i * 1000,
mship: KnownMembership.Join,
prevMship: KnownMembership.Join,
name: "A user",
}),
);
}
// emote
matrixEvents.push(
TestUtilsMatrix.mkEvent({
content: {
body: "waves",
msgtype: "m.emote",
},
user: "@me:here",
type: "m.room.message",
room: mockRoom.roomId,
event: true,
}),
);
// redacted events
for (i = 0; i < 10; i++) {
matrixEvents.push(mkRedactedEvent(i));
}
return matrixEvents;
}
function renderToString(elem: JSX.Element): string {
return render(elem).container.outerHTML;
}
it("checks if the export format is valid", function () {
function isValidFormat(format: string): boolean {
const options: string[] = Object.values(ExportFormat);
return options.includes(format);
}
expect(isValidFormat("Html")).toBeTruthy();
expect(isValidFormat("Json")).toBeTruthy();
expect(isValidFormat("PlainText")).toBeTruthy();
expect(isValidFormat("Pdf")).toBeFalsy();
});
it("checks if the icons' html corresponds to export regex", function () {
const exporter = new HTMLExporter(mockRoom, ExportType.Beginning, mockExportOptions, setProgressText);
const fileRegex = /<span class="mx_MFileBody_info_icon">.*?<\/span>/;
expect(fileRegex.test(renderToString(exporter.getEventTile(mkFileEvent(), true)))).toBeTruthy();
});
it("should export images if attachments are enabled", () => {
const exporter = new HTMLExporter(
mockRoom,
ExportType.Beginning,
{
numberOfMessages: 5,
maxSize: 100 * 1024 * 1024,
attachmentsIncluded: true,
},
setProgressText,
);
const imageRegex = /<img.+ src="mxc:\/\/test.org" alt="image\.png"\/?>/;
expect(imageRegex.test(renderToString(exporter.getEventTile(mkImageEvent(), true)))).toBeTruthy();
});
const invalidExportOptions: [string, IExportOptions][] = [
[
"numberOfMessages exceeds max",
{
numberOfMessages: 10 ** 9,
maxSize: 1024 * 1024 * 1024,
attachmentsIncluded: false,
},
],
[
"maxSize exceeds 8GB",
{
numberOfMessages: -1,
maxSize: 8001 * 1024 * 1024,
attachmentsIncluded: false,
},
],
[
"maxSize is less than 1mb",
{
numberOfMessages: 0,
maxSize: 0,
attachmentsIncluded: false,
},
],
];
it.each(invalidExportOptions)("%s", (_d, options) => {
expect(() => new PlainTextExporter(mockRoom, ExportType.Beginning, options, setProgressText)).toThrow(
"Invalid export options",
);
});
it("tests the file extension splitter", function () {
const exporter = new PlainTextExporter(mockRoom, ExportType.Beginning, mockExportOptions, setProgressText);
const fileNameWithExtensions: Record<string, [string, string]> = {
"": ["", ""],
"name": ["name", ""],
"name.txt": ["name", ".txt"],
".htpasswd": ["", ".htpasswd"],
"name.with.many.dots.myext": ["name.with.many.dots", ".myext"],
};
for (const fileName in fileNameWithExtensions) {
expect(exporter.splitFileName(fileName)).toStrictEqual(fileNameWithExtensions[fileName]);
}
});
it("checks if the reply regex executes correctly", function () {
const eventContents: ITestContent[] = [
{
msgtype: "m.text",
body: "> <@me:here> Source\n\nReply",
expectedText: '<@me:here "Source"> Reply',
},
{
msgtype: "m.text",
// if the reply format is invalid, then return the body
body: "Invalid reply format",
expectedText: "Invalid reply format",
},
{
msgtype: "m.text",
body: "> <@me:here> The source is more than 32 characters\n\nReply",
expectedText: '<@me:here "The source is more than 32 chara..."> Reply',
},
{
msgtype: "m.text",
body: "> <@me:here> This\nsource\nhas\nnew\nlines\n\nReply",
expectedText: '<@me:here "This"> Reply',
},
];
const exporter = new PlainTextExporter(mockRoom, ExportType.Beginning, mockExportOptions, setProgressText);
for (const content of eventContents) {
expect(exporter.textForReplyEvent(content)).toBe(content.expectedText);
}
});
it("checks if the render to string doesn't throw any error for different types of events", function () {
const exporter = new HTMLExporter(mockRoom, ExportType.Beginning, mockExportOptions, setProgressText);
for (const event of events) {
expect(renderToString(exporter.getEventTile(event, false))).toBeTruthy();
}
});
});

View file

@ -0,0 +1,590 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import {
EventType,
IRoomEvent,
MatrixClient,
MatrixEvent,
MsgType,
Room,
RoomMember,
RoomState,
} from "matrix-js-sdk/src/matrix";
import fetchMock from "fetch-mock-jest";
import escapeHtml from "escape-html";
import { filterConsole, mkStubRoom, REPEATABLE_DATE, stubClient } from "../../test-utils";
import { ExportType, IExportOptions } from "../../../src/utils/exportUtils/exportUtils";
import SdkConfig from "../../../src/SdkConfig";
import HTMLExporter from "../../../src/utils/exportUtils/HtmlExport";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { mediaFromMxc } from "../../../src/customisations/Media";
jest.mock("jszip");
const EVENT_MESSAGE: IRoomEvent = {
event_id: "$1",
type: EventType.RoomMessage,
sender: "@bob:example.com",
origin_server_ts: 0,
content: {
msgtype: "m.text",
body: "Message",
avatar_url: "mxc://example.org/avatar.bmp",
},
};
const EVENT_ATTACHMENT: IRoomEvent = {
event_id: "$2",
type: EventType.RoomMessage,
sender: "@alice:example.com",
origin_server_ts: 1,
content: {
msgtype: MsgType.File,
body: "hello.txt",
filename: "hello.txt",
url: "mxc://example.org/test-id",
},
};
const EVENT_ATTACHMENT_MALFORMED: IRoomEvent = {
event_id: "$2",
type: EventType.RoomMessage,
sender: "@alice:example.com",
origin_server_ts: 1,
content: {
msgtype: MsgType.File,
body: "hello.txt",
file: {
url: undefined,
},
},
};
describe("HTMLExport", () => {
let client: jest.Mocked<MatrixClient>;
let room: Room;
filterConsole(
"Starting export",
"events in", // Fetched # events in # seconds
"events so far",
"Export successful!",
"does not have an m.room.create event",
"Creating HTML",
"Generating a ZIP",
"Cleaning up",
);
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(REPEATABLE_DATE);
client = stubClient() as jest.Mocked<MatrixClient>;
DMRoomMap.makeShared(client);
room = new Room("!myroom:example.org", client, "@me:example.org");
client.getRoom.mockReturnValue(room);
});
function mockMessages(...events: IRoomEvent[]): void {
client.createMessagesRequest.mockImplementation((_roomId, fromStr, limit = 30) => {
const from = fromStr === null ? 0 : parseInt(fromStr);
const chunk = events.slice(from, limit);
return Promise.resolve({
chunk,
from: from.toString(),
to: (from + limit).toString(),
});
});
}
/** Retrieve a map of files within the zip. */
function getFiles(exporter: HTMLExporter): { [filename: string]: Blob } {
//@ts-ignore private access
const files = exporter.files;
return files.reduce((d, f) => ({ ...d, [f.name]: f.blob }), {});
}
function getMessageFile(exporter: HTMLExporter): Blob {
const files = getFiles(exporter);
return files["messages.html"]!;
}
/** set a mock fetch response for an MXC */
function mockMxc(mxc: string, body: string) {
const media = mediaFromMxc(mxc, client);
fetchMock.get(media.srcHttp!, body);
}
it("should throw when created with invalid config for LastNMessages", async () => {
expect(
() =>
new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: undefined,
},
() => {},
),
).toThrow("Invalid export options");
});
it("should have an SDK-branded destination file name", () => {
const roomName = "My / Test / Room: Welcome";
const stubOptions: IExportOptions = {
attachmentsIncluded: false,
maxSize: 50000000,
numberOfMessages: 40,
};
const stubRoom = mkStubRoom("!myroom:example.org", roomName, client);
const exporter = new HTMLExporter(stubRoom, ExportType.LastNMessages, stubOptions, () => {});
expect(exporter.destinationFileName).toMatchSnapshot();
SdkConfig.put({ brand: "BrandedChat/WithSlashes/ForFun" });
expect(exporter.destinationFileName).toMatchSnapshot();
});
it("should export", async () => {
const events = [...Array(50)].map<IRoomEvent>((_, i) => ({
event_id: `${i}`,
type: EventType.RoomMessage,
sender: `@user${i}:example.com`,
origin_server_ts: 5_000 + i * 1000,
content: {
msgtype: "m.text",
body: `Message #${i}`,
},
}));
mockMessages(...events);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: events.length,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(await file.text()).toMatchSnapshot();
});
it("should include the room's avatar", async () => {
mockMessages(EVENT_MESSAGE);
const mxc = "mxc://www.example.com/avatars/nice-room.jpeg";
const avatar = "011011000110111101101100";
jest.spyOn(room, "getMxcAvatarUrl").mockReturnValue(mxc);
mockMxc(mxc, avatar);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
const files = getFiles(exporter);
expect(await files["room.png"]!.text()).toBe(avatar);
});
it("should include the creation event", async () => {
const creator = "@bob:example.com";
mockMessages(EVENT_MESSAGE);
room.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomCreate,
event_id: "$00001",
room_id: room.roomId,
sender: creator,
origin_server_ts: 0,
content: {},
state_key: "",
}),
]);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
expect(await getMessageFile(exporter).text()).toContain(`${creator} created this room.`);
});
it("should include the topic", async () => {
const topic = ":^-) (-^:";
mockMessages(EVENT_MESSAGE);
room.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomTopic,
event_id: "$00001",
room_id: room.roomId,
sender: "@alice:example.com",
origin_server_ts: 0,
content: { topic },
state_key: "",
}),
]);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
expect(await getMessageFile(exporter).text()).toContain(`Topic: ${topic}`);
});
it("should include avatars", async () => {
mockMessages(EVENT_MESSAGE);
jest.spyOn(RoomMember.prototype, "getMxcAvatarUrl").mockReturnValue("mxc://example.org/avatar.bmp");
const avatarContent = "this is a bitmap all the pixels are red :^-)";
mockMxc("mxc://example.org/avatar.bmp", avatarContent);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
// Ensure that the avatar is present
const files = getFiles(exporter);
const file = files["users/@bob-example.com.png"];
expect(file).not.toBeUndefined();
// Ensure it has the expected content
expect(await file.text()).toBe(avatarContent);
});
it("should handle when an event has no sender", async () => {
const EVENT_MESSAGE_NO_SENDER: IRoomEvent = {
event_id: "$1",
type: EventType.RoomMessage,
sender: "",
origin_server_ts: 0,
content: {
msgtype: "m.text",
body: "Message with no sender",
},
};
mockMessages(EVENT_MESSAGE_NO_SENDER);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(await file.text()).toContain(EVENT_MESSAGE_NO_SENDER.content.body);
});
it("should handle when events sender cannot be found in room state", async () => {
mockMessages(EVENT_MESSAGE);
jest.spyOn(RoomState.prototype, "getSentinelMember").mockReturnValue(null);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(await file.text()).toContain(EVENT_MESSAGE.content.body);
});
it("should include attachments", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT);
const attachmentBody = "Lorem ipsum dolor sit amet";
mockMxc("mxc://example.org/test-id", attachmentBody);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: true,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
// Ensure that the attachment is present
const files = getFiles(exporter);
const file = files[Object.keys(files).find((k) => k.endsWith(".txt"))!];
expect(file).not.toBeUndefined();
// Ensure that the attachment has the expected content
const text = await file.text();
expect(text).toBe(attachmentBody);
});
it("should handle when attachment cannot be fetched", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT_MALFORMED, EVENT_ATTACHMENT);
const attachmentBody = "Lorem ipsum dolor sit amet";
mockMxc("mxc://example.org/test-id", attachmentBody);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: true,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
// good attachment present
const files = getFiles(exporter);
const file = files[Object.keys(files).find((k) => k.endsWith(".txt"))!];
expect(file).not.toBeUndefined();
// Ensure that the attachment has the expected content
const text = await file.text();
expect(text).toBe(attachmentBody);
// messages export still successful
const messagesFile = getMessageFile(exporter);
expect(await messagesFile.text()).toBeTruthy();
});
it("should handle when attachment srcHttp is falsy", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT);
const attachmentBody = "Lorem ipsum dolor sit amet";
mockMxc("mxc://example.org/test-id", attachmentBody);
jest.spyOn(client, "mxcUrlToHttp").mockReturnValue(null);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: true,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
// attachment not present
const files = getFiles(exporter);
const file = files[Object.keys(files).find((k) => k.endsWith(".txt"))!];
expect(file).toBeUndefined();
// messages export still successful
const messagesFile = getMessageFile(exporter);
expect(await messagesFile.text()).toBeTruthy();
});
it("should omit attachments", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
// Ensure that the attachment is present
const files = getFiles(exporter);
for (const fileName of Object.keys(files)) {
expect(fileName).not.toMatch(/^files\/hello/);
}
});
it("should add link to next and previous file", async () => {
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 5000,
},
() => {},
);
// test link to the first page
//@ts-ignore private access
let result = await exporter.wrapHTML("", 0, 3);
expect(result).not.toContain("Previous group of messages");
expect(result).toContain(
'<div style="text-align:center;margin:10px"><a href="./messages2.html" style="font-weight:bold">Next group of messages</a></div>',
);
// test link for a middle page
//@ts-ignore private access
result = await exporter.wrapHTML("", 1, 3);
expect(result).toContain(
'<div style="text-align:center"><a href="./messages.html" style="font-weight:bold">Previous group of messages</a></div>',
);
expect(result).toContain(
'<div style="text-align:center;margin:10px"><a href="./messages3.html" style="font-weight:bold">Next group of messages</a></div>',
);
// test link for last page
//@ts-ignore private access
result = await exporter.wrapHTML("", 2, 3);
expect(result).toContain(
'<div style="text-align:center"><a href="./messages2.html" style="font-weight:bold">Previous group of messages</a></div>',
);
expect(result).not.toContain("Next group of messages");
});
it("should not leak javascript from room names or topics", async () => {
const name = "<svg onload=alert(3)>";
const topic = "<svg onload=alert(5)>";
mockMessages(EVENT_MESSAGE);
room.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomName,
event_id: "$00001",
room_id: room.roomId,
sender: "@alice:example.com",
origin_server_ts: 0,
content: { name },
state_key: "",
}),
new MatrixEvent({
type: EventType.RoomTopic,
event_id: "$00002",
room_id: room.roomId,
sender: "@alice:example.com",
origin_server_ts: 1,
content: { topic },
state_key: "",
}),
]);
room.recalculate();
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: 40,
},
() => {},
);
await exporter.export();
const html = await getMessageFile(exporter).text();
expect(html).not.toContain(`${name}`);
expect(html).toContain(`${escapeHtml(name)}`);
expect(html).not.toContain(`${topic}`);
expect(html).toContain(`Topic: ${escapeHtml(topic)}`);
});
it("should not make /messages requests when exporting 'Current Timeline'", async () => {
client.createMessagesRequest.mockRejectedValue(new Error("Should never be called"));
room.addLiveEvents([
new MatrixEvent({
event_id: `$eventId`,
type: EventType.RoomMessage,
sender: client.getSafeUserId(),
origin_server_ts: 123456789,
content: {
msgtype: "m.text",
body: `testing testing`,
},
}),
]);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(await file.text()).toContain("testing testing");
expect(client.createMessagesRequest).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,31 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import JSONExporter from "../../../src/utils/exportUtils/JSONExport";
import { createTestClient, mkStubRoom, REPEATABLE_DATE } from "../../test-utils";
import { ExportType, IExportOptions } from "../../../src/utils/exportUtils/exportUtils";
describe("JSONExport", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(REPEATABLE_DATE);
});
it("should have a Matrix-branded destination file name", () => {
const roomName = "My / Test / Room: Welcome";
const client = createTestClient();
const stubOptions: IExportOptions = {
attachmentsIncluded: false,
maxSize: 50000000,
};
const stubRoom = mkStubRoom("!myroom:example.org", roomName, client);
const exporter = new JSONExporter(stubRoom, ExportType.Timeline, stubOptions, () => {});
expect(exporter.destinationFileName).toMatchSnapshot();
});
});

View file

@ -0,0 +1,64 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { createTestClient, mkStubRoom, REPEATABLE_DATE } from "../../test-utils";
import { ExportType, IExportOptions } from "../../../src/utils/exportUtils/exportUtils";
import PlainTextExporter from "../../../src/utils/exportUtils/PlainTextExport";
import SettingsStore from "../../../src/settings/SettingsStore";
class TestablePlainTextExporter extends PlainTextExporter {
public async testCreateOutput(events: MatrixEvent[]): Promise<string> {
return this.createOutput(events);
}
}
describe("PlainTextExport", () => {
let stubOptions: IExportOptions;
let stubRoom: Room;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(REPEATABLE_DATE);
const roomName = "My / Test / Room: Welcome";
const client = createTestClient();
stubOptions = {
attachmentsIncluded: false,
maxSize: 50000000,
};
stubRoom = mkStubRoom("!myroom:example.org", roomName, client);
});
it("should have a Matrix-branded destination file name", () => {
const exporter = new PlainTextExporter(stubRoom, ExportType.Timeline, stubOptions, () => {});
expect(exporter.destinationFileName).toMatchSnapshot();
});
it.each([
[24, false, "Fri, Apr 16, 2021, 17:20:00 - @alice:example.com: Hello, world!\n"],
[12, true, "Fri, Apr 16, 2021, 5:20:00 PM - @alice:example.com: Hello, world!\n"],
])("should return text with %i hr time format", async (hour: number, setting: boolean, expectedMessage: string) => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) =>
settingName === "showTwelveHourTimestamps" ? setting : undefined,
);
const events: MatrixEvent[] = [
new MatrixEvent({
type: "m.room.message",
content: {
body: "Hello, world!",
},
sender: "@alice:example.com",
origin_server_ts: 1618593600000,
}),
];
const exporter = new TestablePlainTextExporter(stubRoom, ExportType.Timeline, stubOptions, () => {});
const output = await exporter.testCreateOutput(events);
expect(output).toBe(expectedMessage);
});
});

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`JSONExport should have a Matrix-branded destination file name 1`] = `"matrix - My Test Room Welcome - Chat Export - 2022-11-17T16-58-32.517Z.json"`;

View file

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PlainTextExport should have a Matrix-branded destination file name 1`] = `"matrix - My Test Room Welcome - Chat Export - 2022-11-17T16-58-32.517Z.txt"`;

View file

@ -0,0 +1,18 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import getExportCSS from "../../../src/utils/exportUtils/exportCSS";
describe("exportCSS", () => {
describe("getExportCSS", () => {
it("supports documents missing stylesheets", async () => {
const css = await getExportCSS(new Set());
expect(css).not.toContain("color-scheme: light");
});
});
});

View file

@ -0,0 +1,117 @@
#!/usr/bin/env python
from __future__ import print_function
import base64
import json
import struct
from cryptography.hazmat import backends
from cryptography.hazmat.primitives import ciphers, hashes, hmac
from cryptography.hazmat.primitives.kdf import pbkdf2
from cryptography.hazmat.primitives.ciphers import algorithms, modes
backend = backends.default_backend()
def parse_u128(s):
a, b = struct.unpack(">QQ", s)
return (a << 64) | b
def encrypt_ctr(key, iv, plaintext, counter_bits=64):
alg = algorithms.AES(key)
# Some AES-CTR implementations treat some parts of the IV as a nonce (which
# remains constant throughought encryption), and some as a counter (which
# increments every block, ie 16 bytes, and wraps after a while). Different
# implmententations use different amounts of the IV for each part.
#
# The python cryptography library uses the whole IV as a counter; to make
# it match other implementations with a given counter size, we manually
# implement wrapping the counter.
# number of AES blocks between each counter wrap
limit = 1 << counter_bits
# parse IV as a 128-bit int
parsed_iv = parse_u128(iv)
# split IV into counter and nonce
counter = parsed_iv & (limit - 1)
nonce = parsed_iv & ~(limit - 1)
# encrypt up to the first counter wraparound
size = 16 * (limit - counter)
encryptor = ciphers.Cipher(
alg,
modes.CTR(iv),
backend=backend
).encryptor()
input = plaintext[:size]
result = encryptor.update(input) + encryptor.finalize()
offset = size
# do remaining data starting with a counter of zero
iv = struct.pack(">QQ", nonce >> 64, nonce & ((1 << 64) - 1))
size = 16 * limit
while offset < len(plaintext):
encryptor = ciphers.Cipher(
alg,
modes.CTR(iv),
backend=backend
).encryptor()
input = plaintext[offset:offset+size]
result += encryptor.update(input) + encryptor.finalize()
offset += size
return result
def hmac_sha256(key, message):
h = hmac.HMAC(key, hashes.SHA256(), backend=backend)
h.update(message)
return h.finalize()
def encrypt(key, iv, salt, plaintext, iterations=1000):
"""
Returns:
(bytes) ciphertext
"""
if len(salt) != 16:
raise Exception("Expected 128 bits of salt - got %i bits" % len((salt) * 8))
if len(iv) != 16:
raise Exception("Expected 128 bits of IV - got %i bits" % (len(iv) * 8))
sha = hashes.SHA512()
kdf = pbkdf2.PBKDF2HMAC(sha, 64, salt, iterations, backend)
k = kdf.derive(key)
aes_key = k[0:32]
sha_key = k[32:]
packed_file = (
b"\x01" # version
+ salt
+ iv
+ struct.pack(">L", iterations)
+ encrypt_ctr(aes_key, iv, plaintext)
)
packed_file += hmac_sha256(sha_key, packed_file)
return (
b"-----BEGIN MEGOLM SESSION DATA-----\n" +
base64.encodestring(packed_file) +
b"-----END MEGOLM SESSION DATA-----"
)
def gen(password, iv, salt, plaintext, iterations=1000):
ciphertext = encrypt(
password.encode('utf-8'), iv, salt, plaintext.encode('utf-8'), iterations
)
return (plaintext, password, ciphertext.decode('utf-8'))
print (json.dumps([
gen("password", b"\x88"*16, b"saltsaltsaltsalt", "plain", 10),
gen("betterpassword", b"\xFF"*8 + b"\x00"*8, b"moresaltmoresalt", "Hello, World"),
gen("SWORDFISH", b"\xFF"*8 + b"\x00"*8, b"yessaltygoodness", "alphanumerically" * 4),
gen("password"*32, b"\xFF"*16, b"\xFF"*16, "alphanumerically" * 4),
], indent=4))

View file

@ -0,0 +1,54 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import SpaceStore from "../../src/stores/spaces/SpaceStore";
import { stubClient } from "../test-utils";
import { roomContextDetails } from "../../src/utils/i18n-helpers";
import DMRoomMap from "../../src/utils/DMRoomMap";
describe("roomContextDetails", () => {
const client = stubClient();
DMRoomMap.makeShared(client);
const room = new Room("!room:server", client, client.getSafeUserId());
const parent1 = new Room("!parent1:server", client, client.getSafeUserId());
parent1.name = "Alpha";
const parent2 = new Room("!parent2:server", client, client.getSafeUserId());
parent2.name = "Beta";
const parent3 = new Room("!parent3:server", client, client.getSafeUserId());
parent3.name = "Charlie";
mocked(client.getRoom).mockImplementation((roomId) => {
return [parent1, parent2, parent3].find((r) => r.roomId === roomId) ?? null;
});
it("should return 1-parent variant", () => {
jest.spyOn(SpaceStore.instance, "getKnownParents").mockReturnValue(new Set([parent1.roomId]));
const res = roomContextDetails(room);
expect(res!.details).toMatchInlineSnapshot(`"Alpha"`);
expect(res!.ariaLabel).toMatchInlineSnapshot(`"In Alpha."`);
});
it("should return 2-parent variant", () => {
jest.spyOn(SpaceStore.instance, "getKnownParents").mockReturnValue(new Set([parent2.roomId, parent3.roomId]));
const res = roomContextDetails(room);
expect(res!.details).toMatchInlineSnapshot(`"Beta and Charlie"`);
expect(res!.ariaLabel).toMatchInlineSnapshot(`"In spaces Beta and Charlie."`);
});
it("should return n-parent variant", () => {
jest.spyOn(SpaceStore.instance, "getKnownParents").mockReturnValue(
new Set([parent1.roomId, parent2.roomId, parent3.roomId]),
);
const res = roomContextDetails(room);
expect(res!.details).toMatchInlineSnapshot(`"Alpha and one other"`);
expect(res!.ariaLabel).toMatchInlineSnapshot(`"In Alpha and one other space."`);
});
});

View file

@ -0,0 +1,69 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { iterableDiff, iterableIntersection } from "../../src/utils/iterables";
describe("iterables", () => {
describe("iterableIntersection", () => {
it("should return the intersection", () => {
const a = [1, 2, 3];
const b = [1, 2, 4]; // note diff
const result = iterableIntersection(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(2);
expect(result).toEqual([1, 2]);
});
it("should return an empty array on no matches", () => {
const a = [1, 2, 3];
const b = [4, 5, 6];
const result = iterableIntersection(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(0);
});
});
describe("iterableDiff", () => {
it("should see added from A->B", () => {
const a = [1, 2, 3];
const b = [1, 2, 3, 4];
const result = iterableDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(0);
expect(result.added).toEqual([4]);
});
it("should see removed from A->B", () => {
const a = [1, 2, 3];
const b = [1, 2];
const result = iterableDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(1);
expect(result.removed).toEqual([3]);
});
it("should see added and removed in the same set", () => {
const a = [1, 2, 3];
const b = [1, 2, 4]; // note diff
const result = iterableDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(1);
expect(result.added).toEqual([4]);
expect(result.removed).toEqual([3]);
});
});
});

View file

@ -0,0 +1,149 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked, Mocked } from "jest-mock";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import { mkRoom, resetAsyncStoreWithClient, setupAsyncStoreWithClient, stubClient } from "../test-utils";
import defaultDispatcher from "../../src/dispatcher/dispatcher";
import { ViewRoomPayload } from "../../src/dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../src/dispatcher/actions";
import { leaveRoomBehaviour } from "../../src/utils/leave-behaviour";
import { SdkContextClass } from "../../src/contexts/SDKContext";
import DMRoomMap from "../../src/utils/DMRoomMap";
import SpaceStore from "../../src/stores/spaces/SpaceStore";
import { MetaSpace } from "../../src/stores/spaces";
import { ActionPayload } from "../../src/dispatcher/payloads";
import SettingsStore from "../../src/settings/SettingsStore";
describe("leaveRoomBehaviour", () => {
SdkContextClass.instance.constructEagerStores(); // Initialize RoomViewStore
let client: Mocked<MatrixClient>;
let room: Mocked<Room>;
let space: Mocked<Room>;
beforeEach(async () => {
stubClient();
client = mocked(MatrixClientPeg.safeGet());
DMRoomMap.makeShared(client);
room = mkRoom(client, "!1:example.org");
space = mkRoom(client, "!2:example.org");
space.isSpaceRoom.mockReturnValue(true);
client.getRoom.mockImplementation((roomId) => {
switch (roomId) {
case room.roomId:
return room;
case space.roomId:
return space;
default:
return null;
}
});
await setupAsyncStoreWithClient(SpaceStore.instance, client);
});
afterEach(async () => {
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
await resetAsyncStoreWithClient(SpaceStore.instance);
jest.restoreAllMocks();
});
const viewRoom = (room: Room) =>
defaultDispatcher.dispatch<ViewRoomPayload>(
{
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: undefined,
},
true,
);
const expectDispatch = async <T extends ActionPayload>(payload: T) => {
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
await sleep(0);
expect(dispatcherSpy).toHaveBeenCalledWith(payload);
defaultDispatcher.unregister(dispatcherRef);
};
it("returns to the home page after leaving a room outside of a space that was being viewed", async () => {
viewRoom(room);
await leaveRoomBehaviour(client, room.roomId);
await expectDispatch({ action: Action.ViewHomePage });
});
it("returns to the parent space after leaving a room inside of a space that was being viewed", async () => {
jest.spyOn(SpaceStore.instance, "getCanonicalParent").mockImplementation((roomId) =>
roomId === room.roomId ? space : null,
);
viewRoom(room);
SpaceStore.instance.setActiveSpace(space.roomId, false);
await leaveRoomBehaviour(client, room.roomId);
await expectDispatch({
action: Action.ViewRoom,
room_id: space.roomId,
metricsTrigger: undefined,
});
});
it("returns to the home page after leaving a top-level space that was being viewed", async () => {
viewRoom(space);
SpaceStore.instance.setActiveSpace(space.roomId, false);
await leaveRoomBehaviour(client, space.roomId);
await expectDispatch({ action: Action.ViewHomePage });
});
it("returns to the parent space after leaving a subspace that was being viewed", async () => {
room.isSpaceRoom.mockReturnValue(true);
jest.spyOn(SpaceStore.instance, "getCanonicalParent").mockImplementation((roomId) =>
roomId === room.roomId ? space : null,
);
viewRoom(room);
SpaceStore.instance.setActiveSpace(room.roomId, false);
await leaveRoomBehaviour(client, room.roomId);
await expectDispatch({
action: Action.ViewRoom,
room_id: space.roomId,
metricsTrigger: undefined,
});
});
describe("If the feature_dynamic_room_predecessors is not enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("Passes through the dynamic predecessor setting", async () => {
await leaveRoomBehaviour(client, room.roomId);
expect(client.getRoomUpgradeHistory).toHaveBeenCalledWith(room.roomId, false, false);
});
});
describe("If the feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
// Turn on feature_dynamic_room_predecessors setting
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
it("Passes through the dynamic predecessor setting", async () => {
await leaveRoomBehaviour(client, room.roomId);
expect(client.getRoomUpgradeHistory).toHaveBeenCalledWith(room.roomId, false, true);
});
});
});

View file

@ -0,0 +1,156 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { LocalRoom, LocalRoomState, LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom";
import * as localRoomModule from "../../src/utils/local-room";
import defaultDispatcher from "../../src/dispatcher/dispatcher";
import { createTestClient } from "../test-utils";
import { isRoomReady } from "../../src/utils/localRoom/isRoomReady";
jest.mock("../../src/utils/localRoom/isRoomReady", () => ({
isRoomReady: jest.fn(),
}));
describe("local-room", () => {
const userId1 = "@user1:example.com";
let room1: Room;
let localRoom: LocalRoom;
let client: MatrixClient;
beforeEach(() => {
client = createTestClient();
room1 = new Room("!room1:example.com", client, userId1);
room1.getMyMembership = () => KnownMembership.Join;
localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, "@test:example.com");
mocked(client.getRoom).mockImplementation((roomId: string) => {
if (roomId === localRoom.roomId) {
return localRoom;
}
return null;
});
});
describe("doMaybeLocalRoomAction", () => {
let callback: jest.Mock;
beforeEach(() => {
callback = jest.fn();
callback.mockReturnValue(Promise.resolve());
localRoom.actualRoomId = "@new:example.com";
});
it("should invoke the callback for a non-local room", () => {
localRoomModule.doMaybeLocalRoomAction("!room:example.com", callback, client);
expect(callback).toHaveBeenCalled();
});
it("should invoke the callback with the new room ID for a created room", () => {
localRoom.state = LocalRoomState.CREATED;
localRoomModule.doMaybeLocalRoomAction(localRoom.roomId, callback, client);
expect(callback).toHaveBeenCalledWith(localRoom.actualRoomId);
});
describe("for a local room", () => {
let prom: Promise<unknown>;
beforeEach(() => {
jest.spyOn(defaultDispatcher, "dispatch");
prom = localRoomModule.doMaybeLocalRoomAction(localRoom.roomId, callback, client);
});
it("dispatch a local_room_event", () => {
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
action: "local_room_event",
roomId: localRoom.roomId,
});
});
it("should resolve the promise after invoking the callback", async () => {
localRoom.afterCreateCallbacks.forEach((callback) => {
callback(localRoom.actualRoomId);
});
await prom;
});
});
});
describe("waitForRoomReadyAndApplyAfterCreateCallbacks", () => {
let localRoomCallbackRoomId: string;
beforeEach(() => {
localRoom.actualRoomId = room1.roomId;
localRoom.afterCreateCallbacks.push((roomId: string) => {
localRoomCallbackRoomId = roomId;
return Promise.resolve();
});
jest.useFakeTimers();
});
describe("for an immediate ready room", () => {
beforeEach(() => {
mocked(isRoomReady).mockReturnValue(true);
});
it("should invoke the callbacks, set the room state to created and return the actual room id", async () => {
const result = await localRoomModule.waitForRoomReadyAndApplyAfterCreateCallbacks(
client,
localRoom,
room1.roomId,
);
expect(localRoom.state).toBe(LocalRoomState.CREATED);
expect(localRoomCallbackRoomId).toBe(room1.roomId);
expect(result).toBe(room1.roomId);
});
});
describe("for a room running into the create timeout", () => {
beforeEach(() => {
mocked(isRoomReady).mockReturnValue(false);
});
it("should invoke the callbacks, set the room state to created and return the actual room id", async () => {
const prom = localRoomModule.waitForRoomReadyAndApplyAfterCreateCallbacks(
client,
localRoom,
room1.roomId,
);
jest.advanceTimersByTime(5000);
const roomId = await prom;
expect(localRoom.state).toBe(LocalRoomState.CREATED);
expect(localRoomCallbackRoomId).toBe(room1.roomId);
expect(roomId).toBe(room1.roomId);
expect(jest.getTimerCount()).toBe(0);
});
});
describe("for a room that is ready after a while", () => {
beforeEach(() => {
mocked(isRoomReady).mockReturnValue(false);
});
it("should invoke the callbacks, set the room state to created and return the actual room id", async () => {
const prom = localRoomModule.waitForRoomReadyAndApplyAfterCreateCallbacks(
client,
localRoom,
room1.roomId,
);
mocked(isRoomReady).mockReturnValue(true);
jest.advanceTimersByTime(500);
const roomId = await prom;
expect(localRoom.state).toBe(LocalRoomState.CREATED);
expect(localRoomCallbackRoomId).toBe(room1.roomId);
expect(roomId).toBe(room1.roomId);
expect(jest.getTimerCount()).toBe(0);
});
});
});
});

View file

@ -0,0 +1,40 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../src/models/LocalRoom";
import { isLocalRoom } from "../../../src/utils/localRoom/isLocalRoom";
import { createTestClient } from "../../test-utils";
describe("isLocalRoom", () => {
let room: Room;
let localRoom: LocalRoom;
beforeEach(() => {
const client = createTestClient();
room = new Room("!room:example.com", client, client.getUserId()!);
localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, client.getUserId()!);
});
it("should return false for a Room", () => {
expect(isLocalRoom(room)).toBe(false);
});
it("should return false for a non-local room ID", () => {
expect(isLocalRoom(room.roomId)).toBe(false);
});
it("should return true for LocalRoom", () => {
expect(isLocalRoom(localRoom)).toBe(true);
});
it("should return true for local room ID", () => {
expect(isLocalRoom(LOCAL_ROOM_ID_PREFIX + "test")).toBe(true);
});
});

View file

@ -0,0 +1,123 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { EventType, MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../src/models/LocalRoom";
import { DirectoryMember } from "../../../src/utils/direct-messages";
import { isRoomReady } from "../../../src/utils/localRoom/isRoomReady";
import { createTestClient, makeMembershipEvent, mkEvent } from "../../test-utils";
describe("isRoomReady", () => {
const userId1 = "@user1:example.com";
const member1 = new DirectoryMember({ user_id: userId1 });
const userId2 = "@user2:example.com";
let room1: Room;
let localRoom: LocalRoom;
let client: MatrixClient;
beforeEach(() => {
client = createTestClient();
room1 = new Room("!room1:example.com", client, userId1);
room1.getMyMembership = () => KnownMembership.Join;
localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, "@test:example.com");
});
beforeEach(() => {
localRoom.targets = [member1];
});
it("should return false if the room has no actual room id", () => {
expect(isRoomReady(client, localRoom)).toBe(false);
});
describe("for a room with an actual room id", () => {
beforeEach(() => {
localRoom.actualRoomId = room1.roomId;
mocked(client.getRoom).mockReturnValue(null);
});
it("should return false", () => {
expect(isRoomReady(client, localRoom)).toBe(false);
});
describe("and the room is known to the client", () => {
beforeEach(() => {
mocked(client.getRoom).mockImplementation((roomId: string) => {
if (roomId === room1.roomId) return room1;
return null;
});
});
it("should return false", () => {
expect(isRoomReady(client, localRoom)).toBe(false);
});
describe("and all members have been invited or joined", () => {
beforeEach(() => {
room1.currentState.setStateEvents([
makeMembershipEvent(room1.roomId, userId1, KnownMembership.Join),
makeMembershipEvent(room1.roomId, userId2, KnownMembership.Invite),
]);
});
it("should return false", () => {
expect(isRoomReady(client, localRoom)).toBe(false);
});
describe("and a RoomHistoryVisibility event", () => {
beforeEach(() => {
room1.currentState.setStateEvents([
mkEvent({
user: userId1,
event: true,
type: EventType.RoomHistoryVisibility,
room: room1.roomId,
content: {},
}),
]);
});
it("should return true", () => {
expect(isRoomReady(client, localRoom)).toBe(true);
});
describe("and an encrypted room", () => {
beforeEach(() => {
localRoom.encrypted = true;
});
it("should return false", () => {
expect(isRoomReady(client, localRoom)).toBe(false);
});
describe("and a room encryption state event", () => {
beforeEach(() => {
room1.currentState.setStateEvents([
mkEvent({
user: userId1,
event: true,
type: EventType.RoomEncryption,
room: room1.roomId,
content: {},
}),
]);
});
it("should return true", () => {
expect(isRoomReady(client, localRoom)).toBe(true);
});
});
});
});
});
});
});
});

View file

@ -0,0 +1,65 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import {
M_TEXT,
ILocationContent,
LocationAssetType,
M_ASSET,
M_LOCATION,
M_TIMESTAMP,
ContentHelpers,
} from "matrix-js-sdk/src/matrix";
import { isSelfLocation } from "../../../src/utils/location";
describe("isSelfLocation", () => {
it("Returns true for a full m.asset event", () => {
const content = ContentHelpers.makeLocationContent("", "0", Date.now());
expect(isSelfLocation(content)).toBe(true);
});
it("Returns true for a missing m.asset", () => {
const content = {
body: "",
msgtype: "m.location",
geo_uri: "",
[M_LOCATION.name]: { uri: "" },
[M_TEXT.name]: "",
[M_TIMESTAMP.name]: 0,
// Note: no m.asset!
} as unknown as ILocationContent;
expect(isSelfLocation(content)).toBe(true);
});
it("Returns true for a missing m.asset type", () => {
const content = {
body: "",
msgtype: "m.location",
geo_uri: "",
[M_LOCATION.name]: { uri: "" },
[M_TEXT.name]: "",
[M_TIMESTAMP.name]: 0,
[M_ASSET.name]: {
// Note: no type!
},
} as unknown as ILocationContent;
expect(isSelfLocation(content)).toBe(true);
});
it("Returns false for an unknown asset type", () => {
const content = ContentHelpers.makeLocationContent(
undefined /* text */,
"geo:foo",
0,
undefined /* description */,
"org.example.unknown" as unknown as LocationAssetType,
);
expect(isSelfLocation(content)).toBe(false);
});
});

View file

@ -0,0 +1,20 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { locationEventGeoUri } from "../../../src/utils/location";
import { makeLegacyLocationEvent, makeLocationEvent } from "../../test-utils/location";
describe("locationEventGeoUri()", () => {
it("returns m.location uri when available", () => {
expect(locationEventGeoUri(makeLocationEvent("geo:51.5076,-0.1276"))).toEqual("geo:51.5076,-0.1276");
});
it("returns legacy uri when m.location content not found", () => {
expect(locationEventGeoUri(makeLegacyLocationEvent("geo:51.5076,-0.1276"))).toEqual("geo:51.5076,-0.1276");
});
});

View file

@ -0,0 +1,45 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { createMapSiteLinkFromEvent } from "../../../src/utils/location";
import { mkMessage } from "../../test-utils";
import { makeLegacyLocationEvent, makeLocationEvent } from "../../test-utils/location";
describe("createMapSiteLinkFromEvent", () => {
it("returns null if event does not contain geouri", () => {
expect(
createMapSiteLinkFromEvent(
mkMessage({
room: "1",
user: "@sender:server",
event: true,
}),
),
).toBeNull();
});
it("returns OpenStreetMap link if event contains m.location with valid uri", () => {
expect(createMapSiteLinkFromEvent(makeLocationEvent("geo:51.5076,-0.1276"))).toEqual(
"https://www.openstreetmap.org/" + "?mlat=51.5076&mlon=-0.1276" + "#map=16/51.5076/-0.1276",
);
});
it("returns null if event contains m.location with invalid uri", () => {
expect(createMapSiteLinkFromEvent(makeLocationEvent("123 Sesame St"))).toBeNull();
});
it("returns OpenStreetMap link if event contains geo_uri", () => {
expect(createMapSiteLinkFromEvent(makeLegacyLocationEvent("geo:51.5076,-0.1276"))).toEqual(
"https://www.openstreetmap.org/" + "?mlat=51.5076&mlon=-0.1276" + "#map=16/51.5076/-0.1276",
);
});
it("returns null if event contains an invalid geo_uri", () => {
expect(createMapSiteLinkFromEvent(makeLegacyLocationEvent("123 Sesame St"))).toBeNull();
});
});

View file

@ -0,0 +1,148 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { parseGeoUri } from "../../../src/utils/location/parseGeoUri";
describe("parseGeoUri", () => {
it("fails if the supplied URI is empty", () => {
expect(parseGeoUri("")).toBeFalsy();
});
it("returns undefined if latitude is not a number", () => {
expect(parseGeoUri("geo:ABCD,16.3695,183")).toBeUndefined();
});
it("returns undefined if longitude is not a number", () => {
expect(parseGeoUri("geo:48.2010,EFGH,183")).toBeUndefined();
});
// We use some examples from the spec, but don't check semantics
// like two textually-different URIs being equal, since we are
// just a humble parser.
// Note: we do not understand geo URIs with percent-encoded coords
// or accuracy. It is RECOMMENDED in the spec never to percent-encode
// these, but it is permitted, and we will fail to parse in that case.
it("rfc5870 6.1 Simple 3-dimensional", () => {
expect(parseGeoUri("geo:48.2010,16.3695,183")).toEqual({
latitude: 48.201,
longitude: 16.3695,
altitude: 183,
accuracy: undefined,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: expect.any(Function),
});
});
it("rfc5870 6.2 Explicit CRS and accuracy", () => {
expect(parseGeoUri("geo:48.198634,16.371648;crs=wgs84;u=40")).toEqual({
latitude: 48.198634,
longitude: 16.371648,
altitude: null,
accuracy: 40,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: expect.any(Function),
});
});
it("rfc5870 6.4 Negative longitude and explicit CRS", () => {
expect(parseGeoUri("geo:90,-22.43;crs=WGS84")).toEqual({
latitude: 90,
longitude: -22.43,
altitude: null,
accuracy: undefined,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: expect.any(Function),
});
});
it("rfc5870 6.4 Integer lat and lon", () => {
expect(parseGeoUri("geo:90,46")).toEqual({
latitude: 90,
longitude: 46,
altitude: null,
accuracy: undefined,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: expect.any(Function),
});
});
it("rfc5870 6.4 Percent-encoded param value", () => {
expect(parseGeoUri("geo:66,30;u=6.500;FOo=this%2dthat")).toEqual({
latitude: 66,
longitude: 30,
altitude: null,
accuracy: 6.5,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: expect.any(Function),
});
});
it("rfc5870 6.4 Unknown param", () => {
expect(parseGeoUri("geo:66.0,30;u=6.5;foo=this-that>")).toEqual({
latitude: 66.0,
longitude: 30,
altitude: null,
accuracy: 6.5,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: expect.any(Function),
});
});
it("rfc5870 6.4 Multiple unknown params", () => {
expect(parseGeoUri("geo:70,20;foo=1.00;bar=white")).toEqual({
latitude: 70,
longitude: 20,
altitude: null,
accuracy: undefined,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: expect.any(Function),
});
});
it("Negative latitude", () => {
expect(parseGeoUri("geo:-7.5,20")).toEqual({
latitude: -7.5,
longitude: 20,
altitude: null,
accuracy: undefined,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: expect.any(Function),
});
});
it("Zero altitude is not unknown", () => {
expect(parseGeoUri("geo:-7.5,-20,0")).toEqual({
latitude: -7.5,
longitude: -20,
altitude: 0,
accuracy: undefined,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: expect.any(Function),
});
});
});

View file

@ -0,0 +1,30 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { positionFailureMessage } from "../../../src/utils/location/positionFailureMessage";
describe("positionFailureMessage()", () => {
// error codes from GeolocationPositionError
// see: https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError
// 1: PERMISSION_DENIED
// 2: POSITION_UNAVAILABLE
// 3: TIMEOUT
type TestCase = [number, string | undefined];
it.each<TestCase>([
[
1,
"Element was denied permission to fetch your location. Please allow location access in your browser settings.",
],
[2, "Failed to fetch your location. Please try again later."],
[3, "Timed out trying to fetch your location. Please try again later."],
[4, "Unknown error fetching location. Please try again later."],
[5, undefined],
])("returns correct message for error code %s", (code, expectedMessage) => {
expect(positionFailureMessage(code)).toEqual(expectedMessage);
});
});

View file

@ -0,0 +1,219 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { EnhancedMap, mapDiff } from "../../src/utils/maps";
describe("maps", () => {
describe("mapDiff", () => {
it("should indicate no differences when the pointers are the same", () => {
const a = new Map([
[1, 1],
[2, 2],
[3, 3],
]);
const result = mapDiff(a, a);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
expect(result.changed).toHaveLength(0);
});
it("should indicate no differences when there are none", () => {
const a = new Map([
[1, 1],
[2, 2],
[3, 3],
]);
const b = new Map([
[1, 1],
[2, 2],
[3, 3],
]);
const result = mapDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
expect(result.changed).toHaveLength(0);
});
it("should indicate added properties", () => {
const a = new Map([
[1, 1],
[2, 2],
[3, 3],
]);
const b = new Map([
[1, 1],
[2, 2],
[3, 3],
[4, 4],
]);
const result = mapDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(0);
expect(result.changed).toHaveLength(0);
expect(result.added).toEqual([4]);
});
it("should indicate removed properties", () => {
const a = new Map([
[1, 1],
[2, 2],
[3, 3],
]);
const b = new Map([
[1, 1],
[2, 2],
]);
const result = mapDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(1);
expect(result.changed).toHaveLength(0);
expect(result.removed).toEqual([3]);
});
it("should indicate changed properties", () => {
const a = new Map([
[1, 1],
[2, 2],
[3, 3],
]);
const b = new Map([
[1, 1],
[2, 2],
[3, 4],
]); // note change
const result = mapDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
expect(result.changed).toHaveLength(1);
expect(result.changed).toEqual([3]);
});
it("should indicate changed, added, and removed properties", () => {
const a = new Map([
[1, 1],
[2, 2],
[3, 3],
]);
const b = new Map([
[1, 1],
[2, 8],
[4, 4],
]); // note change
const result = mapDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(1);
expect(result.changed).toHaveLength(1);
expect(result.added).toEqual([4]);
expect(result.removed).toEqual([3]);
expect(result.changed).toEqual([2]);
});
it("should indicate changes for difference in pointers", () => {
const a = new Map([[1, {}]]); // {} always creates a new object
const b = new Map([[1, {}]]);
const result = mapDiff(a, b);
expect(result).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
expect(result.changed).toHaveLength(1);
expect(result.changed).toEqual([1]);
});
});
describe("EnhancedMap", () => {
// Most of these tests will make sure it implements the Map<K, V> class
it("should be empty by default", () => {
const result = new EnhancedMap();
expect(result.size).toBe(0);
});
it("should use the provided entries", () => {
const obj = { a: 1, b: 2 };
const result = new EnhancedMap(Object.entries(obj));
expect(result.size).toBe(2);
expect(result.get("a")).toBe(1);
expect(result.get("b")).toBe(2);
});
it("should create keys if they do not exist", () => {
const key = "a";
const val = {}; // we'll check pointers
const result = new EnhancedMap<string, any>();
expect(result.size).toBe(0);
let get = result.getOrCreate(key, val);
expect(get).toBeDefined();
expect(get).toBe(val);
expect(result.size).toBe(1);
get = result.getOrCreate(key, 44); // specifically change `val`
expect(get).toBeDefined();
expect(get).toBe(val);
expect(result.size).toBe(1);
get = result.get(key); // use the base class function
expect(get).toBeDefined();
expect(get).toBe(val);
expect(result.size).toBe(1);
});
it("should proxy remove to delete and return it", () => {
const val = {};
const result = new EnhancedMap<string, any>();
result.set("a", val);
expect(result.size).toBe(1);
const removed = result.remove("a");
expect(result.size).toBe(0);
expect(removed).toBeDefined();
expect(removed).toBe(val);
});
it("should support removing unknown keys", () => {
const val = {};
const result = new EnhancedMap<string, any>();
result.set("a", val);
expect(result.size).toBe(1);
const removed = result.remove("not-a");
expect(result.size).toBe(1);
expect(removed).not.toBeDefined();
});
});
});

View file

@ -0,0 +1,112 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { logger } from "matrix-js-sdk/src/logger";
import { screen } from "jest-matrix-react";
import { requestMediaPermissions } from "../../../src/utils/media/requestMediaPermissions";
import { flushPromises, useMockMediaDevices } from "../../test-utils";
describe("requestMediaPermissions", () => {
let error: Error;
const audioVideoStream = {} as MediaStream;
const audioStream = {} as MediaStream;
const itShouldLogTheErrorAndShowTheNoMediaPermissionsModal = () => {
it("should log the error and show the »No media permissions« modal", () => {
expect(logger.log).toHaveBeenCalledWith("Failed to list userMedia devices", error);
screen.getByText("No media permissions");
});
};
beforeEach(() => {
useMockMediaDevices();
error = new Error();
jest.spyOn(logger, "log");
});
describe("when an audio and video device is available", () => {
beforeEach(() => {
mocked(navigator.mediaDevices.getUserMedia).mockImplementation(
async ({ audio, video }: MediaStreamConstraints): Promise<MediaStream> => {
if (audio && video) return audioVideoStream;
return audioStream;
},
);
});
it("should return the audio/video stream", async () => {
expect(await requestMediaPermissions()).toBe(audioVideoStream);
});
});
describe("when calling with video = false and an audio device is available", () => {
beforeEach(() => {
mocked(navigator.mediaDevices.getUserMedia).mockImplementation(
async ({ audio, video }: MediaStreamConstraints): Promise<MediaStream> => {
if (audio && !video) return audioStream;
return audioVideoStream;
},
);
});
it("should return the audio stream", async () => {
expect(await requestMediaPermissions(false)).toBe(audioStream);
});
});
describe("when only an audio stream is available", () => {
beforeEach(() => {
error.name = "NotFoundError";
mocked(navigator.mediaDevices.getUserMedia).mockImplementation(
async ({ audio, video }: MediaStreamConstraints): Promise<MediaStream> => {
if (audio && video) throw error;
if (audio) return audioStream;
return audioVideoStream;
},
);
});
it("should return the audio stream", async () => {
expect(await requestMediaPermissions()).toBe(audioStream);
});
});
describe("when no device is available", () => {
beforeEach(async () => {
error.name = "NotFoundError";
mocked(navigator.mediaDevices.getUserMedia).mockImplementation(async (): Promise<MediaStream> => {
throw error;
});
await requestMediaPermissions();
// required for the modal to settle
await flushPromises();
await flushPromises();
});
itShouldLogTheErrorAndShowTheNoMediaPermissionsModal();
});
describe("when an Error is raised", () => {
beforeEach(async () => {
mocked(navigator.mediaDevices.getUserMedia).mockImplementation(
async ({ audio, video }: MediaStreamConstraints): Promise<MediaStream> => {
if (audio && video) throw error;
return audioVideoStream;
},
);
await requestMediaPermissions();
// required for the modal to settle
await flushPromises();
await flushPromises();
});
itShouldLogTheErrorAndShowTheNoMediaPermissionsModal();
});
});

View file

@ -0,0 +1,120 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient, MatrixEvent, Room, RoomMember, RoomState, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked } from "jest-mock";
import { isKnockDenied, waitForMember } from "../../src/utils/membership";
import { createTestClient, mkRoomMember, stubClient } from "../test-utils";
describe("isKnockDenied", () => {
const userId = "alice";
let client: jest.Mocked<MatrixClient>;
let room: Room;
beforeEach(() => {
client = stubClient() as jest.Mocked<MatrixClient>;
room = new Room("!room-id:example.com", client, "@user:example.com");
});
it("checks that the user knock has been denied", () => {
const roomMember = mkRoomMember(room.roomId, userId, KnownMembership.Leave, true, {
membership: KnownMembership.Knock,
});
jest.spyOn(room, "getMember").mockReturnValue(roomMember);
expect(isKnockDenied(room)).toBe(true);
});
it.each([
{ membership: KnownMembership.Leave, isKicked: false, prevMembership: KnownMembership.Invite },
{ membership: KnownMembership.Leave, isKicked: true, prevMembership: KnownMembership.Invite },
{ membership: KnownMembership.Leave, isKicked: false, prevMembership: KnownMembership.Join },
{ membership: KnownMembership.Leave, isKicked: true, prevMembership: KnownMembership.Join },
])("checks that the user knock has been not denied", ({ membership, isKicked, prevMembership }) => {
const roomMember = mkRoomMember(room.roomId, userId, membership, isKicked, { membership: prevMembership });
jest.spyOn(room, "getMember").mockReturnValue(roomMember);
expect(isKnockDenied(room)).toBe(false);
});
});
/* Shorter timeout, we've got tests to run */
const timeout = 30;
describe("waitForMember", () => {
const STUB_ROOM_ID = "!stub_room:domain";
const STUB_MEMBER_ID = "!stub_member:domain";
let client: MatrixClient;
beforeEach(() => {
client = createTestClient();
// getRoom() only knows about !stub_room, which has only one member
const stubRoom = {
getMember: jest.fn().mockImplementation((userId) => {
return userId === STUB_MEMBER_ID ? ({} as RoomMember) : null;
}),
};
mocked(client.getRoom).mockImplementation((roomId) => {
return roomId === STUB_ROOM_ID ? (stubRoom as unknown as Room) : null;
});
});
afterEach(() => {
jest.useRealTimers();
});
it("resolves with false if the timeout is reached", async () => {
const result = await waitForMember(client, "", "", { timeout: 0 });
expect(result).toBe(false);
});
it("resolves with false if the timeout is reached, even if other RoomState.newMember events fire", async () => {
jest.useFakeTimers();
const roomId = "!roomId:domain";
const userId = "@clientId:domain";
const resultProm = waitForMember(client, roomId, userId, { timeout });
jest.advanceTimersByTime(50);
expect(await resultProm).toBe(false);
client.emit(
RoomStateEvent.NewMember,
undefined as unknown as MatrixEvent,
undefined as unknown as RoomState,
{
roomId,
userId: "@anotherClient:domain",
} as RoomMember,
);
jest.useRealTimers();
});
it("resolves with true if RoomState.newMember fires", async () => {
const roomId = "!roomId:domain";
const userId = "@clientId:domain";
const resultProm = waitForMember(client, roomId, userId, { timeout });
client.emit(
RoomStateEvent.NewMember,
undefined as unknown as MatrixEvent,
undefined as unknown as RoomState,
{ roomId, userId } as RoomMember,
);
expect(await resultProm).toBe(true);
});
it("resolves immediately if the user is already a member", async () => {
jest.useFakeTimers();
const resultProm = waitForMember(client, STUB_ROOM_ID, STUB_MEMBER_ID, { timeout });
expect(await resultProm).toBe(true);
});
it("waits for the timeout if the room is known but the user is not", async () => {
const result = await waitForMember(client, STUB_ROOM_ID, "@other_user", { timeout: 0 });
expect(result).toBe(false);
});
});

View file

@ -0,0 +1,365 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent, NotificationCountType, Room, MatrixClient, ReceiptType } from "matrix-js-sdk/src/matrix";
import { Mocked, mocked } from "jest-mock";
import {
localNotificationsAreSilenced,
getLocalNotificationAccountDataEventType,
createLocalNotificationSettingsIfNeeded,
deviceNotificationSettingsKeys,
clearAllNotifications,
clearRoomNotification,
notificationLevelToIndicator,
getThreadNotificationLevel,
getMarkedUnreadState,
setMarkedUnreadState,
} from "../../src/utils/notifications";
import SettingsStore from "../../src/settings/SettingsStore";
import { getMockClientWithEventEmitter } from "../test-utils/client";
import { mkMessage, stubClient } from "../test-utils/test-utils";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import { NotificationLevel } from "../../src/stores/notifications/NotificationLevel";
jest.mock("../../src/settings/SettingsStore");
describe("notifications", () => {
let accountDataStore: Record<string, MatrixEvent> = {};
let mockClient: Mocked<MatrixClient>;
let accountDataEventKey: string;
beforeEach(() => {
jest.clearAllMocks();
mockClient = getMockClientWithEventEmitter({
isGuest: jest.fn().mockReturnValue(false),
getAccountData: jest.fn().mockImplementation((eventType) => accountDataStore[eventType]),
setAccountData: jest.fn().mockImplementation((eventType, content) => {
accountDataStore[eventType] = new MatrixEvent({
type: eventType,
content,
});
}),
});
accountDataStore = {};
accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId!);
mocked(SettingsStore).getValue.mockReturnValue(false);
});
describe("createLocalNotification", () => {
it("creates account data event", async () => {
await createLocalNotificationSettingsIfNeeded(mockClient);
const event = mockClient.getAccountData(accountDataEventKey);
expect(event?.getContent().is_silenced).toBe(true);
});
it("does not do anything for guests", async () => {
mockClient.isGuest.mockReset().mockReturnValue(true);
await createLocalNotificationSettingsIfNeeded(mockClient);
const event = mockClient.getAccountData(accountDataEventKey);
expect(event).toBeFalsy();
});
it.each(deviceNotificationSettingsKeys)(
"unsilenced for existing sessions when %s setting is truthy",
async (settingKey) => {
mocked(SettingsStore).getValue.mockImplementation((key): any => {
return key === settingKey;
});
await createLocalNotificationSettingsIfNeeded(mockClient);
const event = mockClient.getAccountData(accountDataEventKey);
expect(event?.getContent().is_silenced).toBe(false);
},
);
it("does not override an existing account event data", async () => {
mockClient.setAccountData(accountDataEventKey, {
is_silenced: false,
});
await createLocalNotificationSettingsIfNeeded(mockClient);
const event = mockClient.getAccountData(accountDataEventKey);
expect(event?.getContent().is_silenced).toBe(false);
});
});
describe("localNotificationsAreSilenced", () => {
it("defaults to false when no setting exists", () => {
expect(localNotificationsAreSilenced(mockClient)).toBeFalsy();
});
it("checks the persisted value", () => {
mockClient.setAccountData(accountDataEventKey, { is_silenced: true });
expect(localNotificationsAreSilenced(mockClient)).toBeTruthy();
mockClient.setAccountData(accountDataEventKey, { is_silenced: false });
expect(localNotificationsAreSilenced(mockClient)).toBeFalsy();
});
});
describe("clearRoomNotification", () => {
let client: MatrixClient;
let room: Room;
let sendReadReceiptSpy: jest.SpyInstance;
const ROOM_ID = "123";
const USER_ID = "@bob:example.org";
let message: MatrixEvent;
let sendReceiptsSetting = true;
beforeEach(() => {
stubClient();
client = mocked(MatrixClientPeg.safeGet());
room = new Room(ROOM_ID, client, USER_ID);
message = mkMessage({
event: true,
room: ROOM_ID,
user: USER_ID,
msg: "Hello",
});
room.addLiveEvents([message]);
sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockResolvedValue({});
jest.spyOn(client, "getRooms").mockReturnValue([room]);
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
return name === "sendReadReceipts" && sendReceiptsSetting;
});
});
it("sends a request even if everything has been read", async () => {
await clearRoomNotification(room, client);
expect(sendReadReceiptSpy).toHaveBeenCalledWith(message, ReceiptType.Read, true);
});
it("marks the room as read even if the receipt failed", async () => {
room.setUnreadNotificationCount(NotificationCountType.Total, 5);
sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockReset().mockRejectedValue({ error: 42 });
await expect(async () => {
await clearRoomNotification(room, client);
}).rejects.toEqual({ error: 42 });
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
});
describe("when sendReadReceipts setting is disabled", () => {
beforeEach(() => {
sendReceiptsSetting = false;
});
it("should send a private read receipt", async () => {
await clearRoomNotification(room, client);
expect(sendReadReceiptSpy).toHaveBeenCalledWith(message, ReceiptType.ReadPrivate, true);
});
});
});
describe("clearAllNotifications", () => {
let client: MatrixClient;
let room: Room;
let sendReadReceiptSpy: jest.SpyInstance;
const ROOM_ID = "123";
const USER_ID = "@bob:example.org";
beforeEach(() => {
stubClient();
client = mocked(MatrixClientPeg.safeGet());
room = new Room(ROOM_ID, client, USER_ID);
sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockResolvedValue({});
jest.spyOn(client, "getRooms").mockReturnValue([room]);
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
return name === "sendReadReceipts";
});
});
it("does not send any requests if everything has been read", () => {
clearAllNotifications(client);
expect(sendReadReceiptSpy).not.toHaveBeenCalled();
});
it("sends unthreaded receipt requests", async () => {
const message = mkMessage({
event: true,
room: ROOM_ID,
user: USER_ID,
ts: 1,
});
room.addLiveEvents([message]);
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
await clearAllNotifications(client);
expect(sendReadReceiptSpy).toHaveBeenCalledWith(message, ReceiptType.Read, true);
});
it("sends private read receipts", async () => {
const message = mkMessage({
event: true,
room: ROOM_ID,
user: USER_ID,
ts: 1,
});
room.addLiveEvents([message]);
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false);
await clearAllNotifications(client);
expect(sendReadReceiptSpy).toHaveBeenCalledWith(message, ReceiptType.ReadPrivate, true);
});
});
describe("getMarkedUnreadState", () => {
let client: MatrixClient;
let room: Room;
const ROOM_ID = "123";
const USER_ID = "@bob:example.org";
beforeEach(() => {
stubClient();
client = mocked(MatrixClientPeg.safeGet());
room = new Room(ROOM_ID, client, USER_ID);
});
it("reads from stable prefix", async () => {
room.getAccountData = jest.fn().mockImplementation((eventType: string) => {
if (eventType === "m.marked_unread") {
return { getContent: jest.fn().mockReturnValue({ unread: true }) };
}
return null;
});
expect(getMarkedUnreadState(room)).toBe(true);
});
it("reads from unstable prefix", async () => {
room.getAccountData = jest.fn().mockImplementation((eventType: string) => {
if (eventType === "com.famedly.marked_unread") {
return { getContent: jest.fn().mockReturnValue({ unread: true }) };
}
return null;
});
expect(getMarkedUnreadState(room)).toBe(true);
});
it("returns undefined if neither prefix is present", async () => {
room.getAccountData = jest.fn().mockImplementation((eventType: string) => {
return null;
});
expect(getMarkedUnreadState(room)).toBe(undefined);
});
});
describe("setUnreadMarker", () => {
let client: MatrixClient;
let room: Room;
const ROOM_ID = "123";
const USER_ID = "@bob:example.org";
beforeEach(() => {
stubClient();
client = mocked(MatrixClientPeg.safeGet());
room = new Room(ROOM_ID, client, USER_ID);
});
// set true, no existing event
it("sets unread flag if event doesn't exist", async () => {
await setMarkedUnreadState(room, client, true);
expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", {
unread: true,
});
});
// set false, no existing event
it("does nothing when clearing if flag is false", async () => {
await setMarkedUnreadState(room, client, false);
expect(client.setRoomAccountData).not.toHaveBeenCalled();
});
// set true, existing event = false
it("sets unread flag to if existing event is false", async () => {
room.getAccountData = jest
.fn()
.mockReturnValue({ getContent: jest.fn().mockReturnValue({ unread: false }) });
await setMarkedUnreadState(room, client, true);
expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", {
unread: true,
});
});
// set false, existing event = false
it("does nothing if set false and existing event is false", async () => {
room.getAccountData = jest
.fn()
.mockReturnValue({ getContent: jest.fn().mockReturnValue({ unread: false }) });
await setMarkedUnreadState(room, client, false);
expect(client.setRoomAccountData).not.toHaveBeenCalled();
});
// set true, existing event = true
it("does nothing if setting true and existing event is true", async () => {
room.getAccountData = jest
.fn()
.mockReturnValue({ getContent: jest.fn().mockReturnValue({ unread: true }) });
await setMarkedUnreadState(room, client, true);
expect(client.setRoomAccountData).not.toHaveBeenCalled();
});
// set false, existing event = true
it("sets flag if setting false and existing event is true", async () => {
room.getAccountData = jest
.fn()
.mockReturnValue({ getContent: jest.fn().mockReturnValue({ unread: true }) });
await setMarkedUnreadState(room, client, false);
expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", {
unread: false,
});
});
});
describe("notificationLevelToIndicator", () => {
it("returns undefined if notification level is None", () => {
expect(notificationLevelToIndicator(NotificationLevel.None)).toBeUndefined();
});
it("returns default if notification level is Activity", () => {
expect(notificationLevelToIndicator(NotificationLevel.Activity)).toEqual("default");
});
it("returns success if notification level is Notification", () => {
expect(notificationLevelToIndicator(NotificationLevel.Notification)).toEqual("success");
});
it("returns critical if notification level is Highlight", () => {
expect(notificationLevelToIndicator(NotificationLevel.Highlight)).toEqual("critical");
});
});
describe("getThreadNotificationLevel", () => {
let room: Room;
const ROOM_ID = "123";
const USER_ID = "@bob:example.org";
beforeEach(() => {
room = new Room(ROOM_ID, MatrixClientPeg.safeGet(), USER_ID);
});
it.each([
{ notificationCountType: NotificationCountType.Highlight, expected: NotificationLevel.Highlight },
{ notificationCountType: NotificationCountType.Total, expected: NotificationLevel.Notification },
{ notificationCountType: null, expected: NotificationLevel.Activity },
])(
"returns NotificationLevel $expected when notificationCountType is $expected",
({ notificationCountType, expected }) => {
jest.spyOn(room, "threadsAggregateNotificationType", "get").mockReturnValue(notificationCountType);
expect(getThreadNotificationLevel(room)).toEqual(expected);
},
);
});
});

View file

@ -0,0 +1,160 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { clamp, defaultNumber, percentageOf, percentageWithin, sum } from "../../src/utils/numbers";
describe("numbers", () => {
describe("defaultNumber", () => {
it("should use the default when the input is not a number", () => {
const def = 42;
let result = defaultNumber(null, def);
expect(result).toBe(def);
result = defaultNumber(undefined, def);
expect(result).toBe(def);
result = defaultNumber(Number.NaN, def);
expect(result).toBe(def);
});
it("should use the number when it is a number", () => {
const input = 24;
const def = 42;
const result = defaultNumber(input, def);
expect(result).toBe(input);
});
});
describe("clamp", () => {
it("should clamp high numbers", () => {
const input = 101;
const min = 0;
const max = 100;
const result = clamp(input, min, max);
expect(result).toBe(max);
});
it("should clamp low numbers", () => {
const input = -1;
const min = 0;
const max = 100;
const result = clamp(input, min, max);
expect(result).toBe(min);
});
it("should not clamp numbers in range", () => {
const input = 50;
const min = 0;
const max = 100;
const result = clamp(input, min, max);
expect(result).toBe(input);
});
it("should clamp floats", () => {
const min = -0.1;
const max = +0.1;
let result = clamp(-1.2, min, max);
expect(result).toBe(min);
result = clamp(1.2, min, max);
expect(result).toBe(max);
result = clamp(0.02, min, max);
expect(result).toBe(0.02);
});
});
describe("sum", () => {
it("should sum", () => {
// duh
const result = sum(1, 2, 1, 4);
expect(result).toBe(8);
});
});
describe("percentageWithin", () => {
it("should work within 0-100", () => {
const result = percentageWithin(0.4, 0, 100);
expect(result).toBe(40);
});
it("should work within 0-100 when pct > 1", () => {
const result = percentageWithin(1.4, 0, 100);
expect(result).toBe(140);
});
it("should work within 0-100 when pct < 0", () => {
const result = percentageWithin(-1.4, 0, 100);
expect(result).toBe(-140);
});
it("should work with ranges other than 0-100", () => {
const result = percentageWithin(0.4, 10, 20);
expect(result).toBe(14);
});
it("should work with ranges other than 0-100 when pct > 1", () => {
const result = percentageWithin(1.4, 10, 20);
expect(result).toBe(24);
});
it("should work with ranges other than 0-100 when pct < 0", () => {
const result = percentageWithin(-1.4, 10, 20);
expect(result).toBe(-4);
});
it("should work with floats", () => {
const result = percentageWithin(0.4, 10.2, 20.4);
expect(result).toBe(14.28);
});
});
// These are the inverse of percentageWithin
describe("percentageOf", () => {
it("should work within 0-100", () => {
const result = percentageOf(40, 0, 100);
expect(result).toBe(0.4);
});
it("should work within 0-100 when val > 100", () => {
const result = percentageOf(140, 0, 100);
expect(result).toBe(1.4);
});
it("should work within 0-100 when val < 0", () => {
const result = percentageOf(-140, 0, 100);
expect(result).toBe(-1.4);
});
it("should work with ranges other than 0-100", () => {
const result = percentageOf(14, 10, 20);
expect(result).toBe(0.4);
});
it("should work with ranges other than 0-100 when val > 100", () => {
const result = percentageOf(24, 10, 20);
expect(result).toBe(1.4);
});
it("should work with ranges other than 0-100 when val < 0", () => {
const result = percentageOf(-4, 10, 20);
expect(result).toBe(-1.4);
});
it("should work with floats", () => {
const result = percentageOf(14.28, 10.2, 20.4);
expect(result).toBe(0.4);
});
it("should return 0 for values that cause a division by zero", () => {
expect(percentageOf(0, 0, 0)).toBe(0);
});
});
});

View file

@ -0,0 +1,236 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import {
objectClone,
objectDiff,
objectExcluding,
objectHasDiff,
objectKeyChanges,
objectShallowClone,
objectWithOnly,
} from "../../src/utils/objects";
describe("objects", () => {
describe("objectExcluding", () => {
it("should exclude the given properties", () => {
const input = { hello: "world", test: true };
const output = { hello: "world" };
const props = ["test", "doesnotexist"]; // we also make sure it doesn't explode on missing props
const result = objectExcluding(input, <any>props); // any is to test the missing prop
expect(result).toBeDefined();
expect(result).toMatchObject(output);
});
});
describe("objectWithOnly", () => {
it("should exclusively use the given properties", () => {
const input = { hello: "world", test: true };
const output = { hello: "world" };
const props = ["hello", "doesnotexist"]; // we also make sure it doesn't explode on missing props
const result = objectWithOnly(input, <any>props); // any is to test the missing prop
expect(result).toBeDefined();
expect(result).toMatchObject(output);
});
});
describe("objectShallowClone", () => {
it("should create a new object", () => {
const input = { test: 1 };
const result = objectShallowClone(input);
expect(result).toBeDefined();
expect(result).not.toBe(input);
expect(result).toMatchObject(input);
});
it("should only clone the top level properties", () => {
const input = { a: 1, b: { c: 2 } };
const result = objectShallowClone(input);
expect(result).toBeDefined();
expect(result).toMatchObject(input);
expect(result.b).toBe(input.b);
});
it("should support custom clone functions", () => {
const input = { a: 1, b: 2 };
const output = { a: 4, b: 8 };
const result = objectShallowClone(input, (k, v) => {
// XXX: inverted expectation for ease of assertion
expect(Object.keys(input)).toContain(k);
return v * 4;
});
expect(result).toBeDefined();
expect(result).toMatchObject(output);
});
});
describe("objectHasDiff", () => {
it("should return false for the same pointer", () => {
const a = {};
const result = objectHasDiff(a, a);
expect(result).toBe(false);
});
it("should return true if keys for A > keys for B", () => {
const a = { a: 1, b: 2 };
const b = { a: 1 };
const result = objectHasDiff(a, b);
expect(result).toBe(true);
});
it("should return true if keys for A < keys for B", () => {
const a = { a: 1 };
const b = { a: 1, b: 2 };
const result = objectHasDiff(a, b);
expect(result).toBe(true);
});
it("should return false if the objects are the same but different pointers", () => {
const a = { a: 1, b: 2 };
const b = { a: 1, b: 2 };
const result = objectHasDiff(a, b);
expect(result).toBe(false);
});
it("should consider pointers when testing values", () => {
const a = { a: {}, b: 2 }; // `{}` is shorthand for `new Object()`
const b = { a: {}, b: 2 };
const result = objectHasDiff(a, b);
expect(result).toBe(true); // even though the keys are the same, the value pointers vary
});
});
describe("objectDiff", () => {
it("should return empty sets for the same object", () => {
const a = { a: 1, b: 2 };
const b = { a: 1, b: 2 };
const result = objectDiff(a, b);
expect(result).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toHaveLength(0);
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
});
it("should return empty sets for the same object pointer", () => {
const a = { a: 1, b: 2 };
const result = objectDiff(a, a);
expect(result).toBeDefined();
expect(result.changed).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toHaveLength(0);
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
});
it("should indicate when property changes are made", () => {
const a = { a: 1, b: 2 };
const b = { a: 11, b: 2 };
const result = objectDiff(a, b);
expect(result.changed).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toHaveLength(1);
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
expect(result.changed).toEqual(["a"]);
});
it("should indicate when properties are added", () => {
const a = { a: 1, b: 2 };
const b = { a: 1, b: 2, c: 3 };
const result = objectDiff(a, b);
expect(result.changed).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toHaveLength(0);
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(0);
expect(result.added).toEqual(["c"]);
});
it("should indicate when properties are removed", () => {
const a = { a: 1, b: 2 };
const b = { a: 1 };
const result = objectDiff(a, b);
expect(result.changed).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toHaveLength(0);
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(1);
expect(result.removed).toEqual(["b"]);
});
it("should indicate when multiple aspects change", () => {
const a = { a: 1, b: 2, c: 3 };
const b: typeof a | { d: number } = { a: 1, b: 22, d: 4 };
const result = objectDiff(a, b);
expect(result.changed).toBeDefined();
expect(result.added).toBeDefined();
expect(result.removed).toBeDefined();
expect(result.changed).toHaveLength(1);
expect(result.added).toHaveLength(1);
expect(result.removed).toHaveLength(1);
expect(result.changed).toEqual(["b"]);
expect(result.removed).toEqual(["c"]);
expect(result.added).toEqual(["d"]);
});
});
describe("objectKeyChanges", () => {
it("should return an empty set if no properties changed", () => {
const a = { a: 1, b: 2 };
const b = { a: 1, b: 2 };
const result = objectKeyChanges(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(0);
});
it("should return an empty set if no properties changed for the same pointer", () => {
const a = { a: 1, b: 2 };
const result = objectKeyChanges(a, a);
expect(result).toBeDefined();
expect(result).toHaveLength(0);
});
it("should return properties which were changed, added, or removed", () => {
const a = { a: 1, b: 2, c: 3 };
const b: typeof a | { d: number } = { a: 1, b: 22, d: 4 };
const result = objectKeyChanges(a, b);
expect(result).toBeDefined();
expect(result).toHaveLength(3);
expect(result).toEqual(["c", "d", "b"]); // order isn't important, but the test cares
});
});
describe("objectClone", () => {
it("should deep clone an object", () => {
const a = {
hello: "world",
test: {
another: "property",
test: 42,
third: {
prop: true,
},
},
};
const result = objectClone(a);
expect(result).toBeDefined();
expect(result).not.toBe(a);
expect(result).toMatchObject(a);
expect(result.test).not.toBe(a.test);
expect(result.test.third).not.toBe(a.test.third);
});
});
});

View file

@ -0,0 +1,88 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "fetch-mock-jest";
import { mocked } from "jest-mock";
import { TokenRefresher } from "../../../src/utils/oidc/TokenRefresher";
import { persistAccessTokenInStorage, persistRefreshTokenInStorage } from "../../../src/utils/tokens/tokens";
import { mockPlatformPeg } from "../../test-utils";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
jest.mock("../../../src/utils/tokens/tokens", () => ({
persistAccessTokenInStorage: jest.fn(),
persistRefreshTokenInStorage: jest.fn(),
}));
describe("TokenRefresher", () => {
const clientId = "test-client-id";
const issuer = "https://auth.com/";
const redirectUri = "https://test.com";
const deviceId = "test-device-id";
const userId = "@alice:server.org";
const accessToken = "test-access-token";
const refreshToken = "test-refresh-token";
const authConfig = makeDelegatedAuthConfig(issuer);
const idTokenClaims = {
aud: "123",
iss: issuer,
sub: "123",
exp: 123,
iat: 456,
};
beforeEach(() => {
fetchMock.get(`${issuer}.well-known/openid-configuration`, authConfig.metadata);
fetchMock.get(`${issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
mocked(persistAccessTokenInStorage).mockResolvedValue(undefined);
mocked(persistRefreshTokenInStorage).mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should persist tokens with a pickle key", async () => {
const pickleKey = "test-pickle-key";
const getPickleKey = jest.fn().mockResolvedValue(pickleKey);
mockPlatformPeg({ getPickleKey });
const refresher = new TokenRefresher(issuer, clientId, redirectUri, deviceId, idTokenClaims, userId);
await refresher.oidcClientReady;
await refresher.persistTokens({ accessToken, refreshToken });
expect(getPickleKey).toHaveBeenCalledWith(userId, deviceId);
expect(persistAccessTokenInStorage).toHaveBeenCalledWith(accessToken, pickleKey);
expect(persistRefreshTokenInStorage).toHaveBeenCalledWith(refreshToken, pickleKey);
});
it("should persist tokens without a pickle key", async () => {
const getPickleKey = jest.fn().mockResolvedValue(null);
mockPlatformPeg({ getPickleKey });
const refresher = new TokenRefresher(issuer, clientId, redirectUri, deviceId, idTokenClaims, userId);
await refresher.oidcClientReady;
await refresher.persistTokens({ accessToken, refreshToken });
expect(getPickleKey).toHaveBeenCalledWith(userId, deviceId);
expect(persistAccessTokenInStorage).toHaveBeenCalledWith(accessToken, undefined);
expect(persistRefreshTokenInStorage).toHaveBeenCalledWith(refreshToken, undefined);
});
});

View file

@ -0,0 +1,164 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "fetch-mock-jest";
import { completeAuthorizationCodeGrant } from "matrix-js-sdk/src/oidc/authorize";
import * as randomStringUtils from "matrix-js-sdk/src/randomstring";
import { BearerTokenResponse } from "matrix-js-sdk/src/oidc/validate";
import { mocked } from "jest-mock";
import { Crypto } from "@peculiar/webcrypto";
import { getRandomValues } from "node:crypto";
import { completeOidcLogin, startOidcLogin } from "../../../src/utils/oidc/authorize";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
import { OidcClientError } from "../../../src/utils/oidc/error";
import { mockPlatformPeg } from "../../test-utils";
jest.unmock("matrix-js-sdk/src/randomstring");
jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({
...jest.requireActual("matrix-js-sdk/src/oidc/authorize"),
completeAuthorizationCodeGrant: jest.fn(),
}));
const webCrypto = new Crypto();
describe("OIDC authorization", () => {
const issuer = "https://auth.com/";
const homeserverUrl = "https://matrix.org";
const identityServerUrl = "https://is.org";
const clientId = "xyz789";
const baseUrl = "https://test.com";
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
// to restore later
const realWindowLocation = window.location;
beforeEach(() => {
// @ts-ignore allow delete of non-optional prop
delete window.location;
// @ts-ignore ugly mocking
window.location = {
href: baseUrl,
origin: baseUrl,
};
jest.spyOn(randomStringUtils, "randomString").mockRestore();
mockPlatformPeg();
Object.defineProperty(window, "crypto", {
value: {
getRandomValues,
randomUUID: jest.fn().mockReturnValue("not-random-uuid"),
subtle: webCrypto.subtle,
},
});
});
beforeAll(() => {
fetchMock.get(
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
delegatedAuthConfig.metadata,
);
});
afterAll(() => {
window.location = realWindowLocation;
});
describe("startOidcLogin()", () => {
it("navigates to authorization endpoint with correct parameters", async () => {
await startOidcLogin(delegatedAuthConfig, clientId, homeserverUrl);
const expectedScopeWithoutDeviceId = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:`;
const authUrl = new URL(window.location.href);
expect(authUrl.searchParams.get("response_mode")).toEqual("query");
expect(authUrl.searchParams.get("response_type")).toEqual("code");
expect(authUrl.searchParams.get("client_id")).toEqual(clientId);
expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256");
// scope ends with a 10char randomstring deviceId
const scope = authUrl.searchParams.get("scope")!;
expect(scope.substring(0, scope.length - 10)).toEqual(expectedScopeWithoutDeviceId);
expect(scope.substring(scope.length - 10)).toBeTruthy();
// random string, just check they are set
expect(authUrl.searchParams.has("state")).toBeTruthy();
expect(authUrl.searchParams.has("nonce")).toBeTruthy();
expect(authUrl.searchParams.has("code_challenge")).toBeTruthy();
});
});
describe("completeOidcLogin()", () => {
const state = "test-state-444";
const code = "test-code-777";
const queryDict = {
code,
state: state,
};
const tokenResponse: BearerTokenResponse = {
access_token: "abc123",
refresh_token: "def456",
id_token: "ghi789",
scope: "test",
token_type: "Bearer",
expires_at: 12345,
};
beforeEach(() => {
mocked(completeAuthorizationCodeGrant)
.mockClear()
.mockResolvedValue({
oidcClientSettings: {
clientId,
issuer,
},
tokenResponse,
homeserverUrl,
identityServerUrl,
idTokenClaims: {
aud: "123",
iss: issuer,
sub: "123",
exp: 123,
iat: 456,
},
});
});
it("should throw when query params do not include state and code", async () => {
await expect(async () => await completeOidcLogin({})).rejects.toThrow(
OidcClientError.InvalidQueryParameters,
);
});
it("should make request complete authorization code grant", async () => {
await completeOidcLogin(queryDict);
expect(completeAuthorizationCodeGrant).toHaveBeenCalledWith(code, state);
});
it("should return accessToken, configured homeserver and identityServer", async () => {
const result = await completeOidcLogin(queryDict);
expect(result).toEqual({
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
homeserverUrl,
identityServerUrl,
issuer,
clientId,
idToken: "ghi789",
idTokenClaims: result.idTokenClaims,
});
});
});
});

View file

@ -0,0 +1,107 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { IdTokenClaims } from "oidc-client-ts";
import { decodeIdToken } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import {
getStoredOidcClientId,
getStoredOidcIdToken,
getStoredOidcIdTokenClaims,
getStoredOidcTokenIssuer,
persistOidcAuthenticatedSettings,
} from "../../../src/utils/oidc/persistOidcSettings";
jest.mock("matrix-js-sdk/src/matrix");
describe("persist OIDC settings", () => {
jest.spyOn(Storage.prototype, "getItem");
jest.spyOn(Storage.prototype, "setItem");
beforeEach(() => {
localStorage.clear();
});
const clientId = "test-client-id";
const issuer = "https://auth.org/";
const idToken = "test-id-token";
const idTokenClaims: IdTokenClaims = {
// audience is this client
aud: "123",
// issuer matches
iss: issuer,
sub: "123",
exp: 123,
iat: 456,
};
describe("persistOidcAuthenticatedSettings", () => {
it("should set clientId and issuer in localStorage", () => {
persistOidcAuthenticatedSettings(clientId, issuer, idToken);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_client_id", clientId);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_token_issuer", issuer);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_id_token", idToken);
});
});
describe("getStoredOidcTokenIssuer()", () => {
it("should return issuer from localStorage", () => {
localStorage.setItem("mx_oidc_token_issuer", issuer);
expect(getStoredOidcTokenIssuer()).toEqual(issuer);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_token_issuer");
});
it("should return undefined when no issuer in localStorage", () => {
expect(getStoredOidcTokenIssuer()).toBeUndefined();
});
});
describe("getStoredOidcClientId()", () => {
it("should return clientId from localStorage", () => {
localStorage.setItem("mx_oidc_client_id", clientId);
expect(getStoredOidcClientId()).toEqual(clientId);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_client_id");
});
it("should throw when no clientId in localStorage", () => {
expect(() => getStoredOidcClientId()).toThrow("Oidc client id not found in storage");
});
});
describe("getStoredOidcIdToken()", () => {
it("should return token from localStorage", () => {
localStorage.setItem("mx_oidc_id_token", idToken);
expect(getStoredOidcIdToken()).toEqual(idToken);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_id_token");
});
it("should return undefined when no token in localStorage", () => {
expect(getStoredOidcIdToken()).toBeUndefined();
});
});
describe("getStoredOidcIdTokenClaims()", () => {
it("should return claims from localStorage", () => {
localStorage.setItem("mx_oidc_id_token_claims", JSON.stringify(idTokenClaims));
expect(getStoredOidcIdTokenClaims()).toEqual(idTokenClaims);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_id_token_claims");
});
it("should return claims extracted from id_token in localStorage", () => {
localStorage.setItem("mx_oidc_id_token", idToken);
mocked(decodeIdToken).mockReturnValue(idTokenClaims);
expect(getStoredOidcIdTokenClaims()).toEqual(idTokenClaims);
expect(decodeIdToken).toHaveBeenCalledWith(idToken);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_id_token_claims");
});
it("should return undefined when no claims in localStorage", () => {
expect(getStoredOidcIdTokenClaims()).toBeUndefined();
});
});
});

View file

@ -0,0 +1,128 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import fetchMockJest from "fetch-mock-jest";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { getOidcClientId } from "../../../src/utils/oidc/registerClient";
import { mockPlatformPeg } from "../../test-utils";
import PlatformPeg from "../../../src/PlatformPeg";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
describe("getOidcClientId()", () => {
const issuer = "https://auth.com/";
const clientName = "Element";
const baseUrl = "https://just.testing";
const dynamicClientId = "xyz789";
const staticOidcClients = {
[issuer]: {
client_id: "abc123",
},
};
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
beforeEach(() => {
fetchMockJest.mockClear();
fetchMockJest.resetBehavior();
mockPlatformPeg();
Object.defineProperty(PlatformPeg.get(), "baseUrl", {
get(): string {
return baseUrl;
},
});
Object.defineProperty(PlatformPeg.get(), "defaultOidcClientUri", {
get(): string {
return baseUrl;
},
});
Object.defineProperty(PlatformPeg.get(), "getOidcCallbackUrl", {
value: () => ({
href: baseUrl,
}),
});
});
it("should return static clientId when configured", async () => {
expect(await getOidcClientId(delegatedAuthConfig, staticOidcClients)).toEqual("abc123");
// didn't try to register
expect(fetchMockJest).toHaveFetchedTimes(0);
});
it("should throw when no static clientId is configured and no registration endpoint", async () => {
const authConfigWithoutRegistration: OidcClientConfig = makeDelegatedAuthConfig(
"https://issuerWithoutStaticClientId.org/",
);
authConfigWithoutRegistration.registrationEndpoint = undefined;
await expect(getOidcClientId(authConfigWithoutRegistration, staticOidcClients)).rejects.toThrow(
OidcError.DynamicRegistrationNotSupported,
);
// didn't try to register
expect(fetchMockJest).toHaveFetchedTimes(0);
});
it("should handle when staticOidcClients object is falsy", async () => {
const authConfigWithoutRegistration: OidcClientConfig = {
...delegatedAuthConfig,
registrationEndpoint: undefined,
};
await expect(getOidcClientId(authConfigWithoutRegistration)).rejects.toThrow(
OidcError.DynamicRegistrationNotSupported,
);
// didn't try to register
expect(fetchMockJest).toHaveFetchedTimes(0);
});
it("should make correct request to register client", async () => {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
status: 200,
body: JSON.stringify({ client_id: dynamicClientId }),
});
expect(await getOidcClientId(delegatedAuthConfig)).toEqual(dynamicClientId);
// didn't try to register
expect(fetchMockJest).toHaveBeenCalledWith(
delegatedAuthConfig.registrationEndpoint!,
expect.objectContaining({
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
method: "POST",
}),
);
expect(JSON.parse(fetchMockJest.mock.calls[0][1]!.body as string)).toEqual(
expect.objectContaining({
client_name: clientName,
client_uri: baseUrl,
response_types: ["code"],
grant_types: ["authorization_code", "refresh_token"],
redirect_uris: [baseUrl],
id_token_signed_response_alg: "RS256",
token_endpoint_auth_method: "none",
application_type: "web",
logo_uri: `${baseUrl}/vector-icons/1024.png`,
}),
);
});
it("should throw when registration request fails", async () => {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
status: 500,
});
await expect(getOidcClientId(delegatedAuthConfig)).rejects.toThrow(OidcError.DynamicRegistrationFailed);
});
it("should throw when registration response is invalid", async () => {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
status: 200,
// no clientId in response
body: "{}",
});
await expect(getOidcClientId(delegatedAuthConfig)).rejects.toThrow(OidcError.DynamicRegistrationInvalid);
});
});

View file

@ -0,0 +1,22 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { PermalinkParts } from "../../../src/utils/permalinks/PermalinkConstructor";
import MatrixSchemePermalinkConstructor from "../../../src/utils/permalinks/MatrixSchemePermalinkConstructor";
describe("MatrixSchemePermalinkConstructor", () => {
const peramlinkConstructor = new MatrixSchemePermalinkConstructor();
describe("parsePermalink", () => {
it("should strip ?action=chat from user links", () => {
expect(peramlinkConstructor.parsePermalink("matrix:u/user:example.com?action=chat")).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
});
});
});

View file

@ -0,0 +1,52 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import MatrixToPermalinkConstructor from "../../../src/utils/permalinks/MatrixToPermalinkConstructor";
import { PermalinkParts } from "../../../src/utils/permalinks/PermalinkConstructor";
describe("MatrixToPermalinkConstructor", () => {
const peramlinkConstructor = new MatrixToPermalinkConstructor();
describe("parsePermalink", () => {
it.each([
["empty URL", ""],
["something that is not an URL", "hello"],
["should raise an error for a non-matrix.to URL", "https://example.com/#/@user:example.com"],
])("should raise an error for %s", (name: string, url: string) => {
expect(() => peramlinkConstructor.parsePermalink(url)).toThrow(
new Error("Does not appear to be a permalink"),
);
});
it.each([
["(https)", "https://matrix.to/#/@user:example.com"],
["(http)", "http://matrix.to/#/@user:example.com"],
["without protocol", "matrix.to/#/@user:example.com"],
])("should parse an MXID %s", (name: string, url: string) => {
expect(peramlinkConstructor.parsePermalink(url)).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
});
});
describe("forRoom", () => {
it("constructs a link given a room ID and via servers", () => {
expect(peramlinkConstructor.forRoom("!myroom:example.com", ["one.example.com", "two.example.com"])).toEqual(
"https://matrix.to/#/!myroom:example.com?via=one.example.com&via=two.example.com",
);
});
});
describe("forEvent", () => {
it("constructs a link given an event ID, room ID and via servers", () => {
expect(
peramlinkConstructor.forEvent("!myroom:example.com", "$event4", ["one.example.com", "two.example.com"]),
).toEqual("https://matrix.to/#/!myroom:example.com/$event4?via=one.example.com&via=two.example.com");
});
});
});

View file

@ -0,0 +1,435 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EventEmitter } from "events";
import { Room, RoomMember, EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { PermalinkParts } from "../../../src/utils/permalinks/PermalinkConstructor";
import {
makeRoomPermalink,
makeUserPermalink,
parsePermalink,
RoomPermalinkCreator,
} from "../../../src/utils/permalinks/Permalinks";
import { IConfigOptions } from "../../../src/IConfigOptions";
import SdkConfig from "../../../src/SdkConfig";
import { getMockClientWithEventEmitter } from "../../test-utils";
describe("Permalinks", function () {
const userId = "@test:example.com";
const mockClient = getMockClientWithEventEmitter({
getUserId: jest.fn().mockReturnValue(userId),
getRoom: jest.fn(),
});
mockClient.credentials = { userId };
const makeMemberWithPL = (roomId: Room["roomId"], userId: string, powerLevel: number): RoomMember => {
const member = new RoomMember(roomId, userId);
member.powerLevel = powerLevel;
return member;
};
function mockRoom(
roomId: Room["roomId"],
members: RoomMember[],
serverACLContent?: { deny?: string[]; allow?: string[] },
): Room {
members.forEach((m) => (m.membership = KnownMembership.Join));
const powerLevelsUsers = members.reduce<Record<string, number>>((pl, member) => {
if (Number.isFinite(member.powerLevel)) {
pl[member.userId] = member.powerLevel;
}
return pl;
}, {});
const room = new Room(roomId, mockClient, userId);
const powerLevels = new MatrixEvent({
type: EventType.RoomPowerLevels,
room_id: roomId,
state_key: "",
content: {
users: powerLevelsUsers,
users_default: 0,
},
});
const serverACL = serverACLContent
? new MatrixEvent({
type: EventType.RoomServerAcl,
room_id: roomId,
state_key: "",
content: serverACLContent,
})
: undefined;
const stateEvents = serverACL ? [powerLevels, serverACL] : [powerLevels];
room.currentState.setStateEvents(stateEvents);
jest.spyOn(room, "getCanonicalAlias").mockReturnValue(null);
jest.spyOn(room, "getJoinedMembers").mockReturnValue(members);
jest.spyOn(room, "getMember").mockImplementation((userId) => members.find((m) => m.userId === userId) || null);
return room;
}
beforeEach(function () {
jest.clearAllMocks();
});
afterAll(() => {
jest.spyOn(MatrixClientPeg, "get").mockRestore();
});
it("should not clean up listeners even if start was called multiple times", () => {
const room = mockRoom("!fake:example.org", []);
const getListenerCount = (emitter: EventEmitter) =>
emitter
.eventNames()
.map((e) => emitter.listenerCount(e))
.reduce((a, b) => a + b, 0);
const listenerCountBefore = getListenerCount(room.currentState);
const creator = new RoomPermalinkCreator(room);
creator.start();
creator.start();
creator.start();
creator.start();
expect(getListenerCount(room.currentState)).toBeGreaterThan(listenerCountBefore);
creator.stop();
expect(getListenerCount(room.currentState)).toBe(listenerCountBefore);
});
it("should pick no candidate servers when the room has no members", function () {
const room = mockRoom("!fake:example.org", []);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should gracefully handle invalid MXIDs", () => {
const roomId = "!fake:example.org";
const alice50 = makeMemberWithPL(roomId, "@alice:pl_50:org", 50);
const room = mockRoom(roomId, [alice50]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
});
it("should pick a candidate server for the highest power level user in the room", function () {
const roomId = "!fake:example.org";
const alice50 = makeMemberWithPL(roomId, "@alice:pl_50", 50);
const alice75 = makeMemberWithPL(roomId, "@alice:pl_75", 75);
const alice95 = makeMemberWithPL(roomId, "@alice:pl_95", 95);
const room = mockRoom("!fake:example.org", [alice50, alice75, alice95]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(3);
expect(creator.serverCandidates![0]).toBe("pl_95");
// we don't check the 2nd and 3rd servers because that is done by the next test
});
it("should change candidate server when highest power level user leaves the room", function () {
const roomId = "!fake:example.org";
const member95 = makeMemberWithPL(roomId, "@alice:pl_95", 95);
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:pl_50", 50),
makeMemberWithPL(roomId, "@alice:pl_75", 75),
member95,
]);
const creator = new RoomPermalinkCreator(room, null);
creator.load();
expect(creator.serverCandidates![0]).toBe("pl_95");
member95.membership = KnownMembership.Leave;
// @ts-ignore illegal private property
creator.onRoomStateUpdate();
expect(creator.serverCandidates![0]).toBe("pl_75");
member95.membership = KnownMembership.Join;
// @ts-ignore illegal private property
creator.onRoomStateUpdate();
expect(creator.serverCandidates![0]).toBe("pl_95");
});
it("should pick candidate servers based on user population", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:first", 0),
makeMemberWithPL(roomId, "@bob:first", 0),
makeMemberWithPL(roomId, "@charlie:first", 0),
makeMemberWithPL(roomId, "@alice:second", 0),
makeMemberWithPL(roomId, "@bob:second", 0),
makeMemberWithPL(roomId, "@charlie:third", 0),
]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(3);
expect(creator.serverCandidates![0]).toBe("first");
expect(creator.serverCandidates![1]).toBe("second");
expect(creator.serverCandidates![2]).toBe("third");
});
it("should pick prefer candidate servers with higher power levels", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:first", 100),
makeMemberWithPL(roomId, "@alice:second", 0),
makeMemberWithPL(roomId, "@bob:second", 0),
makeMemberWithPL(roomId, "@charlie:third", 0),
]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates!.length).toBe(3);
expect(creator.serverCandidates![0]).toBe("first");
expect(creator.serverCandidates![1]).toBe("second");
expect(creator.serverCandidates![2]).toBe("third");
});
it("should pick a maximum of 3 candidate servers", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:alpha", 100),
makeMemberWithPL(roomId, "@alice:bravo", 0),
makeMemberWithPL(roomId, "@alice:charlie", 0),
makeMemberWithPL(roomId, "@alice:delta", 0),
makeMemberWithPL(roomId, "@alice:echo", 0),
]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(3);
});
it("should not consider IPv4 hosts", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:127.0.0.1", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should not consider IPv6 hosts", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:[::1]", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should not consider IPv4 hostnames with ports", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:127.0.0.1:8448", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should not consider IPv6 hostnames with ports", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:[::1]:8448", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should work with hostnames with ports", function () {
const roomId = "!fake:example.org";
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:example.org:8448", 100)]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(1);
expect(creator.serverCandidates![0]).toBe("example.org:8448");
});
it("should not consider servers explicitly denied by ACLs", function () {
const roomId = "!fake:example.org";
const room = mockRoom(
roomId,
[
makeMemberWithPL(roomId, "@alice:evilcorp.com", 100),
makeMemberWithPL(roomId, "@bob:chat.evilcorp.com", 0),
],
{
deny: ["evilcorp.com", "*.evilcorp.com"],
allow: ["*"],
},
);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should not consider servers not allowed by ACLs", function () {
const roomId = "!fake:example.org";
const room = mockRoom(
roomId,
[
makeMemberWithPL(roomId, "@alice:evilcorp.com", 100),
makeMemberWithPL(roomId, "@bob:chat.evilcorp.com", 0),
],
{
deny: [],
allow: [], // implies "ban everyone"
},
);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(0);
});
it("should consider servers not explicitly banned by ACLs", function () {
const roomId = "!fake:example.org";
const room = mockRoom(
roomId,
[
makeMemberWithPL(roomId, "@alice:evilcorp.com", 100),
makeMemberWithPL(roomId, "@bob:chat.evilcorp.com", 0),
],
{
deny: ["*.evilcorp.com"], // evilcorp.com is still good though
allow: ["*"],
},
);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(1);
expect(creator.serverCandidates![0]).toEqual("evilcorp.com");
});
it("should consider servers not disallowed by ACLs", function () {
const roomId = "!fake:example.org";
const room = mockRoom(
"!fake:example.org",
[
makeMemberWithPL(roomId, "@alice:evilcorp.com", 100),
makeMemberWithPL(roomId, "@bob:chat.evilcorp.com", 0),
],
{
deny: [],
allow: ["evilcorp.com"], // implies "ban everyone else"
},
);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
expect(creator.serverCandidates!.length).toBe(1);
expect(creator.serverCandidates![0]).toEqual("evilcorp.com");
});
it("should generate an event permalink for room IDs with no candidate servers", function () {
const room = mockRoom("!somewhere:example.org", []);
const creator = new RoomPermalinkCreator(room);
creator.load();
const result = creator.forEvent("$something:example.com");
expect(result).toBe("https://matrix.to/#/!somewhere:example.org/$something:example.com");
});
it("should generate an event permalink for room IDs with some candidate servers", function () {
const roomId = "!somewhere:example.org";
const room = mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:first", 100),
makeMemberWithPL(roomId, "@bob:second", 0),
]);
const creator = new RoomPermalinkCreator(room);
creator.load();
const result = creator.forEvent("$something:example.com");
expect(result).toBe("https://matrix.to/#/!somewhere:example.org/$something:example.com?via=first&via=second");
});
it("should generate a room permalink for room IDs with some candidate servers", function () {
mockClient.getRoom.mockImplementation((roomId: Room["roomId"]) => {
return mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:first", 100),
makeMemberWithPL(roomId, "@bob:second", 0),
]);
});
const result = makeRoomPermalink(mockClient, "!somewhere:example.org");
expect(result).toBe("https://matrix.to/#/!somewhere:example.org?via=first&via=second");
});
it("should generate a room permalink for room aliases with no candidate servers", function () {
mockClient.getRoom.mockReturnValue(null);
const result = makeRoomPermalink(mockClient, "#somewhere:example.org");
expect(result).toBe("https://matrix.to/#/#somewhere:example.org");
});
it("should generate a room permalink for room aliases without candidate servers", function () {
mockClient.getRoom.mockImplementation((roomId: Room["roomId"]) => {
return mockRoom(roomId, [
makeMemberWithPL(roomId, "@alice:first", 100),
makeMemberWithPL(roomId, "@bob:second", 0),
]);
});
const result = makeRoomPermalink(mockClient, "#somewhere:example.org");
expect(result).toBe("https://matrix.to/#/#somewhere:example.org");
});
it("should generate a user permalink", function () {
const result = makeUserPermalink("@someone:example.org");
expect(result).toBe("https://matrix.to/#/@someone:example.org");
});
it("should use permalink_prefix for permalinks", function () {
const sdkConfigGet = SdkConfig.get;
jest.spyOn(SdkConfig, "get").mockImplementation((key: keyof IConfigOptions, altCaseName?: string) => {
if (key === "permalink_prefix") {
return "https://element.fs.tld";
} else return sdkConfigGet(key, altCaseName);
});
const result = makeUserPermalink("@someone:example.org");
expect(result).toBe("https://element.fs.tld/#/user/@someone:example.org");
});
describe("parsePermalink", () => {
it("should correctly parse room permalinks with a via argument", () => {
const result = parsePermalink("https://matrix.to/#/!room_id:server?via=some.org");
expect(result?.roomIdOrAlias).toBe("!room_id:server");
expect(result?.viaServers).toEqual(["some.org"]);
});
it("should correctly parse room permalink via arguments", () => {
const result = parsePermalink("https://matrix.to/#/!room_id:server?via=foo.bar&via=bar.foo");
expect(result?.roomIdOrAlias).toBe("!room_id:server");
expect(result?.viaServers).toEqual(["foo.bar", "bar.foo"]);
});
it("should correctly parse event permalink via arguments", () => {
const result = parsePermalink(
"https://matrix.to/#/!room_id:server/$event_id/some_thing_here/foobar" + "?via=m1.org&via=m2.org",
);
expect(result?.eventId).toBe("$event_id/some_thing_here/foobar");
expect(result?.roomIdOrAlias).toBe("!room_id:server");
expect(result?.viaServers).toEqual(["m1.org", "m2.org"]);
});
it("should correctly parse permalinks with http protocol", () => {
expect(parsePermalink("http://matrix.to/#/@user:example.com")).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
});
it("should correctly parse permalinks without protocol", () => {
expect(parsePermalink("matrix.to/#/@user:example.com")).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
});
});
});

View file

@ -0,0 +1,134 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render } from "jest-matrix-react";
import { MatrixEvent, ConditionKind, EventType, PushRuleActionName, Room, TweakName } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { pillifyLinks } from "../../src/utils/pillify";
import { stubClient } from "../test-utils";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import DMRoomMap from "../../src/utils/DMRoomMap";
describe("pillify", () => {
const roomId = "!room:id";
const event = new MatrixEvent({
room_id: roomId,
type: EventType.RoomMessage,
content: {
body: "@room",
},
});
beforeEach(() => {
stubClient();
const cli = MatrixClientPeg.safeGet();
const room = new Room(roomId, cli, cli.getUserId()!);
room.currentState.mayTriggerNotifOfType = jest.fn().mockReturnValue(true);
(cli.getRoom as jest.Mock).mockReturnValue(room);
cli.pushRules!.global = {
override: [
{
rule_id: ".m.rule.roomnotif",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "content.body",
pattern: "@room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
value: true,
},
],
},
{
rule_id: ".m.rule.is_room_mention",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventPropertyIs,
key: "content.m\\.mentions.room",
value: true,
},
{
kind: ConditionKind.SenderNotificationPermission,
key: "room",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
},
],
},
],
};
DMRoomMap.makeShared(cli);
});
it("should do nothing for empty element", () => {
const { container } = render(<div />);
const originalHtml = container.outerHTML;
const containers: Element[] = [];
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
expect(containers).toHaveLength(0);
expect(container.outerHTML).toEqual(originalHtml);
});
it("should pillify @room", () => {
const { container } = render(<div>@room</div>);
const containers: Element[] = [];
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
expect(containers).toHaveLength(1);
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
it("should pillify @room in an intentional mentions world", () => {
mocked(MatrixClientPeg.safeGet().supportsIntentionalMentions).mockReturnValue(true);
const { container } = render(<div>@room</div>);
const containers: Element[] = [];
pillifyLinks(
MatrixClientPeg.safeGet(),
[container],
new MatrixEvent({
room_id: roomId,
type: EventType.RoomMessage,
content: {
"body": "@room",
"m.mentions": {
room: true,
},
},
}),
containers,
);
expect(containers).toHaveLength(1);
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
it("should not double up pillification on repeated calls", () => {
const { container } = render(<div>@room</div>);
const containers: Element[] = [];
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
pillifyLinks(MatrixClientPeg.safeGet(), [container], event, containers);
expect(containers).toHaveLength(1);
expect(container.querySelector(".mx_Pill.mx_AtRoomPill")?.textContent).toBe("!@room");
});
});

View file

@ -0,0 +1,57 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*
*/
import { batch } from "../../src/utils/promise.ts";
describe("promise.ts", () => {
describe("batch", () => {
afterEach(() => jest.useRealTimers());
it("should batch promises into groups of a given size", async () => {
const promises = [() => Promise.resolve(1), () => Promise.resolve(2), () => Promise.resolve(3)];
const batchSize = 2;
const result = await batch(promises, batchSize);
expect(result).toEqual([1, 2, 3]);
});
it("should wait for the current batch to finish to request the next one", async () => {
jest.useFakeTimers();
let promise1Called = false;
const promise1 = () =>
new Promise<number>((resolve) => {
promise1Called = true;
resolve(1);
});
let promise2Called = false;
const promise2 = () =>
new Promise<number>((resolve) => {
promise2Called = true;
setTimeout(() => {
resolve(2);
}, 10);
});
let promise3Called = false;
const promise3 = () =>
new Promise<number>((resolve) => {
promise3Called = true;
resolve(3);
});
const batchSize = 2;
const batchPromise = batch([promise1, promise2, promise3], batchSize);
expect(promise1Called).toBe(true);
expect(promise2Called).toBe(true);
expect(promise3Called).toBe(false);
jest.advanceTimersByTime(11);
expect(await batchPromise).toEqual([1, 2, 3]);
});
});
});

View file

@ -0,0 +1,98 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { JoinRule, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { shouldShowComponent } from "../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../src/settings/UIFeature";
import { canInviteTo } from "../../../src/utils/room/canInviteTo";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils";
jest.mock("../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
describe("canInviteTo()", () => {
afterEach(() => {
jest.restoreAllMocks();
});
const userId = "@alice:server.org";
const roomId = "!room:server.org";
const makeRoom = (): Room => {
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
});
const room = new Room(roomId, client, userId);
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
jest.spyOn(room, "canInvite").mockReturnValue(true);
return room;
};
beforeEach(() => {
mocked(shouldShowComponent).mockReturnValue(true);
});
describe("when user has permissions to issue an invite for this room", () => {
// aka when Room.canInvite is true
it("should return false when current user membership is not joined", () => {
const room = makeRoom();
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Invite);
expect(canInviteTo(room)).toEqual(false);
});
it("should return false when UIComponent.InviteUsers customisation hides invite", () => {
const room = makeRoom();
mocked(shouldShowComponent).mockReturnValue(false);
expect(canInviteTo(room)).toEqual(false);
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.InviteUsers);
});
it("should return true when user can invite and is a room member", () => {
const room = makeRoom();
expect(canInviteTo(room)).toEqual(true);
});
});
describe("when user does not have permissions to issue an invite for this room", () => {
// aka when Room.canInvite is false
it("should return false when room is a private space", () => {
const room = makeRoom();
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
jest.spyOn(room, "isSpaceRoom").mockReturnValue(true);
jest.spyOn(room, "canInvite").mockReturnValue(false);
expect(canInviteTo(room)).toEqual(false);
});
it("should return false when room is just a room", () => {
const room = makeRoom();
jest.spyOn(room, "canInvite").mockReturnValue(false);
expect(canInviteTo(room)).toEqual(false);
});
it("should return true when room is a public space", () => {
const room = makeRoom();
// default join rule is public
jest.spyOn(room, "isSpaceRoom").mockReturnValue(true);
jest.spyOn(room, "canInvite").mockReturnValue(false);
expect(canInviteTo(room)).toEqual(true);
});
});
});

View file

@ -0,0 +1,79 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { getFunctionalMembers } from "../../../src/utils/room/getFunctionalMembers";
import { getJoinedNonFunctionalMembers } from "../../../src/utils/room/getJoinedNonFunctionalMembers";
jest.mock("../../../src/utils/room/getFunctionalMembers", () => ({
getFunctionalMembers: jest.fn(),
}));
describe("getJoinedNonFunctionalMembers", () => {
let room: Room;
let roomMember1: RoomMember;
let roomMember2: RoomMember;
beforeEach(() => {
room = new Room("!room:example.com", {} as unknown as MatrixClient, "@user:example.com");
room.getJoinedMembers = jest.fn();
roomMember1 = new RoomMember(room.roomId, "@user1:example.com");
roomMember2 = new RoomMember(room.roomId, "@user2:example.com");
});
describe("if there are no members", () => {
beforeEach(() => {
mocked(room.getJoinedMembers).mockReturnValue([]);
mocked(getFunctionalMembers).mockReturnValue([]);
});
it("should return an empty list", () => {
expect(getJoinedNonFunctionalMembers(room)).toHaveLength(0);
});
});
describe("if there are only regular room members", () => {
beforeEach(() => {
mocked(room.getJoinedMembers).mockReturnValue([roomMember1, roomMember2]);
mocked(getFunctionalMembers).mockReturnValue([]);
});
it("should return the room members", () => {
const members = getJoinedNonFunctionalMembers(room);
expect(members).toContain(roomMember1);
expect(members).toContain(roomMember2);
});
});
describe("if there are only functional room members", () => {
beforeEach(() => {
mocked(room.getJoinedMembers).mockReturnValue([]);
mocked(getFunctionalMembers).mockReturnValue(["@functional:example.com"]);
});
it("should return an empty list", () => {
expect(getJoinedNonFunctionalMembers(room)).toHaveLength(0);
});
});
describe("if there are some functional room members", () => {
beforeEach(() => {
mocked(room.getJoinedMembers).mockReturnValue([roomMember1, roomMember2]);
mocked(getFunctionalMembers).mockReturnValue([roomMember1.userId]);
});
it("should only return the non-functional members", () => {
const members = getJoinedNonFunctionalMembers(room);
expect(members).not.toContain(roomMember1);
expect(members).toContain(roomMember2);
});
});
});

View file

@ -0,0 +1,49 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { Room, UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "matrix-js-sdk/src/matrix";
import { getFunctionalMembers } from "../../../src/utils/room/getFunctionalMembers";
import { createTestClient, mkEvent } from "../../test-utils";
describe("getRoomFunctionalMembers", () => {
const client = createTestClient();
const room = new Room("!room:example.com", client, client.getUserId()!);
it("should return an empty array if no functional members state event exists", () => {
expect(getFunctionalMembers(room)).toHaveLength(0);
});
it("should return an empty array if functional members state event does not have a service_members field", () => {
room.currentState.setStateEvents([
mkEvent({
event: true,
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
user: "@user:example.com)",
room: room.roomId,
skey: "",
content: {},
}),
]);
expect(getFunctionalMembers(room)).toHaveLength(0);
});
it("should return service_members field of the functional users state event", () => {
room.currentState.setStateEvents([
mkEvent({
event: true,
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
user: "@user:example.com)",
room: room.roomId,
skey: "",
content: { service_members: ["@user:example.com"] },
}),
]);
expect(getFunctionalMembers(room)).toEqual(["@user:example.com"]);
});
});

View file

@ -0,0 +1,57 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { inviteToRoom } from "../../../src/utils/room/inviteToRoom";
import { getMockClientWithEventEmitter } from "../../test-utils";
describe("inviteToRoom()", () => {
const userId = "@alice:server.org";
const roomId = "!room:server.org";
const makeRoom = (): Room => {
const client = getMockClientWithEventEmitter({
isGuest: jest.fn(),
});
const room = new Room(roomId, client, userId);
return room;
};
beforeEach(() => {
// stub
jest.spyOn(defaultDispatcher, "dispatch").mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
it("requires registration when a guest tries to invite to a room", () => {
const room = makeRoom();
jest.spyOn(room.client, "isGuest").mockReturnValue(true);
inviteToRoom(room);
expect(defaultDispatcher.dispatch).toHaveBeenCalledTimes(1);
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "require_registration" });
});
it("opens the room inviter", () => {
const room = makeRoom();
jest.spyOn(room.client, "isGuest").mockReturnValue(false);
inviteToRoom(room);
expect(defaultDispatcher.dispatch).toHaveBeenCalledTimes(1);
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "view_invite", roomId });
});
});

View file

@ -0,0 +1,102 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../../src/utils/room/shouldEncryptRoomWithSingle3rdPartyInvite";
import { privateShouldBeEncrypted } from "../../../src/utils/rooms";
import { mkRoomMemberJoinEvent, mkThirdPartyInviteEvent, stubClient } from "../../test-utils";
jest.mock("../../../src/utils/rooms", () => ({
privateShouldBeEncrypted: jest.fn(),
}));
describe("shouldEncryptRoomWithSingle3rdPartyInvite", () => {
let client: MatrixClient;
let thirdPartyInviteEvent: MatrixEvent;
let roomWithOneThirdPartyInvite: Room;
beforeAll(() => {
client = stubClient();
DMRoomMap.makeShared(client);
});
beforeEach(() => {
roomWithOneThirdPartyInvite = new Room("!room1:example.com", client, client.getSafeUserId());
thirdPartyInviteEvent = mkThirdPartyInviteEvent(
client.getSafeUserId(),
"user@example.com",
roomWithOneThirdPartyInvite.roomId,
);
roomWithOneThirdPartyInvite.currentState.setStateEvents([
mkRoomMemberJoinEvent(client.getSafeUserId(), roomWithOneThirdPartyInvite.roomId),
thirdPartyInviteEvent,
]);
jest.spyOn(DMRoomMap.shared(), "getRoomIds").mockReturnValue(new Set([roomWithOneThirdPartyInvite.roomId]));
});
describe("when well-known promotes encryption", () => {
beforeEach(() => {
mocked(privateShouldBeEncrypted).mockReturnValue(true);
});
it("should return true + invite event for a DM room with one third-party invite", () => {
expect(shouldEncryptRoomWithSingle3rdPartyInvite(roomWithOneThirdPartyInvite)).toEqual({
shouldEncrypt: true,
inviteEvent: thirdPartyInviteEvent,
});
});
it("should return false for a non-DM room with one third-party invite", () => {
mocked(DMRoomMap.shared().getRoomIds).mockReturnValue(new Set());
expect(shouldEncryptRoomWithSingle3rdPartyInvite(roomWithOneThirdPartyInvite)).toEqual({
shouldEncrypt: false,
});
});
it("should return false for a DM room with two members", () => {
roomWithOneThirdPartyInvite.currentState.setStateEvents([
mkRoomMemberJoinEvent("@user2:example.com", roomWithOneThirdPartyInvite.roomId),
]);
expect(shouldEncryptRoomWithSingle3rdPartyInvite(roomWithOneThirdPartyInvite)).toEqual({
shouldEncrypt: false,
});
});
it("should return false for a DM room with two third-party invites", () => {
roomWithOneThirdPartyInvite.currentState.setStateEvents([
mkThirdPartyInviteEvent(
client.getSafeUserId(),
"user2@example.com",
roomWithOneThirdPartyInvite.roomId,
),
]);
expect(shouldEncryptRoomWithSingle3rdPartyInvite(roomWithOneThirdPartyInvite)).toEqual({
shouldEncrypt: false,
});
});
});
describe("when well-known does not promote encryption", () => {
beforeEach(() => {
mocked(privateShouldBeEncrypted).mockReturnValue(false);
});
it("should return false for a DM room with one third-party invite", () => {
expect(shouldEncryptRoomWithSingle3rdPartyInvite(roomWithOneThirdPartyInvite)).toEqual({
shouldEncrypt: false,
});
});
});
});

View file

@ -0,0 +1,146 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import RoomListActions from "../../../src/actions/RoomListActions";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { DefaultTagID, TagID } from "../../../src/stores/room-list/models";
import RoomListStore from "../../../src/stores/room-list/RoomListStore";
import { tagRoom } from "../../../src/utils/room/tagRoom";
import { getMockClientWithEventEmitter } from "../../test-utils";
describe("tagRoom()", () => {
const userId = "@alice:server.org";
const roomId = "!room:server.org";
const makeRoom = (tags: TagID[] = []): Room => {
const client = getMockClientWithEventEmitter({
isGuest: jest.fn(),
});
const room = new Room(roomId, client, userId);
jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValue(tags);
return room;
};
beforeEach(() => {
// stub
jest.spyOn(defaultDispatcher, "dispatch").mockImplementation(() => {});
jest.spyOn(RoomListActions, "tagRoom").mockReturnValue({ action: "mocked_tag_room_action", fn: () => {} });
});
afterEach(() => {
jest.restoreAllMocks();
});
it("does nothing when room tag is not allowed", () => {
const room = makeRoom();
tagRoom(room, DefaultTagID.ServerNotice);
expect(defaultDispatcher.dispatch).not.toHaveBeenCalled();
expect(RoomListActions.tagRoom).not.toHaveBeenCalled();
});
describe("when a room has no tags", () => {
it("should tag a room as favourite", () => {
const room = makeRoom();
tagRoom(room, DefaultTagID.Favourite);
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
room.client,
room,
DefaultTagID.LowPriority, // remove
DefaultTagID.Favourite, // add
0,
);
});
it("should tag a room low priority", () => {
const room = makeRoom();
tagRoom(room, DefaultTagID.LowPriority);
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
room.client,
room,
DefaultTagID.Favourite, // remove
DefaultTagID.LowPriority, // add
0,
);
});
});
describe("when a room is tagged as favourite", () => {
it("should unfavourite a room", () => {
const room = makeRoom([DefaultTagID.Favourite]);
tagRoom(room, DefaultTagID.Favourite);
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
room.client,
room,
DefaultTagID.Favourite, // remove
null, // add
0,
);
});
it("should tag a room low priority", () => {
const room = makeRoom([DefaultTagID.Favourite]);
tagRoom(room, DefaultTagID.LowPriority);
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
room.client,
room,
DefaultTagID.Favourite, // remove
DefaultTagID.LowPriority, // add
0,
);
});
});
describe("when a room is tagged as low priority", () => {
it("should favourite a room", () => {
const room = makeRoom([DefaultTagID.LowPriority]);
tagRoom(room, DefaultTagID.Favourite);
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
room.client,
room,
DefaultTagID.LowPriority, // remove
DefaultTagID.Favourite, // add
0,
);
});
it("should untag a room low priority", () => {
const room = makeRoom([DefaultTagID.LowPriority]);
tagRoom(room, DefaultTagID.LowPriority);
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
room.client,
room,
DefaultTagID.LowPriority, // remove
null, // add
0,
);
});
});
});

View file

@ -0,0 +1,71 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { privateShouldBeEncrypted } from "../../src/utils/rooms";
import { getMockClientWithEventEmitter } from "../test-utils";
describe("privateShouldBeEncrypted()", () => {
const mockClient = getMockClientWithEventEmitter({
getClientWellKnown: jest.fn(),
});
beforeEach(() => {
mockClient.getClientWellKnown.mockReturnValue(undefined);
});
it("should return true when there is no e2ee well known", () => {
expect(privateShouldBeEncrypted(mockClient)).toEqual(true);
});
it("should return true when default is not set to false", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
default: true,
},
});
expect(privateShouldBeEncrypted(mockClient)).toEqual(true);
});
it("should return true when there is no default property", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
// no default
},
});
expect(privateShouldBeEncrypted(mockClient)).toEqual(true);
});
it("should return false when encryption is force disabled", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
force_disable: true,
default: true,
},
});
expect(privateShouldBeEncrypted(mockClient)).toEqual(false);
});
it("should return false when default encryption setting is false", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
force_disable: false,
default: false,
},
});
expect(privateShouldBeEncrypted(mockClient)).toEqual(false);
});
it("should return true when default encryption setting is set to something other than false", () => {
mockClient.getClientWellKnown.mockReturnValue({
"io.element.e2ee": {
default: "test",
},
});
expect(privateShouldBeEncrypted(mockClient)).toEqual(true);
});
});

View file

@ -0,0 +1,48 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { setHasDiff } from "../../src/utils/sets";
describe("sets", () => {
describe("setHasDiff", () => {
it("should flag true on A length > B length", () => {
const a = new Set([1, 2, 3, 4]);
const b = new Set([1, 2, 3]);
const result = setHasDiff(a, b);
expect(result).toBe(true);
});
it("should flag true on A length < B length", () => {
const a = new Set([1, 2, 3]);
const b = new Set([1, 2, 3, 4]);
const result = setHasDiff(a, b);
expect(result).toBe(true);
});
it("should flag true on element differences", () => {
const a = new Set([1, 2, 3]);
const b = new Set([4, 5, 6]);
const result = setHasDiff(a, b);
expect(result).toBe(true);
});
it("should flag false if same but order different", () => {
const a = new Set([1, 2, 3]);
const b = new Set([3, 1, 2]);
const result = setHasDiff(a, b);
expect(result).toBe(false);
});
it("should flag false if same", () => {
const a = new Set([1, 2, 3]);
const b = new Set([1, 2, 3]);
const result = setHasDiff(a, b);
expect(result).toBe(false);
});
});
});

View file

@ -0,0 +1,188 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { sortBy } from "lodash";
import { averageBetweenStrings, DEFAULT_ALPHABET } from "matrix-js-sdk/src/utils";
import { midPointsBetweenStrings, reorderLexicographically } from "../../src/utils/stringOrderField";
const moveLexicographicallyTest = (
orders: Array<string | undefined>,
fromIndex: number,
toIndex: number,
expectedChanges: number,
maxLength?: number,
): void => {
const ops = reorderLexicographically(orders, fromIndex, toIndex, maxLength);
const zipped: Array<[number, string | undefined]> = orders.map((o, i) => [i, o]);
ops.forEach(({ index, order }) => {
zipped[index][1] = order;
});
const newOrders = sortBy(zipped, (i) => i[1]);
expect(newOrders[toIndex][0]).toBe(fromIndex);
expect(ops).toHaveLength(expectedChanges);
};
describe("stringOrderField", () => {
describe("midPointsBetweenStrings", () => {
it("should work", () => {
expect(averageBetweenStrings("!!", "##")).toBe('""');
const midpoints = ["a", ...midPointsBetweenStrings("a", "e", 3, 1), "e"].sort();
expect(midpoints[0]).toBe("a");
expect(midpoints[4]).toBe("e");
expect(midPointsBetweenStrings(" ", "!'Tu:}", 1, 50)).toStrictEqual([" S:J\\~"]);
});
it("should return empty array when the request is not possible", () => {
expect(midPointsBetweenStrings("a", "e", 0, 1)).toStrictEqual([]);
expect(midPointsBetweenStrings("a", "e", 4, 1)).toStrictEqual([]);
});
});
describe("reorderLexicographically", () => {
it("should work when moving left", () => {
moveLexicographicallyTest(["a", "c", "e", "g", "i"], 2, 1, 1);
});
it("should work when moving right", () => {
moveLexicographicallyTest(["a", "c", "e", "g", "i"], 1, 2, 1);
});
it("should work when all orders are undefined", () => {
moveLexicographicallyTest([undefined, undefined, undefined, undefined, undefined, undefined], 4, 1, 2);
});
it("should work when moving to end and all orders are undefined", () => {
moveLexicographicallyTest([undefined, undefined, undefined, undefined, undefined, undefined], 1, 4, 5);
});
it("should work when moving left and some orders are undefined", () => {
moveLexicographicallyTest(["a", "c", "e", undefined, undefined, undefined], 5, 2, 1);
moveLexicographicallyTest(["a", "a", "e", undefined, undefined, undefined], 5, 1, 2);
});
it("should work moving to the start when all is undefined", () => {
moveLexicographicallyTest([undefined, undefined, undefined, undefined], 2, 0, 1);
});
it("should work moving to the end when all is undefined", () => {
moveLexicographicallyTest([undefined, undefined, undefined, undefined], 1, 3, 4);
});
it("should work moving left when all is undefined", () => {
moveLexicographicallyTest([undefined, undefined, undefined, undefined, undefined, undefined], 4, 1, 2);
});
it("should work moving right when all is undefined", () => {
moveLexicographicallyTest([undefined, undefined, undefined, undefined], 1, 2, 3);
});
it("should work moving more right when all is undefined", () => {
moveLexicographicallyTest(
[undefined, undefined, undefined, undefined, undefined, /**/ undefined, undefined],
1,
4,
5,
);
});
it("should work moving left when right is undefined", () => {
moveLexicographicallyTest(["20", undefined, undefined, undefined, undefined, undefined], 4, 2, 2);
});
it("should work moving right when right is undefined", () => {
moveLexicographicallyTest(
["50", undefined, undefined, undefined, undefined, /**/ undefined, undefined],
1,
4,
4,
);
});
it("should work moving left when right is defined", () => {
moveLexicographicallyTest(["10", "20", "30", "40", undefined, undefined], 3, 1, 1);
});
it("should work moving right when right is defined", () => {
moveLexicographicallyTest(["10", "20", "30", "40", "50", undefined], 1, 3, 1);
});
it("should work moving left when all is defined", () => {
moveLexicographicallyTest(["11", "13", "15", "17", "19"], 2, 1, 1);
});
it("should work moving right when all is defined", () => {
moveLexicographicallyTest(["11", "13", "15", "17", "19"], 1, 2, 1);
});
it("should work moving left into no left space", () => {
moveLexicographicallyTest(["11", "12", "13", "14", "19"], 3, 1, 2, 2);
moveLexicographicallyTest(
[
DEFAULT_ALPHABET.charAt(0),
// Target
DEFAULT_ALPHABET.charAt(1),
DEFAULT_ALPHABET.charAt(2),
DEFAULT_ALPHABET.charAt(3),
DEFAULT_ALPHABET.charAt(4),
DEFAULT_ALPHABET.charAt(5),
],
5,
1,
5,
1,
);
});
it("should work moving right into no right space", () => {
moveLexicographicallyTest(["15", "16", "17", "18", "19"], 1, 3, 3, 2);
moveLexicographicallyTest(
[
DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 5),
DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 4),
DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 3),
DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 2),
DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 1),
],
1,
3,
3,
1,
);
});
it("should work moving right into no left space", () => {
moveLexicographicallyTest(["11", "12", "13", "14", "15", "16", undefined], 1, 3, 3);
moveLexicographicallyTest(["0", "1", "2", "3", "4", "5"], 1, 3, 3, 1);
});
it("should work moving left into no right space", () => {
moveLexicographicallyTest(["15", "16", "17", "18", "19"], 4, 3, 4, 2);
moveLexicographicallyTest(
[
DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 5),
DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 4),
DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 3),
DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 2),
DEFAULT_ALPHABET.charAt(DEFAULT_ALPHABET.length - 1),
],
4,
3,
4,
1,
);
});
});
});

View file

@ -0,0 +1,148 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { Mocked } from "jest-mock";
import { IIdentityServerProvider, MatrixClient } from "matrix-js-sdk/src/matrix";
import { DirectoryMember, ThreepidMember } from "../../src/utils/direct-messages";
import { lookupThreePids, resolveThreePids } from "../../src/utils/threepids";
import { stubClient } from "../test-utils";
describe("threepids", () => {
let client: Mocked<MatrixClient>;
const accessToken = "s3cr3t";
let identityServer: Mocked<IIdentityServerProvider>;
beforeEach(() => {
client = stubClient() as Mocked<MatrixClient>;
identityServer = {
getAccessToken: jest.fn().mockResolvedValue(accessToken),
} as unknown as Mocked<IIdentityServerProvider>;
});
describe("resolveThreePids", () => {
const userId = "@user1:example.com";
const directoryMember = new DirectoryMember({
user_id: userId,
});
const threePid1Id = "three1@example.com";
const threePid1MXID = "@three1:example.com";
const threePid1Member = new ThreepidMember(threePid1Id);
const threePid1Displayname = "Three Pid 1";
const threePid2Id = "three2@example.com";
const threePid2MXID = "@three2:example.com";
const threePid2Member = new ThreepidMember(threePid2Id);
const threePid3Id = "three3@example.com";
const threePid3Member = new ThreepidMember(threePid3Id);
const threePidPhoneId = "8801500121121";
const threePidPhoneMember = new ThreepidMember(threePidPhoneId);
it("should return an empty list for an empty input", async () => {
expect(await resolveThreePids([], client)).toEqual([]);
});
it("should return the same list for non-3rd-party members", async () => {
expect(await resolveThreePids([directoryMember], client)).toEqual([directoryMember]);
});
it("should return the same list for if no identity server is configured", async () => {
expect(await resolveThreePids([directoryMember, threePid1Member], client)).toEqual([
directoryMember,
threePid1Member,
]);
});
describe("when an identity server is configured", () => {
beforeEach(() => {
client.identityServer = identityServer;
});
it("should return the same list if the lookup doesn't return any results", async () => {
expect(
await resolveThreePids(
[directoryMember, threePid1Member, threePid2Member, threePidPhoneMember],
client,
),
).toEqual([directoryMember, threePid1Member, threePid2Member, threePidPhoneMember]);
expect(client.bulkLookupThreePids).toHaveBeenCalledWith(
[
["email", threePid1Id],
["email", threePid2Id],
["msisdn", threePidPhoneId],
],
accessToken,
);
});
describe("and some 3-rd party members can be resolved", () => {
beforeEach(() => {
client.bulkLookupThreePids.mockResolvedValue({
threepids: [
["email", threePid1Id, threePid1MXID],
["email", threePid2Id, threePid2MXID],
],
});
});
it("should return the resolved members", async () => {
expect(
await resolveThreePids(
[directoryMember, threePid1Member, threePid2Member, threePid3Member],
client,
),
).toEqual([
directoryMember,
new DirectoryMember({ user_id: threePid1MXID }),
new DirectoryMember({ user_id: threePid2MXID }),
threePid3Member,
]);
expect(client.bulkLookupThreePids).toHaveBeenCalledWith(
[
["email", threePid1Id],
["email", threePid2Id],
["email", threePid3Id],
],
accessToken,
);
});
describe("and some 3rd-party members have a profile", () => {
beforeEach(() => {
client.getProfileInfo.mockImplementation((matrixId: string) => {
if (matrixId === threePid1MXID)
return Promise.resolve({ displayname: threePid1Displayname });
throw new Error("Profile not found");
});
});
it("should resolve the profiles", async () => {
expect(
await resolveThreePids(
[directoryMember, threePid1Member, threePid2Member, threePid3Member],
client,
),
).toEqual([
directoryMember,
new DirectoryMember({ user_id: threePid1MXID, display_name: threePid1Displayname }),
new DirectoryMember({ user_id: threePid2MXID }),
threePid3Member,
]);
});
});
});
});
});
describe("lookupThreePids", () => {
it("should return an empty list for an empty list", async () => {
client.identityServer = identityServer;
expect(await lookupThreePids([], client)).toEqual([]);
});
});
});

View file

@ -0,0 +1,76 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { act, render } from "jest-matrix-react";
import { tooltipifyLinks } from "../../src/utils/tooltipify";
import PlatformPeg from "../../src/PlatformPeg";
import BasePlatform from "../../src/BasePlatform";
describe("tooltipify", () => {
jest.spyOn(PlatformPeg, "get").mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform);
it("does nothing for empty element", () => {
const { container: root } = render(<div />);
const originalHtml = root.outerHTML;
const containers: Element[] = [];
tooltipifyLinks([root], [], containers);
expect(containers).toHaveLength(0);
expect(root.outerHTML).toEqual(originalHtml);
});
it("wraps single anchor", () => {
const { container: root } = render(
<div>
<a href="/foo">click</a>
</div>,
);
const containers: Element[] = [];
tooltipifyLinks([root], [], containers);
expect(containers).toHaveLength(1);
const anchor = root.querySelector("a");
expect(anchor?.getAttribute("href")).toEqual("/foo");
const tooltip = anchor!.querySelector(".mx_TextWithTooltip_target");
expect(tooltip).toBeDefined();
});
it("ignores node", () => {
const { container: root } = render(
<div>
<a href="/foo">click</a>
</div>,
);
const originalHtml = root.outerHTML;
const containers: Element[] = [];
tooltipifyLinks([root], [root.children[0]], containers);
expect(containers).toHaveLength(0);
expect(root.outerHTML).toEqual(originalHtml);
});
it("does not re-wrap if called multiple times", async () => {
const { container: root, unmount } = render(
<div>
<a href="/foo">click</a>
</div>,
);
const containers: Element[] = [];
tooltipifyLinks([root], [], containers);
tooltipifyLinks([root], [], containers);
tooltipifyLinks([root], [], containers);
tooltipifyLinks([root], [], containers);
expect(containers).toHaveLength(1);
const anchor = root.querySelector("a");
expect(anchor?.getAttribute("href")).toEqual("/foo");
const tooltip = anchor!.querySelector(".mx_TextWithTooltip_target");
expect(tooltip).toBeDefined();
await act(async () => {
unmount();
});
});
});

View file

@ -0,0 +1,35 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import { validateNumberInRange } from "../../../src/utils/validate";
describe("validateNumberInRange", () => {
const min = 1;
const max = 10;
it("returns false when value is a not a number", () => {
expect(validateNumberInRange(min, max)("test" as unknown as number)).toEqual(false);
});
it("returns false when value is undefined", () => {
expect(validateNumberInRange(min, max)(undefined)).toEqual(false);
});
it("returns false when value is NaN", () => {
expect(validateNumberInRange(min, max)(NaN)).toEqual(false);
});
it("returns true when value is equal to min", () => {
expect(validateNumberInRange(min, max)(min)).toEqual(true);
});
it("returns true when value is equal to max", () => {
expect(validateNumberInRange(min, max)(max)).toEqual(true);
});
it("returns true when value is an int in range", () => {
expect(validateNumberInRange(min, max)(2)).toEqual(true);
});
it("returns true when value is a float in range", () => {
expect(validateNumberInRange(min, max)(2.2)).toEqual(true);
});
});