Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Ensure that events are correctly updated when they are edited. #9789

Merged
merged 7 commits into from
Dec 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 85 additions & 4 deletions cypress/e2e/crypto/crypto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@ 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";
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<VerificationRequest> => {
Expand Down Expand Up @@ -197,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;
Expand All @@ -210,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<string>("@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<MatrixClient>("@bobSecondDevice").then((bobSecondDevice) => {
cy.get<ISendEventResponse>("@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<ISendEventResponse>("@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");
});
});
});
22 changes: 16 additions & 6 deletions cypress/support/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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 {
Expand All @@ -60,7 +64,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<MatrixClient>;
getBot(synapse: SynapseInstance, opts: CreateBotOpts): Chainable<CypressBot>;
/**
* Returns a new Bot instance logged in as an existing user
* @param synapse the instance on which to register the bot user
Expand Down Expand Up @@ -156,14 +160,20 @@ function setupBotClient(
});
}

Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Chainable<MatrixClient> => {
Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Chainable<CypressBot> => {
opts = Object.assign({}, defaultCreateBotOptions, opts);
const username = Cypress._.uniqueId(opts.userIdPrefix);
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<CypressBot> => {
Object.assign(client, { __cypress_password: password });
return cy.wrap(client as CypressBot);
});
});

Cypress.Commands.add(
Expand Down
87 changes: 38 additions & 49 deletions src/components/views/rooms/EventTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -278,7 +278,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
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(),
Expand Down Expand Up @@ -371,6 +372,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
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);
Expand All @@ -395,7 +397,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
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 {
Expand Down Expand Up @@ -461,6 +463,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
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);
}
Expand All @@ -470,15 +473,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
this.threadState?.off(NotificationStateEvents.Update, this.onThreadStateUpdate);
}

public componentDidUpdate(prevProps: Readonly<EventTileProps>) {
public componentDidUpdate(prevProps: Readonly<EventTileProps>, prevState: Readonly<IState>) {
// 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);
this.isListeningForReceipts = true;
}
// 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();
}
}

Expand Down Expand Up @@ -586,26 +593,36 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
*/
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.props.mxEvent);
this.forceUpdate();
this.verifyEvent();
// 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 => {
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<void> {
/** 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 {
// 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;
}

Expand All @@ -615,66 +632,36 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>

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;
}

if (!userTrust.isCrossSigningVerified()) {
// 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 {
Expand Down Expand Up @@ -768,7 +755,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
};

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;
Expand Down
Loading