From 2d70155f578a32200a12a9d30edcc44e5fd52c7e Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 25 Nov 2022 13:54:06 +0000 Subject: [PATCH] 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 d89e0bc176fc..eb78828ff212 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 36a2520b0c5d..55ab38c9e492 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -371,6 +371,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); @@ -461,6 +462,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); } @@ -607,10 +609,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; } @@ -743,7 +754,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 bebec2efc845..1f347b9e4a51 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 1e128cbdc45c..3baf9fe3c2e1 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.