Migrate more tests to vitest (#34349)
This commit is contained in:
@@ -1,743 +0,0 @@
|
||||
/*
|
||||
Copyright 2018-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { mocked, type Mocked, type MockedObject } from "jest-mock";
|
||||
import { HttpApiEvent, type MatrixClient, type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { decryptExistingEvent, mkDecryptionFailureMatrixEvent } from "matrix-js-sdk/src/testing";
|
||||
import {
|
||||
type CryptoApi,
|
||||
DecryptionFailureCode,
|
||||
UserVerificationStatus,
|
||||
CryptoEvent,
|
||||
} from "matrix-js-sdk/src/crypto-api";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
|
||||
import { DecryptionFailureTracker, type ErrorProperties } from "../../src/DecryptionFailureTracker";
|
||||
import { stubClient } from "../test-utils";
|
||||
import * as Lifecycle from "../../src/Lifecycle";
|
||||
|
||||
async function createFailedDecryptionEvent(opts: { sender?: string; code?: DecryptionFailureCode } = {}) {
|
||||
return await mkDecryptionFailureMatrixEvent({
|
||||
roomId: "!room:id",
|
||||
sender: opts.sender ?? "@alice:example.com",
|
||||
code: opts.code ?? DecryptionFailureCode.UNKNOWN_ERROR,
|
||||
msg: ":(",
|
||||
});
|
||||
}
|
||||
|
||||
// wrap tracker.eventDecrypted so that we don't need to have so many `ts-ignore`s
|
||||
function eventDecrypted(tracker: DecryptionFailureTracker, e: MatrixEvent, nowTs: number): void {
|
||||
// @ts-ignore access to private member
|
||||
return tracker.eventDecrypted(e, nowTs);
|
||||
}
|
||||
|
||||
describe("DecryptionFailureTracker", function () {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("tracks a failed decryption for a visible event", async function () {
|
||||
const failedDecryptionEvent = await createFailedDecryptionEvent();
|
||||
|
||||
let count = 0;
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
() => count++,
|
||||
() => "UnknownError",
|
||||
false,
|
||||
);
|
||||
|
||||
tracker.addVisibleEvent(failedDecryptionEvent);
|
||||
eventDecrypted(tracker, failedDecryptionEvent, Date.now());
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
// should track a failure for an event that failed decryption
|
||||
expect(count).not.toBe(0);
|
||||
});
|
||||
|
||||
it("tracks a failed decryption with expected raw error for a visible event", async function () {
|
||||
const failedDecryptionEvent = await createFailedDecryptionEvent({
|
||||
code: DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX,
|
||||
});
|
||||
|
||||
let count = 0;
|
||||
let reportedRawCode = "";
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
(_errCode: string, rawCode: string) => {
|
||||
count++;
|
||||
reportedRawCode = rawCode;
|
||||
},
|
||||
() => "UnknownError",
|
||||
false,
|
||||
);
|
||||
|
||||
tracker.addVisibleEvent(failedDecryptionEvent);
|
||||
eventDecrypted(tracker, failedDecryptionEvent, Date.now());
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
// should track a failure for an event that failed decryption
|
||||
expect(count).not.toBe(0);
|
||||
|
||||
// Should add the rawCode to the event context
|
||||
expect(reportedRawCode).toBe("OLM_UNKNOWN_MESSAGE_INDEX");
|
||||
});
|
||||
|
||||
it("tracks a failed decryption for an event that becomes visible later", async function () {
|
||||
const failedDecryptionEvent = await createFailedDecryptionEvent();
|
||||
|
||||
let count = 0;
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
() => count++,
|
||||
() => "UnknownError",
|
||||
false,
|
||||
);
|
||||
|
||||
eventDecrypted(tracker, failedDecryptionEvent, Date.now());
|
||||
tracker.addVisibleEvent(failedDecryptionEvent);
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
// should track a failure for an event that failed decryption
|
||||
expect(count).not.toBe(0);
|
||||
});
|
||||
|
||||
it("tracks visible vs. not visible events", async () => {
|
||||
const propertiesByErrorCode: Record<string, ErrorProperties> = {};
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
(errorCode: string, rawError: string, properties: ErrorProperties) => {
|
||||
propertiesByErrorCode[errorCode] = properties;
|
||||
},
|
||||
(error: string) => error,
|
||||
false,
|
||||
);
|
||||
|
||||
// use three different errors so that we can distinguish the reports
|
||||
const error1 = DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID;
|
||||
const error2 = DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE;
|
||||
const error3 = DecryptionFailureCode.MEGOLM_KEY_WITHHELD;
|
||||
|
||||
// event that will be marked as visible before it's marked as undecryptable
|
||||
const markedVisibleFirst = await createFailedDecryptionEvent({ code: error1 });
|
||||
// event that will be marked as undecryptable before it's marked as visible
|
||||
const markedUndecryptableFirst = await createFailedDecryptionEvent({ code: error2 });
|
||||
// event that is never marked as visible
|
||||
const neverVisible = await createFailedDecryptionEvent({ code: error3 });
|
||||
|
||||
tracker.addVisibleEvent(markedVisibleFirst);
|
||||
|
||||
const now = Date.now();
|
||||
eventDecrypted(tracker, markedVisibleFirst, now);
|
||||
eventDecrypted(tracker, markedUndecryptableFirst, now);
|
||||
eventDecrypted(tracker, neverVisible, now);
|
||||
|
||||
tracker.addVisibleEvent(markedUndecryptableFirst);
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
expect(propertiesByErrorCode[error1].wasVisibleToUser).toBe(true);
|
||||
expect(propertiesByErrorCode[error2].wasVisibleToUser).toBe(true);
|
||||
expect(propertiesByErrorCode[error3].wasVisibleToUser).toBe(false);
|
||||
});
|
||||
|
||||
it("does not track a failed decryption where the event is subsequently successfully decrypted", async () => {
|
||||
const decryptedEvent = await createFailedDecryptionEvent();
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
() => {
|
||||
// should not track an event that has since been decrypted correctly
|
||||
expect(true).toBe(false);
|
||||
},
|
||||
() => "UnknownError",
|
||||
false,
|
||||
);
|
||||
|
||||
tracker.addVisibleEvent(decryptedEvent);
|
||||
eventDecrypted(tracker, decryptedEvent, Date.now());
|
||||
|
||||
// Indicate successful decryption.
|
||||
await decryptExistingEvent(decryptedEvent, {
|
||||
plainType: "m.room.message",
|
||||
plainContent: { body: "success" },
|
||||
});
|
||||
eventDecrypted(tracker, decryptedEvent, Date.now());
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
});
|
||||
|
||||
it(
|
||||
"does not track a failed decryption where the event is subsequently successfully decrypted " +
|
||||
"and later becomes visible",
|
||||
async () => {
|
||||
const decryptedEvent = await createFailedDecryptionEvent();
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
() => {
|
||||
// should not track an event that has since been decrypted correctly
|
||||
expect(true).toBe(false);
|
||||
},
|
||||
() => "UnknownError",
|
||||
false,
|
||||
);
|
||||
|
||||
eventDecrypted(tracker, decryptedEvent, Date.now());
|
||||
|
||||
// Indicate successful decryption.
|
||||
await decryptExistingEvent(decryptedEvent, {
|
||||
plainType: "m.room.message",
|
||||
plainContent: { body: "success" },
|
||||
});
|
||||
eventDecrypted(tracker, decryptedEvent, Date.now());
|
||||
|
||||
tracker.addVisibleEvent(decryptedEvent);
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
},
|
||||
);
|
||||
|
||||
it("only tracks a single failure per event, despite multiple failed decryptions for multiple events", async () => {
|
||||
const decryptedEvent = await createFailedDecryptionEvent();
|
||||
const decryptedEvent2 = await createFailedDecryptionEvent();
|
||||
|
||||
let count = 0;
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
() => count++,
|
||||
() => "UnknownError",
|
||||
false,
|
||||
);
|
||||
|
||||
tracker.addVisibleEvent(decryptedEvent);
|
||||
|
||||
// Arbitrary number of failed decryptions for both events
|
||||
const now = Date.now();
|
||||
eventDecrypted(tracker, decryptedEvent, now);
|
||||
eventDecrypted(tracker, decryptedEvent, now);
|
||||
eventDecrypted(tracker, decryptedEvent, now);
|
||||
eventDecrypted(tracker, decryptedEvent, now);
|
||||
eventDecrypted(tracker, decryptedEvent, now);
|
||||
eventDecrypted(tracker, decryptedEvent2, now);
|
||||
eventDecrypted(tracker, decryptedEvent2, now);
|
||||
tracker.addVisibleEvent(decryptedEvent2);
|
||||
eventDecrypted(tracker, decryptedEvent2, now);
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
// Simulated polling of `checkFailures`, an arbitrary number ( > 2 ) times
|
||||
tracker.checkFailures(Infinity);
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
// should only track a single failure per event
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("should not track a failure for an event that was tracked previously", async () => {
|
||||
const decryptedEvent = await createFailedDecryptionEvent();
|
||||
|
||||
let count = 0;
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
() => count++,
|
||||
() => "UnknownError",
|
||||
);
|
||||
await tracker.start(mockClient());
|
||||
|
||||
tracker.addVisibleEvent(decryptedEvent);
|
||||
|
||||
// Indicate decryption
|
||||
eventDecrypted(tracker, decryptedEvent, Date.now());
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
// Indicate a second decryption, after having tracked the failure
|
||||
eventDecrypted(tracker, decryptedEvent, Date.now());
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
// should only track a single failure per event
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("should not report a failure for an event that was reported in a previous session", async () => {
|
||||
const decryptedEvent = await createFailedDecryptionEvent();
|
||||
|
||||
let count = 0;
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
() => count++,
|
||||
() => "UnknownError",
|
||||
);
|
||||
await tracker.start(mockClient());
|
||||
|
||||
tracker.addVisibleEvent(decryptedEvent);
|
||||
|
||||
// Indicate decryption
|
||||
eventDecrypted(tracker, decryptedEvent, Date.now());
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
// NB: This saves to localStorage specific to DFT
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
// Simulate the browser refreshing by destroying tracker and creating a new tracker
|
||||
// @ts-ignore access to private constructor
|
||||
const secondTracker = new DecryptionFailureTracker(
|
||||
() => count++,
|
||||
() => "UnknownError",
|
||||
);
|
||||
await secondTracker.start(mockClient());
|
||||
|
||||
secondTracker.addVisibleEvent(decryptedEvent);
|
||||
|
||||
eventDecrypted(secondTracker, decryptedEvent, Date.now());
|
||||
secondTracker.checkFailures(Infinity);
|
||||
|
||||
// should only track a single failure per event
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("should report a failure for an event that was tracked but not reported in a previous session", async () => {
|
||||
const decryptedEvent = await createFailedDecryptionEvent();
|
||||
|
||||
let count = 0;
|
||||
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
() => count++,
|
||||
() => "UnknownError",
|
||||
);
|
||||
await tracker.start(mockClient());
|
||||
|
||||
tracker.addVisibleEvent(decryptedEvent);
|
||||
|
||||
// Indicate decryption
|
||||
eventDecrypted(tracker, decryptedEvent, Date.now());
|
||||
|
||||
// we do *not* call `checkFailures` here
|
||||
expect(count).toBe(0);
|
||||
|
||||
// Simulate the browser refreshing by destroying tracker and creating a new tracker
|
||||
// @ts-ignore access to private constructor
|
||||
const secondTracker = new DecryptionFailureTracker(
|
||||
() => count++,
|
||||
() => "UnknownError",
|
||||
);
|
||||
await secondTracker.start(mockClient());
|
||||
|
||||
secondTracker.addVisibleEvent(decryptedEvent);
|
||||
|
||||
eventDecrypted(secondTracker, decryptedEvent, Date.now());
|
||||
secondTracker.checkFailures(Infinity);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("should report a failure for an event that was reported before a logout/login cycle", async () => {
|
||||
const decryptedEvent = await createFailedDecryptionEvent();
|
||||
|
||||
let count = 0;
|
||||
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
() => count++,
|
||||
() => "UnknownError",
|
||||
);
|
||||
await tracker.start(mockClient());
|
||||
|
||||
tracker.addVisibleEvent(decryptedEvent);
|
||||
|
||||
// Indicate decryption
|
||||
eventDecrypted(tracker, decryptedEvent, Date.now());
|
||||
tracker.checkFailures(Infinity);
|
||||
expect(count).toBe(1);
|
||||
|
||||
// Simulate a logout/login cycle
|
||||
await Lifecycle.onLoggedOut();
|
||||
await tracker.start(mockClient());
|
||||
|
||||
tracker.addVisibleEvent(decryptedEvent);
|
||||
eventDecrypted(tracker, decryptedEvent, Date.now());
|
||||
tracker.checkFailures(Infinity);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("should count different error codes separately for multiple failures with different error codes", async () => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
(errorCode: string) => (counts[errorCode] = (counts[errorCode] || 0) + 1),
|
||||
(error: DecryptionFailureCode) =>
|
||||
error === DecryptionFailureCode.UNKNOWN_ERROR ? "UnknownError" : "OlmKeysNotSentError",
|
||||
false,
|
||||
);
|
||||
|
||||
const decryptedEvent1 = await createFailedDecryptionEvent({
|
||||
code: DecryptionFailureCode.UNKNOWN_ERROR,
|
||||
});
|
||||
const decryptedEvent2 = await createFailedDecryptionEvent({
|
||||
code: DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
});
|
||||
const decryptedEvent3 = await createFailedDecryptionEvent({
|
||||
code: DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
});
|
||||
|
||||
tracker.addVisibleEvent(decryptedEvent1);
|
||||
tracker.addVisibleEvent(decryptedEvent2);
|
||||
tracker.addVisibleEvent(decryptedEvent3);
|
||||
|
||||
// One failure of UNKNOWN_ERROR, and effectively two for MEGOLM_UNKNOWN_INBOUND_SESSION_ID
|
||||
const now = Date.now();
|
||||
eventDecrypted(tracker, decryptedEvent1, now);
|
||||
eventDecrypted(tracker, decryptedEvent2, now);
|
||||
eventDecrypted(tracker, decryptedEvent2, now);
|
||||
eventDecrypted(tracker, decryptedEvent3, now);
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
//expect(counts['UnknownError']).toBe(1, 'should track one UnknownError');
|
||||
expect(counts["OlmKeysNotSentError"]).toBe(2);
|
||||
});
|
||||
|
||||
it("should aggregate error codes correctly", async () => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
(errorCode: string) => (counts[errorCode] = (counts[errorCode] || 0) + 1),
|
||||
(_errorCode: string) => "OlmUnspecifiedError",
|
||||
false,
|
||||
);
|
||||
|
||||
const decryptedEvent1 = await createFailedDecryptionEvent({
|
||||
code: DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
});
|
||||
const decryptedEvent2 = await createFailedDecryptionEvent({
|
||||
code: DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX,
|
||||
});
|
||||
const decryptedEvent3 = await createFailedDecryptionEvent({
|
||||
code: DecryptionFailureCode.UNKNOWN_ERROR,
|
||||
});
|
||||
|
||||
tracker.addVisibleEvent(decryptedEvent1);
|
||||
tracker.addVisibleEvent(decryptedEvent2);
|
||||
tracker.addVisibleEvent(decryptedEvent3);
|
||||
|
||||
const now = Date.now();
|
||||
eventDecrypted(tracker, decryptedEvent1, now);
|
||||
eventDecrypted(tracker, decryptedEvent2, now);
|
||||
eventDecrypted(tracker, decryptedEvent3, now);
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
expect(counts["OlmUnspecifiedError"]).toBe(3);
|
||||
});
|
||||
|
||||
it("should remap error codes correctly", async () => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
(errorCode: string) => (counts[errorCode] = (counts[errorCode] || 0) + 1),
|
||||
(errorCode: string) => Array.from(errorCode).reverse().join(""),
|
||||
false,
|
||||
);
|
||||
|
||||
const decryptedEvent = await createFailedDecryptionEvent({
|
||||
code: DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX,
|
||||
});
|
||||
tracker.addVisibleEvent(decryptedEvent);
|
||||
eventDecrypted(tracker, decryptedEvent, Date.now());
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
// should track remapped error code
|
||||
expect(counts["XEDNI_EGASSEM_NWONKNU_MLO"]).toBe(1);
|
||||
});
|
||||
|
||||
it("default error code mapper maps error codes correctly", async () => {
|
||||
const errorCodes: string[] = [];
|
||||
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
(errorCode: string) => {
|
||||
errorCodes.push(errorCode);
|
||||
},
|
||||
// @ts-ignore access to private member
|
||||
DecryptionFailureTracker.instance.errorCodeMapFn,
|
||||
false,
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
async function createAndTrackEventWithError(code: DecryptionFailureCode) {
|
||||
const event = await createFailedDecryptionEvent({ code });
|
||||
tracker.addVisibleEvent(event);
|
||||
eventDecrypted(tracker, event, now);
|
||||
return event;
|
||||
}
|
||||
|
||||
await createAndTrackEventWithError(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID);
|
||||
await createAndTrackEventWithError(DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX);
|
||||
await createAndTrackEventWithError(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
|
||||
await createAndTrackEventWithError(DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED);
|
||||
await createAndTrackEventWithError(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP);
|
||||
await createAndTrackEventWithError(DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED);
|
||||
await createAndTrackEventWithError(DecryptionFailureCode.MEGOLM_KEY_WITHHELD);
|
||||
await createAndTrackEventWithError(DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE);
|
||||
await createAndTrackEventWithError(DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED);
|
||||
await createAndTrackEventWithError(DecryptionFailureCode.UNSIGNED_SENDER_DEVICE);
|
||||
await createAndTrackEventWithError(DecryptionFailureCode.UNKNOWN_ERROR);
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
expect(errorCodes).toEqual([
|
||||
"OlmKeysNotSentError",
|
||||
"OlmIndexError",
|
||||
"HistoricalMessage",
|
||||
"HistoricalMessage",
|
||||
"HistoricalMessage",
|
||||
"ExpectedDueToMembership",
|
||||
"OlmKeysNotSentError",
|
||||
"RoomKeysWithheldForUnverifiedDevice",
|
||||
"ExpectedVerificationViolation",
|
||||
"ExpectedSentByInsecureDevice",
|
||||
"UnknownError",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks late decryptions vs. undecryptable", async () => {
|
||||
const propertiesByErrorCode: Record<string, ErrorProperties> = {};
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
(errorCode: string, rawError: string, properties: ErrorProperties) => {
|
||||
propertiesByErrorCode[errorCode] = properties;
|
||||
},
|
||||
(error: string) => error,
|
||||
false,
|
||||
);
|
||||
|
||||
// use three different errors so that we can distinguish the reports
|
||||
const error1 = DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID;
|
||||
const error2 = DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE;
|
||||
const error3 = DecryptionFailureCode.MEGOLM_KEY_WITHHELD;
|
||||
|
||||
// event that will be slow to decrypt
|
||||
const lateDecryption = await createFailedDecryptionEvent({ code: error1 });
|
||||
// event that will be so slow to decrypt, it gets counted as undecryptable
|
||||
const veryLateDecryption = await createFailedDecryptionEvent({ code: error2 });
|
||||
// event that never gets decrypted
|
||||
const neverDecrypted = await createFailedDecryptionEvent({ code: error3 });
|
||||
|
||||
tracker.addVisibleEvent(lateDecryption);
|
||||
tracker.addVisibleEvent(veryLateDecryption);
|
||||
tracker.addVisibleEvent(neverDecrypted);
|
||||
|
||||
const now = Date.now();
|
||||
eventDecrypted(tracker, lateDecryption, now);
|
||||
eventDecrypted(tracker, veryLateDecryption, now);
|
||||
eventDecrypted(tracker, neverDecrypted, now);
|
||||
|
||||
await decryptExistingEvent(lateDecryption, {
|
||||
plainType: "m.room.message",
|
||||
plainContent: { body: "success" },
|
||||
});
|
||||
await decryptExistingEvent(veryLateDecryption, {
|
||||
plainType: "m.room.message",
|
||||
plainContent: { body: "success" },
|
||||
});
|
||||
eventDecrypted(tracker, lateDecryption, now + 40000);
|
||||
eventDecrypted(tracker, veryLateDecryption, now + 100000);
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
expect(propertiesByErrorCode[error1].timeToDecryptMillis).toEqual(40000);
|
||||
expect(propertiesByErrorCode[error2].timeToDecryptMillis).toEqual(-1);
|
||||
expect(propertiesByErrorCode[error3].timeToDecryptMillis).toEqual(-1);
|
||||
});
|
||||
|
||||
it("listens for client events", async () => {
|
||||
// Test that the decryption failure tracker registers the right event
|
||||
// handlers on start, and unregisters them when the client logs out.
|
||||
const client = mockClient();
|
||||
|
||||
let errorCount: number = 0;
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
(errorCode: string, rawError: string, properties: ErrorProperties) => {
|
||||
errorCount++;
|
||||
},
|
||||
(error: string) => error,
|
||||
false,
|
||||
);
|
||||
|
||||
// Calling .start will start some intervals. This test shouldn't run
|
||||
// long enough for the timers to fire, but we'll use fake timers just
|
||||
// to be safe.
|
||||
jest.useFakeTimers();
|
||||
await tracker.start(client);
|
||||
|
||||
// If the client fails to decrypt, it should get tracked
|
||||
const failedDecryption = await createFailedDecryptionEvent();
|
||||
client.emit(MatrixEventEvent.Decrypted, failedDecryption);
|
||||
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
expect(errorCount).toEqual(1);
|
||||
|
||||
client.emit(HttpApiEvent.SessionLoggedOut, {} as any);
|
||||
|
||||
// After the client has logged out, we shouldn't be listening to events
|
||||
// any more, so even if the client emits an event regarding a failed
|
||||
// decryption, we won't track it.
|
||||
const anotherFailedDecryption = await createFailedDecryptionEvent();
|
||||
client.emit(MatrixEventEvent.Decrypted, anotherFailedDecryption);
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
expect(errorCount).toEqual(1);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("tracks client information", async () => {
|
||||
const client = mockClient();
|
||||
const propertiesByErrorCode: Record<string, ErrorProperties> = {};
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
(errorCode: string, rawError: string, properties: ErrorProperties) => {
|
||||
propertiesByErrorCode[errorCode] = properties;
|
||||
},
|
||||
(error: string) => error,
|
||||
false,
|
||||
);
|
||||
|
||||
// @ts-ignore access to private method
|
||||
await tracker.calculateClientProperties(client);
|
||||
// @ts-ignore access to private method
|
||||
await tracker.registerHandlers(client);
|
||||
|
||||
// use three different errors so that we can distinguish the reports
|
||||
const error1 = DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID;
|
||||
const error2 = DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE;
|
||||
const error3 = DecryptionFailureCode.MEGOLM_KEY_WITHHELD;
|
||||
|
||||
// event from a federated user (@alice:example.com)
|
||||
const federatedDecryption = await createFailedDecryptionEvent({
|
||||
code: error1,
|
||||
});
|
||||
// event from a local user
|
||||
const localDecryption = await createFailedDecryptionEvent({
|
||||
sender: "@bob:matrix.org",
|
||||
code: error2,
|
||||
});
|
||||
|
||||
tracker.addVisibleEvent(federatedDecryption);
|
||||
tracker.addVisibleEvent(localDecryption);
|
||||
|
||||
const now = Date.now();
|
||||
eventDecrypted(tracker, federatedDecryption, now);
|
||||
|
||||
mocked(client.getCrypto()!.getUserVerificationStatus).mockResolvedValue(
|
||||
new UserVerificationStatus(true, true, false),
|
||||
);
|
||||
client.emit(CryptoEvent.KeysChanged, {});
|
||||
await sleep(100);
|
||||
eventDecrypted(tracker, localDecryption, now);
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
expect(propertiesByErrorCode[error1].isMatrixDotOrg).toBe(true);
|
||||
expect(propertiesByErrorCode[error1].cryptoSDK).toEqual("Rust");
|
||||
|
||||
expect(propertiesByErrorCode[error1].isFederated).toBe(true);
|
||||
expect(propertiesByErrorCode[error1].userTrustsOwnIdentity).toEqual(false);
|
||||
expect(propertiesByErrorCode[error2].isFederated).toBe(false);
|
||||
expect(propertiesByErrorCode[error2].userTrustsOwnIdentity).toEqual(true);
|
||||
|
||||
// change client params, and make sure the reports the right values
|
||||
client.getDomain.mockReturnValue("example.com");
|
||||
mocked(client.getCrypto()!.getVersion).mockReturnValue("Olm 0.0.0");
|
||||
// @ts-ignore access to private method
|
||||
await tracker.calculateClientProperties(client);
|
||||
|
||||
const anotherFailure = await createFailedDecryptionEvent({
|
||||
code: error3,
|
||||
});
|
||||
tracker.addVisibleEvent(anotherFailure);
|
||||
eventDecrypted(tracker, anotherFailure, now);
|
||||
tracker.checkFailures(Infinity);
|
||||
expect(propertiesByErrorCode[error3].isMatrixDotOrg).toBe(false);
|
||||
expect(propertiesByErrorCode[error3].cryptoSDK).toEqual("Legacy");
|
||||
});
|
||||
|
||||
it("keeps the original timestamp after repeated decryption failures", async () => {
|
||||
const failedDecryptionEvent = await createFailedDecryptionEvent();
|
||||
|
||||
let failure: ErrorProperties | undefined;
|
||||
// @ts-ignore access to private constructor
|
||||
const tracker = new DecryptionFailureTracker(
|
||||
(errorCode: string, rawError: string, properties: ErrorProperties) => {
|
||||
failure = properties;
|
||||
},
|
||||
() => "UnknownError",
|
||||
false,
|
||||
);
|
||||
|
||||
tracker.addVisibleEvent(failedDecryptionEvent);
|
||||
|
||||
const now = Date.now();
|
||||
eventDecrypted(tracker, failedDecryptionEvent, now);
|
||||
eventDecrypted(tracker, failedDecryptionEvent, now + 20000);
|
||||
await decryptExistingEvent(failedDecryptionEvent, {
|
||||
plainType: "m.room.message",
|
||||
plainContent: { body: "success" },
|
||||
});
|
||||
eventDecrypted(tracker, failedDecryptionEvent, now + 50000);
|
||||
|
||||
// Pretend "now" is Infinity
|
||||
tracker.checkFailures(Infinity);
|
||||
|
||||
// the time to decrypt should be relative to the first time we failed
|
||||
// to decrypt, not the second
|
||||
expect(failure?.timeToDecryptMillis).toEqual(50000);
|
||||
});
|
||||
});
|
||||
|
||||
function mockClient(): MockedObject<MatrixClient> {
|
||||
const client = mocked(stubClient());
|
||||
const mockCrypto = {
|
||||
getVersion: jest.fn().mockReturnValue("Rust SDK 0.7.0 (61b175b), Vodozemac 0.5.1"),
|
||||
getUserVerificationStatus: jest.fn().mockResolvedValue(new UserVerificationStatus(false, false, false)),
|
||||
} as unknown as Mocked<CryptoApi>;
|
||||
client.getCrypto.mockReturnValue(mockCrypto);
|
||||
|
||||
// @ts-ignore
|
||||
client.stopClient = jest.fn(() => {});
|
||||
// @ts-ignore
|
||||
client.removeAllListeners = jest.fn(() => {});
|
||||
|
||||
client.store = { destroy: jest.fn(() => {}) } as any;
|
||||
|
||||
return client;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import EventEmitter from "events";
|
||||
import { SimpleObservable } from "matrix-widget-api";
|
||||
|
||||
import { PlaybackState } from "../../../src/audio/Playback";
|
||||
import { vi } from "../../setup/adapter.ts";
|
||||
|
||||
/**
|
||||
* A mocked playback implementation for testing purposes.
|
||||
@@ -51,8 +52,8 @@ export class MockedPlayback extends EventEmitter {
|
||||
return this.waveformObservable;
|
||||
}
|
||||
|
||||
public prepare = jest.fn().mockResolvedValue(undefined);
|
||||
public skipTo = jest.fn();
|
||||
public toggle = jest.fn();
|
||||
public destroy = jest.fn().mockResolvedValue(undefined);
|
||||
public prepare = vi.fn().mockResolvedValue(undefined);
|
||||
public skipTo = vi.fn();
|
||||
public toggle = vi.fn();
|
||||
public destroy = vi.fn().mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { 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();
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`FormattingUtils formatList should return expected sentence in ReactNode when given 2 React children 1`] = `
|
||||
<React.Fragment>
|
||||
<React.Fragment>
|
||||
<span>
|
||||
a
|
||||
</span>
|
||||
</React.Fragment>
|
||||
<React.Fragment>
|
||||
and
|
||||
</React.Fragment>
|
||||
<React.Fragment>
|
||||
<span>
|
||||
b
|
||||
</span>
|
||||
</React.Fragment>
|
||||
</React.Fragment>
|
||||
`;
|
||||
|
||||
exports[`FormattingUtils formatList should return expected sentence in ReactNode when given more React children 1`] = `
|
||||
<React.Fragment>
|
||||
<React.Fragment>
|
||||
<span>
|
||||
a
|
||||
</span>
|
||||
</React.Fragment>
|
||||
<React.Fragment>
|
||||
,
|
||||
</React.Fragment>
|
||||
<React.Fragment>
|
||||
<span>
|
||||
b
|
||||
</span>
|
||||
</React.Fragment>
|
||||
<React.Fragment>
|
||||
,
|
||||
</React.Fragment>
|
||||
<React.Fragment>
|
||||
<span>
|
||||
c
|
||||
</span>
|
||||
</React.Fragment>
|
||||
<React.Fragment>
|
||||
and
|
||||
</React.Fragment>
|
||||
<React.Fragment>
|
||||
<span>
|
||||
d
|
||||
</span>
|
||||
</React.Fragment>
|
||||
</React.Fragment>
|
||||
`;
|
||||
|
||||
exports[`FormattingUtils formatList should return expected sentence in ReactNode when using itemLimit 1`] = `
|
||||
<span>
|
||||
<React.Fragment>
|
||||
<React.Fragment>
|
||||
<span>
|
||||
a
|
||||
</span>
|
||||
,
|
||||
</React.Fragment>
|
||||
<React.Fragment>
|
||||
<span>
|
||||
b
|
||||
</span>
|
||||
</React.Fragment>
|
||||
</React.Fragment>
|
||||
and 2 others
|
||||
</span>
|
||||
`;
|
||||
@@ -1,500 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
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 <strong>
|
||||
<span
|
||||
class="mx_EditHistoryMessage_insertion"
|
||||
>
|
||||
t
|
||||
</span>
|
||||
here</strong>
|
||||
</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>
|
||||
`;
|
||||
@@ -1,32 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
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",
|
||||
}
|
||||
`;
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type 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();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,462 +0,0 @@
|
||||
/*
|
||||
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 OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type 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 { type 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 handle when ACL allow is not an array", function () {
|
||||
const roomId = "!fake:example.org";
|
||||
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:goodcorp.com", 100)], {
|
||||
deny: ["*.evilcorp.com"],
|
||||
allow: "not-an-array" as any, // Test malformed data
|
||||
});
|
||||
const creator = new RoomPermalinkCreator(room);
|
||||
creator.load();
|
||||
// Should fall back to default behavior (no allowed servers list = allow none)
|
||||
expect(creator.serverCandidates).toBeTruthy();
|
||||
expect(creator.serverCandidates!.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle when ACL deny is not an array", function () {
|
||||
const roomId = "!fake:example.org";
|
||||
const room = mockRoom(roomId, [makeMemberWithPL(roomId, "@alice:goodcorp.com", 100)], {
|
||||
deny: "not-an-array" as any, // Test malformed data
|
||||
allow: ["*"],
|
||||
});
|
||||
const creator = new RoomPermalinkCreator(room);
|
||||
creator.load();
|
||||
// Should not crash and still allow the server
|
||||
expect(creator.serverCandidates).toBeTruthy();
|
||||
expect(creator.serverCandidates!.length).toBe(1);
|
||||
expect(creator.serverCandidates![0]).toEqual("goodcorp.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?: string) => {
|
||||
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?: string) => {
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Mocked } from "jest-mock";
|
||||
import { type IIdentityServerProvider, type 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import {
|
||||
clearUserStatus,
|
||||
fetchUserStatus,
|
||||
setUserStatus,
|
||||
userStatusFromProfile,
|
||||
userStatusTextWithinMaxLength,
|
||||
} from "../../../src/utils/userStatus";
|
||||
import { stubClient } from "../../test-utils";
|
||||
|
||||
describe("userStatus utils", () => {
|
||||
describe("userStatusFromProfile", () => {
|
||||
it("returns the user status if it is valid", () => {
|
||||
expect(userStatusFromProfile({ emoji: "🐳", text: "Feeling a little blue" }, undefined)).toEqual({
|
||||
emoji: "🐳",
|
||||
text: "Feeling a little blue",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined if the user status is invalid and there is no call status", () => {
|
||||
expect(userStatusFromProfile({ text: "Feeling a little blue" }, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the call status if the user status is invalid but the call status is valid", () => {
|
||||
expect(userStatusFromProfile({ text: "Feeling a little blue" }, { call_joined_ts: 12345 })).toEqual({
|
||||
emoji: "📞",
|
||||
text: "On a call",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the user status over the call status if both are valid", () => {
|
||||
expect(
|
||||
userStatusFromProfile({ emoji: "🐳", text: "Feeling a little blue" }, { call_joined_ts: 12345 }),
|
||||
).toEqual({
|
||||
emoji: "🐳",
|
||||
text: "Feeling a little blue",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined if the call status has a non-positive call_joined_ts", () => {
|
||||
expect(userStatusFromProfile(undefined, { call_joined_ts: 0 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined if neither status is valid", () => {
|
||||
expect(userStatusFromProfile(undefined, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("userStatusTextWithinMaxLength", () => {
|
||||
it("returns true for text within the max length", () => {
|
||||
const text = "a".repeat(256);
|
||||
expect(userStatusTextWithinMaxLength(text)).toBe(true);
|
||||
});
|
||||
it("returns false for text exceeding the max length", () => {
|
||||
const text = "a".repeat(257);
|
||||
expect(userStatusTextWithinMaxLength(text)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setUserStatus", () => {
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
});
|
||||
|
||||
it("sets the user status with valid input", async () => {
|
||||
setUserStatus(client, { emoji: "🐳", text: "Feeling a little blue" });
|
||||
|
||||
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", {
|
||||
emoji: "🐳",
|
||||
text: "Feeling a little blue",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchUserStatus", () => {
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
client.doesServerSupportExtendedProfiles = jest.fn();
|
||||
});
|
||||
|
||||
it("returns undefined if the server does not support extended profiles", async () => {
|
||||
mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(false);
|
||||
|
||||
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
|
||||
expect(client.getExtendedProfileProperty).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the validated status if the server supports extended profiles and has a status set", async () => {
|
||||
mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
|
||||
mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🐳", text: "Feeling a little blue" });
|
||||
|
||||
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toEqual({
|
||||
emoji: "🐳",
|
||||
text: "Feeling a little blue",
|
||||
});
|
||||
expect(client.getExtendedProfileProperty).toHaveBeenCalledWith(
|
||||
"@alice:example.com",
|
||||
"org.matrix.msc4426.status",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined if the status is invalid", async () => {
|
||||
mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
|
||||
mocked(client.getExtendedProfileProperty).mockResolvedValue({ text: "Feeling a little blue" });
|
||||
|
||||
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined if the user has no status set", async () => {
|
||||
mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
|
||||
mocked(client.getExtendedProfileProperty).mockRejectedValue(
|
||||
new MatrixError({ errcode: "M_NOT_FOUND" }, 404),
|
||||
);
|
||||
|
||||
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined and logs a warning if fetching the status fails unexpectedly", async () => {
|
||||
mocked(client.doesServerSupportExtendedProfiles).mockResolvedValue(true);
|
||||
const error = new Error("network error");
|
||||
mocked(client.getExtendedProfileProperty).mockRejectedValue(error);
|
||||
|
||||
await expect(fetchUserStatus(client, "@alice:example.com")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearUserStatus", () => {
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
});
|
||||
|
||||
it("clears the user status", async () => {
|
||||
clearUserStatus(client);
|
||||
|
||||
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import fetchMock from "@fetch-mock/jest";
|
||||
|
||||
import type { Mocked } from "jest-mock";
|
||||
import type { ConsoleLogger } from "../../../src/rageshake/rageshake";
|
||||
import SdkConfig from "../../../src/SdkConfig";
|
||||
import "../../../src/vector/rageshakesetup";
|
||||
import { BugReportEndpointURLLocal } from "../../../src/IConfigOptions";
|
||||
|
||||
const RAGESHAKE_URL = "https://logs.example.org/logtome";
|
||||
|
||||
describe("mxSendRageshake", () => {
|
||||
let prevLogger: ConsoleLogger;
|
||||
beforeEach(() => {
|
||||
fetchMock.mockGlobal();
|
||||
SdkConfig.put({ bug_report_endpoint_url: RAGESHAKE_URL });
|
||||
fetchMock.postOnce(RAGESHAKE_URL, { status: 200, body: {} });
|
||||
|
||||
const mockConsoleLogger = {
|
||||
flush: jest.fn(),
|
||||
consume: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as unknown as Mocked<ConsoleLogger>;
|
||||
prevLogger = global.mx_rage_logger;
|
||||
mockConsoleLogger.flush.mockReturnValue("line 1\nline 2\n");
|
||||
global.mx_rage_logger = mockConsoleLogger;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.mx_rage_logger = prevLogger;
|
||||
jest.restoreAllMocks();
|
||||
fetchMock.unmockGlobal();
|
||||
SdkConfig.reset();
|
||||
});
|
||||
|
||||
it("Does not send a rageshake if the URL is not configured", async () => {
|
||||
SdkConfig.put({ bug_report_endpoint_url: undefined });
|
||||
await window.mxSendRageshake("test");
|
||||
expect(fetchMock).not.toHaveFetched();
|
||||
});
|
||||
|
||||
it.each(["", " ", undefined, null])("Does not send a rageshake if text is '%s'", async (text) => {
|
||||
await window.mxSendRageshake(text as string);
|
||||
expect(fetchMock).not.toHaveFetched();
|
||||
});
|
||||
|
||||
it("Sends a rageshake via URL", async () => {
|
||||
await window.mxSendRageshake("Hello world");
|
||||
expect(fetchMock).toHaveFetched(RAGESHAKE_URL);
|
||||
});
|
||||
|
||||
it("Provides a rageshake locally", async () => {
|
||||
SdkConfig.put({ bug_report_endpoint_url: BugReportEndpointURLLocal });
|
||||
const urlSpy = jest.spyOn(URL, "createObjectURL");
|
||||
await window.mxSendRageshake("Hello world");
|
||||
expect(fetchMock).not.toHaveFetched(RAGESHAKE_URL);
|
||||
expect(urlSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user