From 990121919479aeb84a5be234458ba4a573617024 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Sat, 17 Dec 2022 23:50:34 +0000 Subject: [PATCH 1/6] rip out unused mkPresence it only serves to make the strict type checker complain --- test/test-utils/test-utils.ts | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 21de3983fb..1e128cbdc4 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -315,29 +315,6 @@ export function mkEvent(opts: MakeEventProps): MatrixEvent { return mxEvent; } -/** - * Create an m.presence event. - * @param {Object} opts Values for the presence. - * @return {Object|MatrixEvent} The event - */ -export function mkPresence(opts) { - if (!opts.user) { - throw new Error("Missing user"); - } - const event = { - event_id: "$" + Math.random() + "-" + Math.random(), - type: "m.presence", - sender: opts.user, - content: { - avatar_url: opts.url, - displayname: opts.name, - last_active_ago: opts.ago, - presence: opts.presence || "offline", - }, - }; - return opts.event ? new MatrixEvent(event) : event; -} - /** * Create an m.room.member event. * @param {Object} opts Values for the membership. From e3f591e7d2dc663c5211bf7ff11bda3997cf0a6f Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 16 Dec 2022 14:59:03 +0000 Subject: [PATCH 2/6] Return the password from `getBot` ... so that we can use it to log in for a second time --- cypress/e2e/crypto/crypto.spec.ts | 3 ++- cypress/support/bot.ts | 22 ++++++++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts index 2a55c21b05..d89e0bc176 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -18,13 +18,14 @@ import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; +import type { CypressBot } from "../../support/bot"; import { SynapseInstance } from "../../plugins/synapsedocker"; import Chainable = Cypress.Chainable; type EmojiMapping = [emoji: string, name: string]; interface CryptoTestContext extends Mocha.Context { synapse: SynapseInstance; - bob: MatrixClient; + bob: CypressBot; } const waitForVerificationRequest = (cli: MatrixClient): Promise => { diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index f4476af12f..40e0986d16 100644 --- a/cypress/support/bot.ts +++ b/cypress/support/bot.ts @@ -46,6 +46,10 @@ const defaultCreateBotOptions = { bootstrapCrossSigning: true, } as CreateBotOpts; +export interface CypressBot extends MatrixClient { + __cypress_password: string; +} + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { @@ -55,7 +59,7 @@ declare global { * @param synapse the instance on which to register the bot user * @param opts create bot options */ - getBot(synapse: SynapseInstance, opts: CreateBotOpts): Chainable; + getBot(synapse: SynapseInstance, opts: CreateBotOpts): Chainable; /** * Returns a new Bot instance logged in as an existing user * @param synapse the instance on which to register the bot user @@ -151,14 +155,20 @@ function setupBotClient( }); } -Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Chainable => { +Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Chainable => { opts = Object.assign({}, defaultCreateBotOptions, opts); const username = Cypress._.uniqueId("userId_"); const password = Cypress._.uniqueId("password_"); - return cy.registerUser(synapse, username, password, opts.displayName).then((credentials) => { - cy.log(`Registered bot user ${username} with displayname ${opts.displayName}`); - return setupBotClient(synapse, credentials, opts); - }); + return cy + .registerUser(synapse, username, password, opts.displayName) + .then((credentials) => { + cy.log(`Registered bot user ${username} with displayname ${opts.displayName}`); + return setupBotClient(synapse, credentials, opts); + }) + .then((client): Chainable => { + Object.assign(client, { __cypress_password: password }); + return cy.wrap(client as CypressBot); + }); }); Cypress.Commands.add( From 46e47a821e8b965f3fb0196722f1ea62d4ba3013 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Sat, 17 Dec 2022 23:22:31 +0000 Subject: [PATCH 3/6] Clean up signature of `verifyEvent` It doesn't need to be `async`, and it doesn't need us to pass in the event every time. --- src/components/views/rooms/EventTile.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index e34bf8716f..8fe1aa15ba 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -395,7 +395,7 @@ export class UnwrappedEventTile extends React.Component const room = client.getRoom(this.props.mxEvent.getRoomId()); room?.on(ThreadEvent.New, this.onNewThread); - this.verifyEvent(this.props.mxEvent); + this.verifyEvent(); } private get supportsThreadNotifications(): boolean { @@ -478,7 +478,7 @@ export class UnwrappedEventTile extends React.Component } // re-check the sender verification as outgoing events progress through the send process. if (prevProps.eventSendStatus !== this.props.eventSendStatus) { - this.verifyEvent(this.props.mxEvent); + this.verifyEvent(); } } @@ -588,23 +588,25 @@ export class UnwrappedEventTile extends React.Component // we need to re-verify the sending device. // (we call onHeightChanged in verifyEvent to handle the case where decryption // has caused a change in size of the event tile) - this.verifyEvent(this.props.mxEvent); + this.verifyEvent(); this.forceUpdate(); }; private onDeviceVerificationChanged = (userId: string, device: string): void => { if (userId === this.props.mxEvent.getSender()) { - this.verifyEvent(this.props.mxEvent); + this.verifyEvent(); } }; private onUserVerificationChanged = (userId: string, _trustStatus: UserTrustLevel): void => { if (userId === this.props.mxEvent.getSender()) { - this.verifyEvent(this.props.mxEvent); + this.verifyEvent(); } }; - private async verifyEvent(mxEvent: MatrixEvent): Promise { + private verifyEvent(): void { + const mxEvent = this.props.mxEvent; + if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) { return; } From c34a2bfb8cff1e5357e8a20defe1b9047cd06f3e Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Sat, 17 Dec 2022 23:07:48 +0000 Subject: [PATCH 4/6] Call onHeightChanged from componentDidUpdate ... to ensure that we don't call it if there was no change to the verification status. --- src/components/views/rooms/EventTile.tsx | 53 ++++++------------------ 1 file changed, 13 insertions(+), 40 deletions(-) diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 8fe1aa15ba..a5699ecfee 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -470,7 +470,11 @@ export class UnwrappedEventTile extends React.Component this.threadState?.off(NotificationStateEvents.Update, this.onThreadStateUpdate); } - public componentDidUpdate(prevProps: Readonly) { + public componentDidUpdate(prevProps: Readonly, prevState: Readonly) { + // If the verification state changed, the height might have changed + if (prevState.verified !== this.state.verified && this.props.onHeightChanged) { + this.props.onHeightChanged(); + } // If we're not listening for receipts and expect to be, register a listener. if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) { MatrixClientPeg.get().on(RoomEvent.Receipt, this.onRoomReceipt); @@ -586,10 +590,9 @@ export class UnwrappedEventTile extends React.Component */ private onDecrypted = () => { // we need to re-verify the sending device. - // (we call onHeightChanged in verifyEvent to handle the case where decryption - // has caused a change in size of the event tile) this.verifyEvent(); - this.forceUpdate(); + // decryption might, of course, trigger a height change, so call onHeightChanged after the re-render + this.forceUpdate(this.props.onHeightChanged); }; private onDeviceVerificationChanged = (userId: string, device: string): void => { @@ -617,12 +620,7 @@ export class UnwrappedEventTile extends React.Component if (encryptionInfo.mismatchedSender) { // something definitely wrong is going on here - this.setState( - { - verified: E2EState.Warning, - }, - this.props.onHeightChanged, - ); // Decryption may have caused a change in size + this.setState({ verified: E2EState.Warning }); return; } @@ -630,53 +628,28 @@ export class UnwrappedEventTile extends React.Component // If the message is unauthenticated, then display a grey // shield, otherwise if the user isn't cross-signed then // nothing's needed - this.setState( - { - verified: encryptionInfo.authenticated ? E2EState.Normal : E2EState.Unauthenticated, - }, - this.props.onHeightChanged, - ); // Decryption may have caused a change in size + this.setState({ verified: encryptionInfo.authenticated ? E2EState.Normal : E2EState.Unauthenticated }); return; } const eventSenderTrust = encryptionInfo.sender && MatrixClientPeg.get().checkDeviceTrust(senderId, encryptionInfo.sender.deviceId); if (!eventSenderTrust) { - this.setState( - { - verified: E2EState.Unknown, - }, - this.props.onHeightChanged, - ); // Decryption may have caused a change in size + this.setState({ verified: E2EState.Unknown }); return; } if (!eventSenderTrust.isVerified()) { - this.setState( - { - verified: E2EState.Warning, - }, - this.props.onHeightChanged, - ); // Decryption may have caused a change in size + this.setState({ verified: E2EState.Warning }); return; } if (!encryptionInfo.authenticated) { - this.setState( - { - verified: E2EState.Unauthenticated, - }, - this.props.onHeightChanged, - ); // Decryption may have caused a change in size + this.setState({ verified: E2EState.Unauthenticated }); return; } - this.setState( - { - verified: E2EState.Verified, - }, - this.props.onHeightChanged, - ); // Decryption may have caused a change in size + this.setState({ verified: E2EState.Verified }); } private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean { From fa01a6211ee3cb251644a595328816b72c254319 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 19 Dec 2022 18:54:41 +0000 Subject: [PATCH 5/6] `verified` can be null --- src/components/views/rooms/EventTile.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index a5699ecfee..3a6635ada7 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -232,7 +232,7 @@ interface IState { // Whether the action bar is focused. actionBarFocused: boolean; // Whether the event's sender has been verified. - verified: string; + verified: string | null; // The Relations model from the JS SDK for reactions to `mxEvent` reactions?: Relations | null | undefined; @@ -278,7 +278,8 @@ export class UnwrappedEventTile extends React.Component this.state = { // Whether the action bar is focused. actionBarFocused: false, - // Whether the event's sender has been verified. + // Whether the event's sender has been verified. `null` if no attempt has yet been made to verify + // (including if the event is not encrypted). verified: null, // The Relations model from the JS SDK for reactions to `mxEvent` reactions: this.getReactions(), From dc29317445cfca119dae770eb89f694932160b15 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 25 Nov 2022 13:54:06 +0000 Subject: [PATCH 6/6] Improve display of edited events --- cypress/e2e/crypto/crypto.spec.ts | 86 +++++++- src/components/views/rooms/EventTile.tsx | 17 +- .../components/views/rooms/EventTile-test.tsx | 190 +++++++++++++++++- test/test-utils/test-utils.ts | 47 +++++ 4 files changed, 330 insertions(+), 10 deletions(-) diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts index d89e0bc176..eb78828ff2 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; - +import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; import type { CypressBot } from "../../support/bot"; @@ -198,7 +197,7 @@ describe("Cryptography", function () { cy.bootstrapCrossSigning(); autoJoin(this.bob); - /* we need to have a room with the other user present, so we can open the verification panel */ + // we need to have a room with the other user present, so we can open the verification panel let roomId: string; cy.createRoom({ name: "TestRoom", invite: [this.bob.getUserId()] }).then((_room1Id) => { roomId = _room1Id; @@ -211,4 +210,85 @@ describe("Cryptography", function () { verify.call(this); }); + + it("should show the correct shield on edited e2e events", function (this: CryptoTestContext) { + cy.bootstrapCrossSigning(); + + // bob has a second, not cross-signed, device + cy.loginBot(this.synapse, this.bob.getUserId(), this.bob.__cypress_password, {}).as("bobSecondDevice"); + + autoJoin(this.bob); + + // first create the room, so that we can open the verification panel + cy.createRoom({ name: "TestRoom", invite: [this.bob.getUserId()] }) + .as("testRoomId") + .then((roomId) => { + cy.log(`Created test room ${roomId}`); + cy.visit(`/#/room/${roomId}`); + + // enable encryption + cy.getClient().then((cli) => { + cli.sendStateEvent(roomId, "m.room.encryption", { algorithm: "m.megolm.v1.aes-sha2" }); + }); + + // wait for Bob to join the room, otherwise our attempt to open his user details may race + // with his join. + cy.contains(".mx_TextualEvent", "Bob joined the room").should("exist"); + }); + + verify.call(this); + + cy.get("@testRoomId").then((roomId) => { + // bob sends a valid event + cy.wrap(this.bob.sendTextMessage(roomId, "Hoo!")).as("testEvent"); + + // the message should appear, decrypted, with no warning + cy.contains(".mx_EventTile_body", "Hoo!") + .closest(".mx_EventTile") + .should("have.class", "mx_EventTile_verified") + .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); + + // bob sends an edit to the first message with his unverified device + cy.get("@bobSecondDevice").then((bobSecondDevice) => { + cy.get("@testEvent").then((testEvent) => { + bobSecondDevice.sendMessage(roomId, { + "m.new_content": { + msgtype: "m.text", + body: "Haa!", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: testEvent.event_id, + }, + }); + }); + }); + + // the edit should have a warning + cy.contains(".mx_EventTile_body", "Haa!") + .closest(".mx_EventTile") + .within(() => { + cy.get(".mx_EventTile_e2eIcon_warning").should("exist"); + }); + + // a second edit from the verified device should be ok + cy.get("@testEvent").then((testEvent) => { + this.bob.sendMessage(roomId, { + "m.new_content": { + msgtype: "m.text", + body: "Hee!", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: testEvent.event_id, + }, + }); + }); + + cy.contains(".mx_EventTile_body", "Hee!") + .closest(".mx_EventTile") + .should("have.class", "mx_EventTile_verified") + .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); + }); + }); }); diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 3a6635ada7..711b6e315d 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -372,6 +372,7 @@ export class UnwrappedEventTile extends React.Component client.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); client.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); this.props.mxEvent.on(MatrixEventEvent.Decrypted, this.onDecrypted); + this.props.mxEvent.on(MatrixEventEvent.Replaced, this.onReplaced); DecryptionFailureTracker.instance.addVisibleEvent(this.props.mxEvent); if (this.props.showReactions) { this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onReactionsCreated); @@ -462,6 +463,7 @@ export class UnwrappedEventTile extends React.Component } this.isListeningForReceipts = false; this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted); + this.props.mxEvent.removeListener(MatrixEventEvent.Replaced, this.onReplaced); if (this.props.showReactions) { this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onReactionsCreated); } @@ -608,10 +610,19 @@ export class UnwrappedEventTile extends React.Component } }; + /** called when the event is edited after we show it. */ + private onReplaced = () => { + // re-verify the event if it is replaced (the edit may not be verified) + this.verifyEvent(); + }; + private verifyEvent(): void { - const mxEvent = this.props.mxEvent; + // if the event was edited, show the verification info for the edit, not + // the original + const mxEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) { + this.setState({ verified: null }); return; } @@ -744,7 +755,9 @@ export class UnwrappedEventTile extends React.Component }; private renderE2EPadlock() { - const ev = this.props.mxEvent; + // if the event was edited, show the verification info for the edit, not + // the original + const ev = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; // no icon for local rooms if (isLocalRoom(ev.getRoomId()!)) return; diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx index bebec2efc8..1f347b9e4a 100644 --- a/test/components/views/rooms/EventTile-test.tsx +++ b/test/components/views/rooms/EventTile-test.tsx @@ -14,19 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import * as React from "react"; import { act, render, screen, waitFor } from "@testing-library/react"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import { DeviceTrustLevel, UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; +import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; +import { IEncryptedEventInfo } from "matrix-js-sdk/src/crypto/api"; import EventTile, { EventTileProps } from "../../../../src/components/views/rooms/EventTile"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import SettingsStore from "../../../../src/settings/SettingsStore"; -import { getRoomContext, mkEvent, mkMessage, stubClient } from "../../../test-utils"; +import { getRoomContext, mkEncryptedEvent, mkEvent, mkMessage, stubClient } from "../../../test-utils"; import { mkThread } from "../../../test-utils/threads"; describe("EventTile", () => { @@ -34,6 +37,7 @@ describe("EventTile", () => { let mxEvent: MatrixEvent; let room: Room; let client: MatrixClient; + // let changeEvent: (event: MatrixEvent) => void; function TestEventTile(props: Partial) { @@ -67,7 +71,7 @@ describe("EventTile", () => { stubClient(); client = MatrixClientPeg.get(); - room = new Room(ROOM_ID, client, client.getUserId(), { + room = new Room(ROOM_ID, client, client.getUserId()!, { pendingEventOrdering: PendingEventOrdering.Detached, }); @@ -140,18 +144,194 @@ describe("EventTile", () => { expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0); act(() => { - room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Total, 3); + room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Total, 3); }); expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(0); act(() => { - room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Highlight, 1); + room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Highlight, 1); }); expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(1); }); }); + + describe("Event verification", () => { + // data for our stubbed getEventEncryptionInfo: a map from event id to result + const eventToEncryptionInfoMap = new Map(); + + const TRUSTED_DEVICE = DeviceInfo.fromStorage({}, "TRUSTED_DEVICE"); + const UNTRUSTED_DEVICE = DeviceInfo.fromStorage({}, "UNTRUSTED_DEVICE"); + + beforeEach(() => { + eventToEncryptionInfoMap.clear(); + + // a mocked version of getEventEncryptionInfo which will pick its result from `eventToEncryptionInfoMap` + client.getEventEncryptionInfo = (event) => eventToEncryptionInfoMap.get(event.getId()!)!; + + // a mocked version of checkUserTrust which always says the user is trusted (we do our testing via + // unverified devices). + const trustedUserTrustLevel = new UserTrustLevel(true, true, true); + client.checkUserTrust = (_userId) => trustedUserTrustLevel; + + // a version of checkDeviceTrust which says that TRUSTED_DEVICE is trusted, and others are not. + const trustedDeviceTrustLevel = DeviceTrustLevel.fromUserTrustLevel(trustedUserTrustLevel, true, false); + const untrustedDeviceTrustLevel = DeviceTrustLevel.fromUserTrustLevel(trustedUserTrustLevel, false, false); + client.checkDeviceTrust = (userId, deviceId) => { + if (deviceId === TRUSTED_DEVICE.deviceId) { + return trustedDeviceTrustLevel; + } else { + return untrustedDeviceTrustLevel; + } + }; + }); + + it("shows a warning for an event from an unverified device", async () => { + mxEvent = await mkEncryptedEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + user: "@alice:example.org", + room: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + authenticated: true, + sender: UNTRUSTED_DEVICE, + } as IEncryptedEventInfo); + + const { container } = getComponent(); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + const eventTile = eventTiles[0]; + + expect(eventTile.classList).toContain("mx_EventTile_unverified"); + + // there should be a warning shield + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain( + "mx_EventTile_e2eIcon_warning", + ); + }); + + it("shows no shield for a verified event", async () => { + mxEvent = await mkEncryptedEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + user: "@alice:example.org", + room: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + authenticated: true, + sender: TRUSTED_DEVICE, + } as IEncryptedEventInfo); + + const { container } = getComponent(); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + const eventTile = eventTiles[0]; + + expect(eventTile.classList).toContain("mx_EventTile_verified"); + + // there should be no warning + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + }); + + it("should update the warning when the event is edited", async () => { + // we start out with an event from the trusted device + mxEvent = await mkEncryptedEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + user: "@alice:example.org", + room: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + authenticated: true, + sender: TRUSTED_DEVICE, + } as IEncryptedEventInfo); + + const { container } = getComponent(); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + const eventTile = eventTiles[0]; + + expect(eventTile.classList).toContain("mx_EventTile_verified"); + + // there should be no warning + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + + // then we replace the event with one from the unverified device + const replacementEvent = await mkEncryptedEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + user: "@alice:example.org", + room: room.roomId, + }); + eventToEncryptionInfoMap.set(replacementEvent.getId()!, { + authenticated: true, + sender: UNTRUSTED_DEVICE, + } as IEncryptedEventInfo); + + act(() => { + mxEvent.makeReplaced(replacementEvent); + }); + + // check it was updated + expect(eventTile.classList).toContain("mx_EventTile_unverified"); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain( + "mx_EventTile_e2eIcon_warning", + ); + }); + + it("should update the warning when the event is replaced with an unencrypted one", async () => { + jest.spyOn(client, "isRoomEncrypted").mockReturnValue(true); + + // we start out with an event from the trusted device + mxEvent = await mkEncryptedEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + user: "@alice:example.org", + room: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + authenticated: true, + sender: TRUSTED_DEVICE, + } as IEncryptedEventInfo); + + const { container } = getComponent(); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + const eventTile = eventTiles[0]; + + expect(eventTile.classList).toContain("mx_EventTile_verified"); + + // there should be no warning + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + + // then we replace the event with an unencrypted one + const replacementEvent = await mkMessage({ + msg: "msg2", + user: "@alice:example.org", + room: room.roomId, + event: true, + }); + + act(() => { + mxEvent.makeReplaced(replacementEvent); + }); + + // check it was updated + expect(eventTile.classList).not.toContain("mx_EventTile_verified"); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain( + "mx_EventTile_e2eIcon_warning", + ); + }); + }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 1e128cbdc4..3baf9fe3c2 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -38,6 +38,8 @@ import { normalize } from "matrix-js-sdk/src/utils"; import { ReEmitter } from "matrix-js-sdk/src/ReEmitter"; import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler"; import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; +import { CryptoBackend } from "matrix-js-sdk/src/common-crypto/CryptoBackend"; +import { IEventDecryptionResult } from "matrix-js-sdk/src/@types/crypto"; import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import { MatrixClientPeg as peg } from "../../src/MatrixClientPeg"; @@ -315,6 +317,51 @@ export function mkEvent(opts: MakeEventProps): MatrixEvent { return mxEvent; } +/** + * Create an m.room.encrypted event + * + * @param opts - Values for the event + * @param opts.room - The ID of the room for the event + * @param opts.user - The sender of the event + * @param opts.plainType - The type the event will have, once it has been decrypted + * @param opts.plainContent - The content the event will have, once it has been decrypted + */ +export async function mkEncryptedEvent(opts: { + room: Room["roomId"]; + user: User["userId"]; + plainType: string; + plainContent: IContent; +}): Promise { + // we construct an event which has been decrypted by stubbing out CryptoBackend.decryptEvent and then + // calling MatrixEvent.attemptDecryption. + + const mxEvent = mkEvent({ + type: "m.room.encrypted", + room: opts.room, + user: opts.user, + event: true, + content: {}, + }); + + const decryptionResult: IEventDecryptionResult = { + claimedEd25519Key: "", + clearEvent: { + type: opts.plainType, + content: opts.plainContent, + }, + forwardingCurve25519KeyChain: [], + senderCurve25519Key: "", + untrusted: false, + }; + + const mockCrypto = { + decryptEvent: async (_ev): Promise => decryptionResult, + } as CryptoBackend; + + await mxEvent.attemptDecryption(mockCrypto); + return mxEvent; +} + /** * Create an m.room.member event. * @param {Object} opts Values for the membership.