6c0f9960b5
* Thin EventTile render state wiring * Move EventTile E2E verification into a view model * Derive EventTile E2E padlock slots in view model * Derive EventTile timestamp slots in render state * Derive EventTile footer slots in render state * Fix SonarCloud issues
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
/*
|
|
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 { waitFor } from "@testing-library/dom";
|
|
import { EventType, MatrixEventEvent, type MatrixClient, type MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
|
|
import {
|
|
CryptoEvent,
|
|
type EventEncryptionInfo,
|
|
EventShieldColour,
|
|
EventShieldReason,
|
|
} from "matrix-js-sdk/src/crypto-api";
|
|
import { E2ePadlockIcon } from "@element-hq/web-shared-components";
|
|
|
|
import { mkEvent, MockClientWithEventEmitter } from "../../test-utils";
|
|
import { EventTileE2eViewModel } from "../../../src/viewmodels/room/timeline/event-tile/EventTileE2eViewModel";
|
|
|
|
function makeClient(
|
|
getEncryptionInfoForEvent = jest.fn<Promise<EventEncryptionInfo | null>, [MatrixEvent]>(),
|
|
): MockClientWithEventEmitter & MatrixClient {
|
|
return new MockClientWithEventEmitter({
|
|
getCrypto: jest.fn(() => ({
|
|
getEncryptionInfoForEvent,
|
|
})),
|
|
}) as MockClientWithEventEmitter & MatrixClient;
|
|
}
|
|
|
|
describe("EventTileE2eViewModel", () => {
|
|
const roomId = "!room:example.org";
|
|
const userId = "@alice:example.org";
|
|
let viewModels: EventTileE2eViewModel[];
|
|
|
|
beforeEach(() => {
|
|
viewModels = [];
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const vm of viewModels) {
|
|
vm.dispose();
|
|
}
|
|
});
|
|
|
|
function makeEvent(): MatrixEvent {
|
|
return mkEvent({
|
|
event: true,
|
|
type: EventType.RoomMessage,
|
|
room: roomId,
|
|
user: userId,
|
|
content: {
|
|
msgtype: MsgType.Text,
|
|
body: "Hello",
|
|
},
|
|
});
|
|
}
|
|
|
|
function makeViewModel(props: ConstructorParameters<typeof EventTileE2eViewModel>[0]): EventTileE2eViewModel {
|
|
const vm = new EventTileE2eViewModel(props);
|
|
viewModels.push(vm);
|
|
return vm;
|
|
}
|
|
|
|
it("verifies encrypted events on start", async () => {
|
|
const mxEvent = makeEvent();
|
|
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
|
|
const cli = makeClient(
|
|
jest.fn().mockResolvedValue({
|
|
shieldColour: EventShieldColour.GREY,
|
|
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
|
} as EventEncryptionInfo),
|
|
);
|
|
|
|
const vm = makeViewModel({
|
|
cli,
|
|
mxEvent,
|
|
isRoomEncrypted: false,
|
|
enableListeners: true,
|
|
});
|
|
vm.start();
|
|
|
|
await waitFor(() =>
|
|
expect(vm.getSnapshot()).toEqual({
|
|
kind: "icon",
|
|
icon: E2ePadlockIcon.Normal,
|
|
title: "Encrypted by a device not verified by its owner.",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("re-verifies when the sender verification changes", async () => {
|
|
const mxEvent = makeEvent();
|
|
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
|
|
const cli = makeClient(
|
|
jest
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
shieldColour: EventShieldColour.GREY,
|
|
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
|
} as EventEncryptionInfo)
|
|
.mockResolvedValueOnce({
|
|
shieldColour: EventShieldColour.RED,
|
|
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
|
|
} as EventEncryptionInfo),
|
|
);
|
|
|
|
const vm = makeViewModel({
|
|
cli,
|
|
mxEvent,
|
|
enableListeners: true,
|
|
});
|
|
vm.start();
|
|
await waitFor(() => expect(vm.getSnapshot().kind).toBe("icon"));
|
|
|
|
cli.emit(CryptoEvent.UserTrustStatusChanged, userId, {});
|
|
|
|
await waitFor(() =>
|
|
expect(vm.getSnapshot()).toEqual({
|
|
kind: "icon",
|
|
icon: E2ePadlockIcon.Warning,
|
|
title: "Encrypted by an unknown or deleted device.",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses the replacing event for verification after edits", async () => {
|
|
const mxEvent = makeEvent();
|
|
const replacementEvent = makeEvent();
|
|
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
|
|
jest.spyOn(replacementEvent, "isEncrypted").mockReturnValue(true);
|
|
const getEncryptionInfoForEvent = jest.fn(async (event: MatrixEvent) => {
|
|
return {
|
|
shieldColour: event === replacementEvent ? EventShieldColour.RED : EventShieldColour.NONE,
|
|
shieldReason: event === replacementEvent ? EventShieldReason.UNKNOWN_DEVICE : null,
|
|
} as EventEncryptionInfo;
|
|
});
|
|
const cli = makeClient(getEncryptionInfoForEvent);
|
|
|
|
const vm = makeViewModel({
|
|
cli,
|
|
mxEvent,
|
|
enableListeners: true,
|
|
});
|
|
vm.start();
|
|
await waitFor(() => expect(vm.getSnapshot().kind).toBe("none"));
|
|
|
|
mxEvent.makeReplaced(replacementEvent);
|
|
|
|
await waitFor(() =>
|
|
expect(vm.getSnapshot()).toEqual({
|
|
kind: "icon",
|
|
icon: E2ePadlockIcon.Warning,
|
|
title: "Encrypted by an unknown or deleted device.",
|
|
}),
|
|
);
|
|
expect(getEncryptionInfoForEvent).toHaveBeenLastCalledWith(replacementEvent);
|
|
});
|
|
|
|
it("derives the unencrypted warning from room encryption state", () => {
|
|
const vm = makeViewModel({
|
|
cli: makeClient(),
|
|
mxEvent: makeEvent(),
|
|
isRoomEncrypted: true,
|
|
enableListeners: false,
|
|
});
|
|
|
|
expect(vm.getSnapshot()).toEqual({
|
|
kind: "icon",
|
|
icon: E2ePadlockIcon.Warning,
|
|
title: "Not encrypted",
|
|
});
|
|
});
|
|
|
|
it("removes registered listeners on dispose", () => {
|
|
const mxEvent = makeEvent();
|
|
const cli = makeClient();
|
|
|
|
const vm = makeViewModel({
|
|
cli,
|
|
mxEvent,
|
|
enableListeners: true,
|
|
});
|
|
vm.start();
|
|
|
|
expect(cli.listenerCount(CryptoEvent.UserTrustStatusChanged)).toBe(1);
|
|
expect(mxEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(1);
|
|
expect(mxEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(1);
|
|
|
|
vm.dispose();
|
|
|
|
expect(cli.listenerCount(CryptoEvent.UserTrustStatusChanged)).toBe(0);
|
|
expect(mxEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(0);
|
|
expect(mxEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(0);
|
|
});
|
|
|
|
it("moves event listeners when the event prop changes", () => {
|
|
const oldEvent = makeEvent();
|
|
const newEvent = makeEvent();
|
|
const cli = makeClient();
|
|
|
|
const vm = makeViewModel({
|
|
cli,
|
|
mxEvent: oldEvent,
|
|
enableListeners: true,
|
|
});
|
|
vm.start();
|
|
|
|
vm.setProps({ mxEvent: newEvent });
|
|
|
|
expect(oldEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(0);
|
|
expect(oldEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(0);
|
|
expect(newEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(1);
|
|
expect(newEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(1);
|
|
expect(cli.listenerCount(CryptoEvent.UserTrustStatusChanged)).toBe(1);
|
|
});
|
|
|
|
it("does not register live listeners when disabled", () => {
|
|
const mxEvent = makeEvent();
|
|
const cli = makeClient();
|
|
|
|
const vm = makeViewModel({
|
|
cli,
|
|
mxEvent,
|
|
enableListeners: false,
|
|
});
|
|
vm.start();
|
|
|
|
expect(cli.listenerCount(CryptoEvent.UserTrustStatusChanged)).toBe(0);
|
|
expect(mxEvent.listenerCount(MatrixEventEvent.Decrypted)).toBe(0);
|
|
expect(mxEvent.listenerCount(MatrixEventEvent.Replaced)).toBe(0);
|
|
});
|
|
|
|
it("does not re-verify when a different user's verification changes", async () => {
|
|
const mxEvent = makeEvent();
|
|
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
|
|
const getEncryptionInfoForEvent = jest.fn().mockResolvedValue({
|
|
shieldColour: EventShieldColour.GREY,
|
|
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
|
} as EventEncryptionInfo);
|
|
const cli = makeClient(getEncryptionInfoForEvent);
|
|
|
|
const vm = makeViewModel({
|
|
cli,
|
|
mxEvent,
|
|
enableListeners: true,
|
|
});
|
|
vm.start();
|
|
await waitFor(() => expect(getEncryptionInfoForEvent).toHaveBeenCalledTimes(1));
|
|
|
|
cli.emit(CryptoEvent.UserTrustStatusChanged, "@bob:example.org", {});
|
|
|
|
expect(getEncryptionInfoForEvent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("ignores stale async verification results", async () => {
|
|
const mxEvent = makeEvent();
|
|
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
|
|
|
|
let resolveFirst: (value: EventEncryptionInfo) => void;
|
|
const firstResult = new Promise<EventEncryptionInfo>((resolve) => {
|
|
resolveFirst = resolve;
|
|
});
|
|
const getEncryptionInfoForEvent = jest
|
|
.fn()
|
|
.mockReturnValueOnce(firstResult)
|
|
.mockResolvedValueOnce({
|
|
shieldColour: EventShieldColour.RED,
|
|
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
|
|
} as EventEncryptionInfo);
|
|
const cli = makeClient(getEncryptionInfoForEvent);
|
|
|
|
const vm = makeViewModel({
|
|
cli,
|
|
mxEvent,
|
|
enableListeners: true,
|
|
});
|
|
vm.start();
|
|
cli.emit(CryptoEvent.UserTrustStatusChanged, userId, {});
|
|
|
|
await waitFor(() =>
|
|
expect(vm.getSnapshot()).toEqual({
|
|
kind: "icon",
|
|
icon: E2ePadlockIcon.Warning,
|
|
title: "Encrypted by an unknown or deleted device.",
|
|
}),
|
|
);
|
|
|
|
resolveFirst!({
|
|
shieldColour: EventShieldColour.GREY,
|
|
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
|
} as EventEncryptionInfo);
|
|
|
|
await Promise.resolve();
|
|
|
|
expect(vm.getSnapshot()).toEqual({
|
|
kind: "icon",
|
|
icon: E2ePadlockIcon.Warning,
|
|
title: "Encrypted by an unknown or deleted device.",
|
|
});
|
|
});
|
|
});
|