mirror of https://github.com/vector-im/riot-web
display a warning when an unverified user's identity changes
parent
249c0fd1df
commit
6fa98f8683
|
@ -319,6 +319,7 @@
|
||||||
@import "./views/rooms/_ThirdPartyMemberInfo.pcss";
|
@import "./views/rooms/_ThirdPartyMemberInfo.pcss";
|
||||||
@import "./views/rooms/_ThreadSummary.pcss";
|
@import "./views/rooms/_ThreadSummary.pcss";
|
||||||
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
|
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
|
||||||
|
@import "./views/rooms/_UserIdentityWarning.pcss";
|
||||||
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
|
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
|
||||||
@import "./views/rooms/_WhoIsTypingTile.pcss";
|
@import "./views/rooms/_WhoIsTypingTile.pcss";
|
||||||
@import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss";
|
@import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss";
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
Copyright 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_UserIdentityWarning {
|
||||||
|
border-top: 1px solid $separator;
|
||||||
|
padding-top: 5px;
|
||||||
|
margin-left: calc(-42px + var(--RoomView_MessageList-padding));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.mx_BaseAvatar {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.mx_UserIdentityWarning_main {
|
||||||
|
margin-left: 24px;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning {
|
||||||
|
margin-left: calc(-25px + var(--RoomView_MessageList-padding));
|
||||||
|
}
|
|
@ -30,6 +30,7 @@ import E2EIcon from "./E2EIcon";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import { aboveLeftOf, MenuProps } from "../../structures/ContextMenu";
|
import { aboveLeftOf, MenuProps } from "../../structures/ContextMenu";
|
||||||
import ReplyPreview from "./ReplyPreview";
|
import ReplyPreview from "./ReplyPreview";
|
||||||
|
import UserIdentityWarning from "./UserIdentityWarning";
|
||||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||||
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
|
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
|
||||||
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
|
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
|
||||||
|
@ -671,6 +672,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||||
<div className={classes} ref={this.ref} role="region" aria-label={_t("a11y|message_composer")}>
|
<div className={classes} ref={this.ref} role="region" aria-label={_t("a11y|message_composer")}>
|
||||||
{recordingTooltip}
|
{recordingTooltip}
|
||||||
<div className="mx_MessageComposer_wrapper">
|
<div className="mx_MessageComposer_wrapper">
|
||||||
|
<UserIdentityWarning room={this.props.room} />
|
||||||
<ReplyPreview
|
<ReplyPreview
|
||||||
replyToEvent={this.props.replyToEvent}
|
replyToEvent={this.props.replyToEvent}
|
||||||
permalinkCreator={this.props.permalinkCreator}
|
permalinkCreator={this.props.permalinkCreator}
|
||||||
|
|
|
@ -0,0 +1,308 @@
|
||||||
|
/*
|
||||||
|
Copyright 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 React from "react";
|
||||||
|
import {
|
||||||
|
CryptoEvent,
|
||||||
|
EventType,
|
||||||
|
KnownMembership,
|
||||||
|
MatrixEvent,
|
||||||
|
Room,
|
||||||
|
RoomStateEvent,
|
||||||
|
RoomMember,
|
||||||
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
|
import type { CryptoApi, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||||
|
import MemberAvatar from "../avatars/MemberAvatar";
|
||||||
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
|
import { SDKContext } from "../../../contexts/SDKContext";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
// The current room being viewed.
|
||||||
|
room: Room;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
// The current room member that we are prompting the user to approve.
|
||||||
|
currentPrompt: RoomMember | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does the given user's identity need to be approved?
|
||||||
|
async function userNeedsApproval(crypto: CryptoApi, userId: string): Promise<boolean> {
|
||||||
|
const verificationStatus = await crypto.getUserVerificationStatus(userId);
|
||||||
|
return verificationStatus.needsUserApproval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a banner warning when there is an issue with a user's identity.
|
||||||
|
*
|
||||||
|
* Warns when an unverified user's identity has changed, and gives the user a
|
||||||
|
* button to acknowledge the change.
|
||||||
|
*/
|
||||||
|
export default class UserIdentityWarning extends React.Component<IProps, IState> {
|
||||||
|
// Which room members need their identity approved.
|
||||||
|
private membersNeedingApproval: Map<string, RoomMember>;
|
||||||
|
// Whether we got a verification status update while we were fetching a
|
||||||
|
// user's verification status.
|
||||||
|
//
|
||||||
|
// We set the entry for a user to `false` when we fetch a user's
|
||||||
|
// verification status, and remove the user's entry when we are done
|
||||||
|
// fetching. When we receive a verification status update, if the entry for
|
||||||
|
// the user is `false`, we set it to `true`. After we have finished
|
||||||
|
// fetching the user's verification status, if the entry for the user is
|
||||||
|
// `true`, rather than `false`, we know that we got an update, and so we
|
||||||
|
// discard the value that we fetched. We always use the value from the
|
||||||
|
// update and consider it as the most up-to-date version. If the fetched
|
||||||
|
// value is more up-to-date, then we should be getting a new update soon
|
||||||
|
// with the newer value, so it will fix itself in the end.
|
||||||
|
private gotVerificationStatusUpdate: Map<string, boolean>;
|
||||||
|
private mounted: boolean;
|
||||||
|
private initialised: boolean;
|
||||||
|
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
|
||||||
|
super(props, context);
|
||||||
|
this.state = {
|
||||||
|
currentPrompt: undefined,
|
||||||
|
};
|
||||||
|
this.membersNeedingApproval = new Map();
|
||||||
|
this.gotVerificationStatusUpdate = new Map();
|
||||||
|
this.mounted = true;
|
||||||
|
this.initialised = false;
|
||||||
|
this.addListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidMount(): void {
|
||||||
|
if (!MatrixClientPeg.safeGet().getCrypto()) return;
|
||||||
|
if (this.props.room.hasEncryptionStateEvent()) {
|
||||||
|
this.initialise().catch((e) => {
|
||||||
|
logger.error("Error initialising UserIdentityWarning:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentWillUnmount(): void {
|
||||||
|
this.mounted = false;
|
||||||
|
this.removeListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select a new user to display a warning for. This is called after the
|
||||||
|
// current prompted user no longer needs their identity approved.
|
||||||
|
private selectCurrentPrompt(): void {
|
||||||
|
if (this.membersNeedingApproval.size === 0) {
|
||||||
|
this.setState({
|
||||||
|
currentPrompt: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// We return the user with the smallest user ID.
|
||||||
|
const keys = Array.from(this.membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b));
|
||||||
|
this.setState({
|
||||||
|
currentPrompt: this.membersNeedingApproval.get(keys[0]!),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise the component. Get the room members, check which ones need
|
||||||
|
// their identity approved, and pick one to display.
|
||||||
|
public async initialise(): Promise<void> {
|
||||||
|
if (!this.mounted || this.initialised) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.initialised = true;
|
||||||
|
|
||||||
|
const crypto = MatrixClientPeg.safeGet().getCrypto()!;
|
||||||
|
const members = await this.props.room.getEncryptionTargetMembers();
|
||||||
|
if (!this.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const userId = member.userId;
|
||||||
|
if (this.gotVerificationStatusUpdate.has(userId)) {
|
||||||
|
// We're already checking their verification status, so we don't
|
||||||
|
// need to do anything here.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.gotVerificationStatusUpdate.set(userId, false);
|
||||||
|
if (await userNeedsApproval(crypto, userId)) {
|
||||||
|
if (
|
||||||
|
!this.membersNeedingApproval.has(userId) &&
|
||||||
|
this.gotVerificationStatusUpdate.get(userId) === false
|
||||||
|
) {
|
||||||
|
this.membersNeedingApproval.set(userId, member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.gotVerificationStatusUpdate.delete(userId);
|
||||||
|
}
|
||||||
|
if (!this.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectCurrentPrompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
private addMemberNeedingApproval(userId: string): void {
|
||||||
|
if (userId === MatrixClientPeg.safeGet().getUserId()) {
|
||||||
|
// We always skip our own user, because we can't pin our own identity.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const member = this.props.room.getMember(userId);
|
||||||
|
if (member) {
|
||||||
|
this.membersNeedingApproval.set(userId, member);
|
||||||
|
if (!this.state.currentPrompt) {
|
||||||
|
// If we're not currently displaying a prompt, then we should
|
||||||
|
// display a prompt for this user.
|
||||||
|
this.selectCurrentPrompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeMemberNeedingApproval(userId: string): void {
|
||||||
|
this.membersNeedingApproval.delete(userId);
|
||||||
|
|
||||||
|
// If we removed the currently displayed user, we need to pick a new one
|
||||||
|
// to display.
|
||||||
|
if (this.state.currentPrompt?.userId === userId) {
|
||||||
|
this.selectCurrentPrompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addListeners(): void {
|
||||||
|
const cli = MatrixClientPeg.safeGet();
|
||||||
|
cli.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||||
|
cli.on(RoomStateEvent.Events, this.onRoomStateEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeListeners(): void {
|
||||||
|
const cli = MatrixClientPeg.get();
|
||||||
|
if (cli) {
|
||||||
|
cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||||
|
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onUserTrustStatusChanged = (userId: string, verificationStatus: UserVerificationStatus): void => {
|
||||||
|
// Handle a change in user trust. If the user's identity now needs
|
||||||
|
// approval, make sure that a warning is shown. If the user's identity
|
||||||
|
// doesn't need approval, remove the warning (if any).
|
||||||
|
|
||||||
|
if (!this.initialised) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.gotVerificationStatusUpdate.has(userId)) {
|
||||||
|
this.gotVerificationStatusUpdate.set(userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verificationStatus.needsUserApproval) {
|
||||||
|
this.addMemberNeedingApproval(userId);
|
||||||
|
} else {
|
||||||
|
this.removeMemberNeedingApproval(userId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onRoomStateEvent = async (event: MatrixEvent): Promise<void> => {
|
||||||
|
if (event.getRoomId() !== this.props.room.roomId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = event.getType();
|
||||||
|
if (eventType === EventType.RoomEncryption && event.getStateKey() === "") {
|
||||||
|
// Room is now encrypted, so we can initialise the component.
|
||||||
|
return this.initialise().catch((e) => {
|
||||||
|
logger.error("Error initialising UserIdentityWarning:", e);
|
||||||
|
});
|
||||||
|
} else if (eventType !== EventType.RoomMember) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.initialised) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = event.getStateKey();
|
||||||
|
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.getContent().membership === KnownMembership.Join ||
|
||||||
|
(event.getContent().membership === KnownMembership.Join && this.props.room.shouldEncryptForInvitedMembers())
|
||||||
|
) {
|
||||||
|
// Someone's membership changed and we will now encrypt to them. If
|
||||||
|
// their identity needs approval, show a warning.
|
||||||
|
if (this.gotVerificationStatusUpdate.has(userId)) {
|
||||||
|
// We're already checking their verification status, so we don't
|
||||||
|
// need to do anything here.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.gotVerificationStatusUpdate.set(userId, false);
|
||||||
|
const crypto = MatrixClientPeg.safeGet().getCrypto()!;
|
||||||
|
if (await userNeedsApproval(crypto, userId)) {
|
||||||
|
if (
|
||||||
|
!this.membersNeedingApproval.has(userId) &&
|
||||||
|
this.gotVerificationStatusUpdate.get(userId) === false
|
||||||
|
) {
|
||||||
|
this.addMemberNeedingApproval(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.gotVerificationStatusUpdate.delete(userId);
|
||||||
|
} else {
|
||||||
|
// Someone's membership changed and we no longer encrypt to them.
|
||||||
|
// If we're showing a warning about them, we don't need to any more.
|
||||||
|
this.removeMemberNeedingApproval(userId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Callback for when the user hits the "OK" button
|
||||||
|
public confirmIdentity = async (ev: ButtonEvent): Promise<void> => {
|
||||||
|
if (this.state.currentPrompt) {
|
||||||
|
await MatrixClientPeg.safeGet().getCrypto()!.pinCurrentUserIdentity(this.state.currentPrompt.userId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public render(): React.ReactNode {
|
||||||
|
const { currentPrompt } = this.state;
|
||||||
|
if (currentPrompt) {
|
||||||
|
const substituteATag = (sub: string): React.ReactNode => (
|
||||||
|
<a href="https://element.io/help#encryption18" target="_blank" rel="noreferrer noopener">
|
||||||
|
{sub}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
const substituteBTag = (sub: string): React.ReactNode => <b>{sub}</b>;
|
||||||
|
return (
|
||||||
|
<div className="mx_UserIdentityWarning">
|
||||||
|
<MemberAvatar member={currentPrompt} title={currentPrompt.userId} size="30px" />
|
||||||
|
<span className="mx_UserIdentityWarning_main">
|
||||||
|
{currentPrompt.rawDisplayName === currentPrompt.userId
|
||||||
|
? _t(
|
||||||
|
"encryption|pinned_identity_changed_no_displayname",
|
||||||
|
{ userId: currentPrompt.userId },
|
||||||
|
{
|
||||||
|
a: substituteATag,
|
||||||
|
b: substituteBTag,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: _t(
|
||||||
|
"encryption|pinned_identity_changed",
|
||||||
|
{ displayName: currentPrompt.rawDisplayName, userId: currentPrompt.userId },
|
||||||
|
{
|
||||||
|
a: substituteATag,
|
||||||
|
b: substituteBTag,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<AccessibleButton kind="primary" onClick={this.confirmIdentity}>
|
||||||
|
{_t("action|ok")}
|
||||||
|
</AccessibleButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -909,6 +909,8 @@
|
||||||
"warning": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings."
|
"warning": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings."
|
||||||
},
|
},
|
||||||
"not_supported": "<not supported>",
|
"not_supported": "<not supported>",
|
||||||
|
"pinned_identity_changed": "%(displayName)s's (<b>%(userId)s</b>) identity appears to have changed. <a>Learn more</a>",
|
||||||
|
"pinned_identity_changed_no_displayname": "<b>%(userId)s</b>'s identity appears to have changed. <a>Learn more</a>",
|
||||||
"recovery_method_removed": {
|
"recovery_method_removed": {
|
||||||
"description_1": "This session has detected that your Security Phrase and key for Secure Messages have been removed.",
|
"description_1": "This session has detected that your Security Phrase and key for Secure Messages have been removed.",
|
||||||
"description_2": "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.",
|
"description_2": "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.",
|
||||||
|
|
|
@ -0,0 +1,342 @@
|
||||||
|
/*
|
||||||
|
Copyright 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 React from "react";
|
||||||
|
import { sleep } from "matrix-js-sdk/src/utils";
|
||||||
|
import {
|
||||||
|
CryptoEvent,
|
||||||
|
EventType,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
Room,
|
||||||
|
RoomState,
|
||||||
|
RoomStateEvent,
|
||||||
|
RoomMember,
|
||||||
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||||
|
|
||||||
|
import { stubClient } from "../../../../test-utils";
|
||||||
|
import UserIdentityWarning from "../../../../../src/components/views/rooms/UserIdentityWarning";
|
||||||
|
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||||
|
|
||||||
|
const ROOM_ID = "!room:id";
|
||||||
|
|
||||||
|
function mockRoom(): Room {
|
||||||
|
const room = {
|
||||||
|
getEncryptionTargetMembers: jest.fn(async () => []),
|
||||||
|
getMember: jest.fn((userId) => {}),
|
||||||
|
roomId: ROOM_ID,
|
||||||
|
shouldEncryptForInvitedMembers: jest.fn(() => true),
|
||||||
|
hasEncryptionStateEvent: jest.fn(() => true),
|
||||||
|
} as unknown as Room;
|
||||||
|
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockRoomMember(userId: string, name?: string): RoomMember {
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
name: name ?? userId,
|
||||||
|
rawDisplayName: name ?? userId,
|
||||||
|
roomId: ROOM_ID,
|
||||||
|
getMxcAvatarUrl: jest.fn(),
|
||||||
|
} as unknown as RoomMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dummyRoomState(): RoomState {
|
||||||
|
return new RoomState(ROOM_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the warning element, given the warning text (excluding the "Learn more" link).
|
||||||
|
function getWarningByText(text: string): Element {
|
||||||
|
return screen.getByText((content?: string, element?: Element | null): boolean => {
|
||||||
|
return (
|
||||||
|
!!element &&
|
||||||
|
element.classList.contains("mx_UserIdentityWarning_main") &&
|
||||||
|
element.textContent === text + " Learn more"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("UserIdentityWarning", () => {
|
||||||
|
let client: MatrixClient;
|
||||||
|
let room: Room;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = stubClient();
|
||||||
|
room = mockRoom();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// This tests the basic functionality of the component. If we have a room
|
||||||
|
// member whose identity needs accepting, we should display a warning. When
|
||||||
|
// the "OK" button gets pressed, it should call `pinCurrentUserIdentity`.
|
||||||
|
it("displays a warning when a user's identity needs approval", async () => {
|
||||||
|
room.getEncryptionTargetMembers = jest.fn(async () => {
|
||||||
|
return [mockRoomMember("@alice:example.org", "Alice")];
|
||||||
|
});
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
crypto["getUserVerificationStatus"] = jest.fn(async () => {
|
||||||
|
return Promise.resolve(new UserVerificationStatus(false, false, false, true));
|
||||||
|
});
|
||||||
|
crypto.pinCurrentUserIdentity = jest.fn();
|
||||||
|
const { container } = render(<UserIdentityWarning room={room} />, {
|
||||||
|
wrapper: ({ ...rest }) => <MatrixClientContext.Provider value={client} {...rest} />,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(container.querySelector("[role=button]")!);
|
||||||
|
await waitFor(() => expect(crypto.pinCurrentUserIdentity).toHaveBeenCalledWith("@alice:example.org"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// We don't display warnings in non-encrypted rooms, but if encryption is
|
||||||
|
// enabled, then we should display a warning if there are any users whose
|
||||||
|
// identity need accepting.
|
||||||
|
it("displays pending warnings when encryption is enabled", async () => {
|
||||||
|
room.getEncryptionTargetMembers = jest.fn(async () => {
|
||||||
|
return [mockRoomMember("@alice:example.org", "Alice")];
|
||||||
|
});
|
||||||
|
// Start the room off unencrypted. We shouldn't display anything.
|
||||||
|
room.hasEncryptionStateEvent = jest.fn(() => false);
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
crypto["getUserVerificationStatus"] = jest.fn(async () => {
|
||||||
|
return Promise.resolve(new UserVerificationStatus(false, false, false, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserIdentityWarning room={room} />, {
|
||||||
|
wrapper: ({ ...rest }) => <MatrixClientContext.Provider value={client} {...rest} />,
|
||||||
|
});
|
||||||
|
await sleep(10); // give it some time to finish initialising
|
||||||
|
expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow();
|
||||||
|
|
||||||
|
// Encryption gets enabled in the room. We should now warn that Alice's
|
||||||
|
// identity changed.
|
||||||
|
client.emit(
|
||||||
|
RoomStateEvent.Events,
|
||||||
|
new MatrixEvent({
|
||||||
|
event_id: "$event_id",
|
||||||
|
type: EventType.RoomEncryption,
|
||||||
|
state_key: "",
|
||||||
|
content: {
|
||||||
|
algorithm: "m.megolm.v1.aes-sha2",
|
||||||
|
},
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
sender: "@alice:example.org",
|
||||||
|
}),
|
||||||
|
dummyRoomState(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When a user's identity needs approval, or has been approved, the display
|
||||||
|
// should update appropriately.
|
||||||
|
it("updates the display when identity changes", async () => {
|
||||||
|
room.getEncryptionTargetMembers = jest.fn(async () => {
|
||||||
|
return [mockRoomMember("@alice:example.org", "Alice")];
|
||||||
|
});
|
||||||
|
room.getMember = jest.fn(() => mockRoomMember("@alice:example.org", "Alice"));
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
crypto["getUserVerificationStatus"] = jest.fn(async () => {
|
||||||
|
return Promise.resolve(new UserVerificationStatus(false, false, false, false));
|
||||||
|
});
|
||||||
|
render(<UserIdentityWarning room={room} />, {
|
||||||
|
wrapper: ({ ...rest }) => <MatrixClientContext.Provider value={client} {...rest} />,
|
||||||
|
});
|
||||||
|
await sleep(10); // give it some time to finish initialising
|
||||||
|
expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow();
|
||||||
|
|
||||||
|
// The user changes their identity, so we should show the warning.
|
||||||
|
client.emit(
|
||||||
|
CryptoEvent.UserTrustStatusChanged,
|
||||||
|
"@alice:example.org",
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate the user's new identity having been approved, so we no
|
||||||
|
// longer show the warning.
|
||||||
|
client.emit(
|
||||||
|
CryptoEvent.UserTrustStatusChanged,
|
||||||
|
"@alice:example.org",
|
||||||
|
new UserVerificationStatus(false, false, false, false),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// We only display warnings about users in the room. When someone
|
||||||
|
// joins/leaves, we should update the warning appropriately.
|
||||||
|
it("updates the display when a member joins/leaves", async () => {
|
||||||
|
// Nobody in the room yet
|
||||||
|
room.getEncryptionTargetMembers = jest.fn(async () => {
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
room.getMember = jest.fn(() => mockRoomMember("@alice:example.org", "Alice"));
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
crypto["getUserVerificationStatus"] = jest.fn(async () => {
|
||||||
|
return Promise.resolve(new UserVerificationStatus(false, false, false, true));
|
||||||
|
});
|
||||||
|
render(<UserIdentityWarning room={room} />, {
|
||||||
|
wrapper: ({ ...rest }) => <MatrixClientContext.Provider value={client} {...rest} />,
|
||||||
|
});
|
||||||
|
await sleep(10); // give it some time to finish initialising
|
||||||
|
|
||||||
|
// Alice joins. Her identity needs approval, so we should show a warning.
|
||||||
|
client.emit(
|
||||||
|
RoomStateEvent.Events,
|
||||||
|
new MatrixEvent({
|
||||||
|
event_id: "$event_id",
|
||||||
|
type: EventType.RoomMember,
|
||||||
|
state_key: "@alice:example.org",
|
||||||
|
content: {
|
||||||
|
membership: "join",
|
||||||
|
},
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
sender: "@alice:example.org",
|
||||||
|
}),
|
||||||
|
dummyRoomState(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alice leaves, so we no longer show her warning.
|
||||||
|
client.emit(
|
||||||
|
RoomStateEvent.Events,
|
||||||
|
new MatrixEvent({
|
||||||
|
event_id: "$event_id",
|
||||||
|
type: EventType.RoomMember,
|
||||||
|
state_key: "@alice:example.org",
|
||||||
|
content: {
|
||||||
|
membership: "leave",
|
||||||
|
},
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
sender: "@alice:example.org",
|
||||||
|
}),
|
||||||
|
dummyRoomState(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When we have multiple users whose identity needs approval, one user's
|
||||||
|
// identity no longer reeds approval (e.g. their identity was approved),
|
||||||
|
// then we show the next one.
|
||||||
|
it("displays the next user when the current user's identity is approved", async () => {
|
||||||
|
room.getEncryptionTargetMembers = jest.fn(async () => {
|
||||||
|
return [mockRoomMember("@alice:example.org", "Alice"), mockRoomMember("@bob:example.org")];
|
||||||
|
});
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
crypto["getUserVerificationStatus"] = jest.fn(async () => {
|
||||||
|
return Promise.resolve(new UserVerificationStatus(false, false, false, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserIdentityWarning room={room} />, {
|
||||||
|
wrapper: ({ ...rest }) => <MatrixClientContext.Provider value={client} {...rest} />,
|
||||||
|
});
|
||||||
|
// We should warn about Alice's identity first.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate Alice's new identity having been approved, so now we warn
|
||||||
|
// about Bob's identity.
|
||||||
|
client.emit(
|
||||||
|
CryptoEvent.UserTrustStatusChanged,
|
||||||
|
"@alice:example.org",
|
||||||
|
new UserVerificationStatus(false, false, false, false),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getWarningByText("@bob:example.org's identity appears to have changed.")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we get an update for a user's verification status while we're fetching
|
||||||
|
// that user's verification status, we should display based on the updated
|
||||||
|
// value.
|
||||||
|
describe("handles races between fetching verification status and receiving updates", () => {
|
||||||
|
// First case: check that if the update says that the user identity
|
||||||
|
// needs approval, but the fetch says it doesn't, we show the warning.
|
||||||
|
it("update says identity needs approval", async () => {
|
||||||
|
room.getEncryptionTargetMembers = jest.fn(async () => {
|
||||||
|
return [mockRoomMember("@alice:example.org", "Alice")];
|
||||||
|
});
|
||||||
|
room.getMember = jest.fn(() => mockRoomMember("@alice:example.org", "Alice"));
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
crypto["getUserVerificationStatus"] = jest.fn(async () => {
|
||||||
|
client.emit(
|
||||||
|
CryptoEvent.UserTrustStatusChanged,
|
||||||
|
"@alice:example.org",
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
return Promise.resolve(new UserVerificationStatus(false, false, false, false));
|
||||||
|
});
|
||||||
|
render(<UserIdentityWarning room={room} />, {
|
||||||
|
wrapper: ({ ...rest }) => <MatrixClientContext.Provider value={client} {...rest} />,
|
||||||
|
});
|
||||||
|
await sleep(10); // give it some time to finish initialising
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second case: check that if the update says that the user identity
|
||||||
|
// doesn't needs approval, but the fetch says it does, we don't show the
|
||||||
|
// warning.
|
||||||
|
it("update says identity doesn't need approval", async () => {
|
||||||
|
room.getEncryptionTargetMembers = jest.fn(async () => {
|
||||||
|
return [mockRoomMember("@alice:example.org", "Alice")];
|
||||||
|
});
|
||||||
|
room.getMember = jest.fn(() => mockRoomMember("@alice:example.org", "Alice"));
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
crypto["getUserVerificationStatus"] = jest.fn(async () => {
|
||||||
|
client.emit(
|
||||||
|
CryptoEvent.UserTrustStatusChanged,
|
||||||
|
"@alice:example.org",
|
||||||
|
new UserVerificationStatus(false, false, false, false),
|
||||||
|
);
|
||||||
|
return Promise.resolve(new UserVerificationStatus(false, false, false, true));
|
||||||
|
});
|
||||||
|
render(<UserIdentityWarning room={room} />, {
|
||||||
|
wrapper: ({ ...rest }) => <MatrixClientContext.Provider value={client} {...rest} />,
|
||||||
|
});
|
||||||
|
await sleep(10); // give it some time to finish initialising
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(() =>
|
||||||
|
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
||||||
|
).toThrow(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue