mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-25 09:58:11 +08:00
de9a2cc382
* Add PostHog codes for trust requirement failures * add tests for new codes
739 lines
28 KiB
TypeScript
739 lines
28 KiB
TypeScript
/*
|
|
Copyright 2018-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 { mocked, Mocked, MockedObject } from "jest-mock";
|
|
import { HttpApiEvent, MatrixClient, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
|
import { decryptExistingEvent, mkDecryptionFailureMatrixEvent } from "matrix-js-sdk/src/testing";
|
|
import { CryptoApi, DecryptionFailureCode, UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
|
import { sleep } from "matrix-js-sdk/src/utils";
|
|
|
|
import { DecryptionFailureTracker, 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_BAD_ROOM;
|
|
const error3 = DecryptionFailureCode.MEGOLM_MISSING_FIELDS;
|
|
|
|
// 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_BAD_ROOM;
|
|
const error3 = DecryptionFailureCode.MEGOLM_MISSING_FIELDS;
|
|
|
|
// 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_BAD_ROOM;
|
|
const error3 = DecryptionFailureCode.MEGOLM_MISSING_FIELDS;
|
|
|
|
// 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;
|
|
}
|