Display a warning when an unverified user's identity changes (#28211)
* display a warning when an unverified user's identity changes * use Compound and make comments into doc comments * refactor to use functional component * split into multiple hooks * apply minor changes from review * use Crypto API to determine if room is encrypted * apply changes from review * change initialisation status to a tri-state rather than a boolean * fix more race conditions, and apply changes from review * apply changes from review and switch to using counter for detecting races * Remove outdated comment Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * fix test --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
parent
affa4e5211
commit
c2ce7dbc5e
6 changed files with 895 additions and 0 deletions
|
@ -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";
|
||||||
|
|
28
res/css/views/rooms/_UserIdentityWarning.pcss
Normal file
28
res/css/views/rooms/_UserIdentityWarning.pcss
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
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 {
|
||||||
|
/* 42px is the padding-left of .mx_MessageComposer_wrapper in res/css/views/rooms/_MessageComposer.pcss */
|
||||||
|
margin-left: calc(-42px + var(--RoomView_MessageList-padding));
|
||||||
|
|
||||||
|
.mx_UserIdentityWarning_row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.mx_BaseAvatar {
|
||||||
|
margin-left: var(--cpd-space-2x);
|
||||||
|
}
|
||||||
|
.mx_UserIdentityWarning_main {
|
||||||
|
margin-left: var(--cpd-space-6x);
|
||||||
|
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";
|
||||||
|
@ -669,6 +670,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||||
<Tooltip open={isTooltipOpen} description={formatTimeLeft(secondsLeft)} placement="bottom">
|
<Tooltip open={isTooltipOpen} description={formatTimeLeft(secondsLeft)} placement="bottom">
|
||||||
<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")}>
|
||||||
<div className="mx_MessageComposer_wrapper">
|
<div className="mx_MessageComposer_wrapper">
|
||||||
|
<UserIdentityWarning room={this.props.room} key={this.props.room.roomId} />
|
||||||
<ReplyPreview
|
<ReplyPreview
|
||||||
replyToEvent={this.props.replyToEvent}
|
replyToEvent={this.props.replyToEvent}
|
||||||
permalinkCreator={this.props.permalinkCreator}
|
permalinkCreator={this.props.permalinkCreator}
|
||||||
|
|
328
src/components/views/rooms/UserIdentityWarning.tsx
Normal file
328
src/components/views/rooms/UserIdentityWarning.tsx
Normal file
|
@ -0,0 +1,328 @@
|
||||||
|
/*
|
||||||
|
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, { useCallback, useRef, useState } from "react";
|
||||||
|
import { EventType, KnownMembership, MatrixEvent, Room, RoomStateEvent, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { Button, Separator } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
import MemberAvatar from "../avatars/MemberAvatar";
|
||||||
|
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||||
|
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||||
|
|
||||||
|
interface UserIdentityWarningProps {
|
||||||
|
/**
|
||||||
|
* The current room being viewed.
|
||||||
|
*/
|
||||||
|
room: Room;
|
||||||
|
/**
|
||||||
|
* The ID of the room being viewed. This is used to ensure that the
|
||||||
|
* component's state and references are cleared when the room changes.
|
||||||
|
*/
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the component is uninitialised, is in the process of initialising, or
|
||||||
|
* has completed initialising.
|
||||||
|
*/
|
||||||
|
enum InitialisationStatus {
|
||||||
|
Uninitialised,
|
||||||
|
Initialising,
|
||||||
|
Completed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }) => {
|
||||||
|
const cli = useMatrixClientContext();
|
||||||
|
const crypto = cli.getCrypto();
|
||||||
|
|
||||||
|
// The current room member that we are prompting the user to approve.
|
||||||
|
// `undefined` means we are not currently showing a prompt.
|
||||||
|
const [currentPrompt, setCurrentPrompt] = useState<RoomMember | undefined>(undefined);
|
||||||
|
|
||||||
|
// Whether or not we've already initialised the component by loading the
|
||||||
|
// room membership.
|
||||||
|
const initialisedRef = useRef<InitialisationStatus>(InitialisationStatus.Uninitialised);
|
||||||
|
// Which room members need their identity approved.
|
||||||
|
const membersNeedingApprovalRef = useRef<Map<string, RoomMember>>(new Map());
|
||||||
|
// For each user, we assign a sequence number to each verification status
|
||||||
|
// that we get, or fetch.
|
||||||
|
//
|
||||||
|
// Since fetching a verification status is asynchronous, we could get an
|
||||||
|
// update in the middle of fetching the verification status, which could
|
||||||
|
// mean that the status that we fetched is out of date. So if the current
|
||||||
|
// sequence number is not the same as the sequence number when we started
|
||||||
|
// the fetch, then we drop our fetched result, under the assumption that the
|
||||||
|
// update that we received is the most up-to-date version. If it is in fact
|
||||||
|
// not the most up-to-date version, then we should be receiving a new update
|
||||||
|
// soon with the newer value, so it will fix itself in the end.
|
||||||
|
//
|
||||||
|
// We also assign a sequence number when the user leaves the room, in order
|
||||||
|
// to prevent prompting about a user who leaves while we are fetching their
|
||||||
|
// verification status.
|
||||||
|
const verificationStatusSequencesRef = useRef<Map<string, number>>(new Map());
|
||||||
|
const incrementVerificationStatusSequence = (userId: string): number => {
|
||||||
|
const verificationStatusSequences = verificationStatusSequencesRef.current;
|
||||||
|
const value = verificationStatusSequences.get(userId);
|
||||||
|
const newValue = value === undefined ? 1 : value + 1;
|
||||||
|
verificationStatusSequences.set(userId, newValue);
|
||||||
|
return newValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the current prompt. Select a new user if needed, or hide the
|
||||||
|
// warning if we don't have anyone to warn about.
|
||||||
|
const updateCurrentPrompt = useCallback((): undefined => {
|
||||||
|
const membersNeedingApproval = membersNeedingApprovalRef.current;
|
||||||
|
// We have to do this in a callback to `setCurrentPrompt`
|
||||||
|
// because this function could have been called after an
|
||||||
|
// `await`, and the `currentPrompt` that this function would
|
||||||
|
// have may be outdated.
|
||||||
|
setCurrentPrompt((currentPrompt) => {
|
||||||
|
// If we're already displaying a warning, and that user still needs
|
||||||
|
// approval, continue showing that user.
|
||||||
|
if (currentPrompt && membersNeedingApproval.has(currentPrompt.userId)) return currentPrompt;
|
||||||
|
|
||||||
|
if (membersNeedingApproval.size === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We pick the user with the smallest user ID.
|
||||||
|
const keys = Array.from(membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b));
|
||||||
|
const selection = membersNeedingApproval.get(keys[0]!);
|
||||||
|
return selection;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add a user to the membersNeedingApproval map, and update the current
|
||||||
|
// prompt if necessary. The user will only be added if they are actually a
|
||||||
|
// member of the room. If they are not a member, this function will do
|
||||||
|
// nothing.
|
||||||
|
const addMemberNeedingApproval = useCallback(
|
||||||
|
(userId: string, member?: RoomMember): void => {
|
||||||
|
if (userId === cli.getUserId()) {
|
||||||
|
// We always skip our own user, because we can't pin our own identity.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
member = member ?? room.getMember(userId) ?? undefined;
|
||||||
|
if (!member) return;
|
||||||
|
|
||||||
|
membersNeedingApprovalRef.current.set(userId, member);
|
||||||
|
// We only select the prompt if we are done initialising,
|
||||||
|
// because we will select the prompt after we're done
|
||||||
|
// initialising, and we want to start by displaying a warning
|
||||||
|
// for the user with the smallest ID.
|
||||||
|
if (initialisedRef.current === InitialisationStatus.Completed) {
|
||||||
|
updateCurrentPrompt();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cli, room, updateCurrentPrompt],
|
||||||
|
);
|
||||||
|
|
||||||
|
// For each user in the list check if their identity needs approval, and if
|
||||||
|
// so, add them to the membersNeedingApproval map and update the prompt if
|
||||||
|
// needed.
|
||||||
|
const addMembersWhoNeedApproval = useCallback(
|
||||||
|
async (members: RoomMember[]): Promise<void> => {
|
||||||
|
const verificationStatusSequences = verificationStatusSequencesRef.current;
|
||||||
|
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const userId = member.userId;
|
||||||
|
const sequenceNum = incrementVerificationStatusSequence(userId);
|
||||||
|
promises.push(
|
||||||
|
userNeedsApproval(crypto!, userId).then((needsApproval) => {
|
||||||
|
if (needsApproval) {
|
||||||
|
// Only actually update the list if we have the most
|
||||||
|
// recent value.
|
||||||
|
if (verificationStatusSequences.get(userId) === sequenceNum) {
|
||||||
|
addMemberNeedingApproval(userId, member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
},
|
||||||
|
[crypto, addMemberNeedingApproval],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove a user from the membersNeedingApproval map, and update the current
|
||||||
|
// prompt if necessary.
|
||||||
|
const removeMemberNeedingApproval = useCallback(
|
||||||
|
(userId: string): void => {
|
||||||
|
membersNeedingApprovalRef.current.delete(userId);
|
||||||
|
updateCurrentPrompt();
|
||||||
|
},
|
||||||
|
[updateCurrentPrompt],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialise the component. Get the room members, check which ones need
|
||||||
|
// their identity approved, and pick one to display.
|
||||||
|
const loadMembers = useCallback(async (): Promise<void> => {
|
||||||
|
if (!crypto || initialisedRef.current !== InitialisationStatus.Uninitialised) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If encryption is not enabled in the room, we don't need to do
|
||||||
|
// anything. If encryption gets enabled later, we will retry, via
|
||||||
|
// onRoomStateEvent.
|
||||||
|
if (!(await crypto.isEncryptionEnabledInRoom(room.roomId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialisedRef.current = InitialisationStatus.Initialising;
|
||||||
|
|
||||||
|
const members = await room.getEncryptionTargetMembers();
|
||||||
|
await addMembersWhoNeedApproval(members);
|
||||||
|
|
||||||
|
updateCurrentPrompt();
|
||||||
|
initialisedRef.current = InitialisationStatus.Completed;
|
||||||
|
}, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]);
|
||||||
|
|
||||||
|
loadMembers().catch((e) => {
|
||||||
|
logger.error("Error initialising UserIdentityWarning:", e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When a user's verification status changes, we check if they need to be
|
||||||
|
// added/removed from the set of members needing approval.
|
||||||
|
const onUserVerificationStatusChanged = useCallback(
|
||||||
|
(userId: string, verificationStatus: UserVerificationStatus): void => {
|
||||||
|
// If we haven't started initialising, that means that we're in a
|
||||||
|
// room where we don't need to display any warnings.
|
||||||
|
if (initialisedRef.current === InitialisationStatus.Uninitialised) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementVerificationStatusSequence(userId);
|
||||||
|
|
||||||
|
if (verificationStatus.needsUserApproval) {
|
||||||
|
addMemberNeedingApproval(userId);
|
||||||
|
} else {
|
||||||
|
removeMemberNeedingApproval(userId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addMemberNeedingApproval, removeMemberNeedingApproval],
|
||||||
|
);
|
||||||
|
useTypedEventEmitter(cli, CryptoEvent.UserTrustStatusChanged, onUserVerificationStatusChanged);
|
||||||
|
|
||||||
|
// We watch for encryption events (since we only display warnings in
|
||||||
|
// encrypted rooms), and for membership changes (since we only display
|
||||||
|
// warnings for users in the room).
|
||||||
|
const onRoomStateEvent = useCallback(
|
||||||
|
async (event: MatrixEvent): Promise<void> => {
|
||||||
|
if (!crypto || event.getRoomId() !== room.roomId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = event.getType();
|
||||||
|
if (eventType === EventType.RoomEncryption && event.getStateKey() === "") {
|
||||||
|
// Room is now encrypted, so we can initialise the component.
|
||||||
|
return loadMembers().catch((e) => {
|
||||||
|
logger.error("Error initialising UserIdentityWarning:", e);
|
||||||
|
});
|
||||||
|
} else if (eventType !== EventType.RoomMember) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're processing an m.room.member event
|
||||||
|
|
||||||
|
if (initialisedRef.current === InitialisationStatus.Uninitialised) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = event.getStateKey();
|
||||||
|
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.getContent().membership === KnownMembership.Join ||
|
||||||
|
(event.getContent().membership === KnownMembership.Invite && room.shouldEncryptForInvitedMembers())
|
||||||
|
) {
|
||||||
|
// Someone's membership changed and we will now encrypt to them. If
|
||||||
|
// their identity needs approval, show a warning.
|
||||||
|
const member = room.getMember(userId);
|
||||||
|
if (member) {
|
||||||
|
await addMembersWhoNeedApproval([member]).catch((e) => {
|
||||||
|
logger.error("Error adding member in UserIdentityWarning:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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.
|
||||||
|
removeMemberNeedingApproval(userId);
|
||||||
|
incrementVerificationStatusSequence(userId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[crypto, room, addMembersWhoNeedApproval, removeMemberNeedingApproval, loadMembers],
|
||||||
|
);
|
||||||
|
useTypedEventEmitter(cli, RoomStateEvent.Events, onRoomStateEvent);
|
||||||
|
|
||||||
|
if (!crypto || !currentPrompt) return null;
|
||||||
|
|
||||||
|
const confirmIdentity = async (): Promise<void> => {
|
||||||
|
await crypto.pinCurrentUserIdentity(currentPrompt.userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx_UserIdentityWarning">
|
||||||
|
<Separator />
|
||||||
|
<div className="mx_UserIdentityWarning_row">
|
||||||
|
<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>
|
||||||
|
<Button kind="primary" size="sm" onClick={confirmIdentity}>
|
||||||
|
{_t("action|ok")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function substituteATag(sub: string): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<a href="https://element.io/help#encryption18" target="_blank" rel="noreferrer noopener">
|
||||||
|
{sub}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function substituteBTag(sub: string): React.ReactNode {
|
||||||
|
return <b>{sub}</b>;
|
||||||
|
}
|
|
@ -905,6 +905,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,534 @@
|
||||||
|
/*
|
||||||
|
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 {
|
||||||
|
EventType,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
Room,
|
||||||
|
RoomState,
|
||||||
|
RoomStateEvent,
|
||||||
|
RoomMember,
|
||||||
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
import { act, render, screen, waitFor } from "jest-matrix-react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
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),
|
||||||
|
} 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). This is needed because the warning text contains a `<b>` tag, so the
|
||||||
|
* normal `getByText` doesn't work.
|
||||||
|
*/
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComponent(client: MatrixClient, room: Room) {
|
||||||
|
return render(<UserIdentityWarning room={room} key={ROOM_ID} />, {
|
||||||
|
wrapper: ({ ...rest }) => <MatrixClientContext.Provider value={client} {...rest} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("UserIdentityWarning", () => {
|
||||||
|
let client: MatrixClient;
|
||||||
|
let room: Room;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = stubClient();
|
||||||
|
room = mockRoom();
|
||||||
|
jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([
|
||||||
|
mockRoomMember("@alice:example.org", "Alice"),
|
||||||
|
]);
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
crypto.pinCurrentUserIdentity = jest.fn();
|
||||||
|
renderComponent(client, room);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
getWarningByText("Alice's (@alice:example.org) identity appears to have changed."),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole("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 () => {
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([
|
||||||
|
mockRoomMember("@alice:example.org", "Alice"),
|
||||||
|
]);
|
||||||
|
// Start the room off unencrypted. We shouldn't display anything.
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(false);
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderComponent(client, room);
|
||||||
|
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.
|
||||||
|
jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(true);
|
||||||
|
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 () => {
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([
|
||||||
|
mockRoomMember("@alice:example.org", "Alice"),
|
||||||
|
]);
|
||||||
|
jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice"));
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
|
new UserVerificationStatus(false, false, false, false),
|
||||||
|
);
|
||||||
|
renderComponent(client, room);
|
||||||
|
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.
|
||||||
|
act(() => {
|
||||||
|
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.
|
||||||
|
act(() => {
|
||||||
|
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.
|
||||||
|
describe("updates the display when a member joins/leaves", () => {
|
||||||
|
it("when invited users can see encrypted messages", async () => {
|
||||||
|
// Nobody in the room yet
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]);
|
||||||
|
jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId));
|
||||||
|
jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(true);
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
renderComponent(client, room);
|
||||||
|
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:example.org's identity appears to have changed.")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bob is invited. His identity needs approval, so we should show a
|
||||||
|
// warning for him after Alice's warning is resolved by her leaving.
|
||||||
|
client.emit(
|
||||||
|
RoomStateEvent.Events,
|
||||||
|
new MatrixEvent({
|
||||||
|
event_id: "$event_id",
|
||||||
|
type: EventType.RoomMember,
|
||||||
|
state_key: "@bob:example.org",
|
||||||
|
content: {
|
||||||
|
membership: "invite",
|
||||||
|
},
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
sender: "@carol:example.org",
|
||||||
|
}),
|
||||||
|
dummyRoomState(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alice leaves, so we no longer show her warning, but we will show
|
||||||
|
// a warning for Bob.
|
||||||
|
act(() => {
|
||||||
|
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:example.org's identity appears to have changed.")).toThrow(),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getWarningByText("@bob:example.org's identity appears to have changed.")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when invited users cannot see encrypted messages", async () => {
|
||||||
|
// Nobody in the room yet
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]);
|
||||||
|
jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId));
|
||||||
|
jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false);
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
renderComponent(client, room);
|
||||||
|
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:example.org's identity appears to have changed.")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bob is invited. His identity needs approval, but we don't encrypt
|
||||||
|
// to him, so we won't show a warning. (When Alice leaves, the
|
||||||
|
// display won't be updated to show a warningfor Bob.)
|
||||||
|
client.emit(
|
||||||
|
RoomStateEvent.Events,
|
||||||
|
new MatrixEvent({
|
||||||
|
event_id: "$event_id",
|
||||||
|
type: EventType.RoomMember,
|
||||||
|
state_key: "@bob:example.org",
|
||||||
|
content: {
|
||||||
|
membership: "invite",
|
||||||
|
},
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
sender: "@carol:example.org",
|
||||||
|
}),
|
||||||
|
dummyRoomState(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alice leaves, so we no longer show her warning, and we don't show
|
||||||
|
// a warning for Bob.
|
||||||
|
act(() => {
|
||||||
|
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:example.org's identity appears to have changed.")).toThrow(),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(() => getWarningByText("@bob:example.org's identity appears to have changed.")).toThrow(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when member leaves immediately after component is loaded", async () => {
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockImplementation(async () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
// Alice immediately leaves after we get the room
|
||||||
|
// membership, so we shouldn't show the warning any more
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return [mockRoomMember("@alice:example.org")];
|
||||||
|
});
|
||||||
|
jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId));
|
||||||
|
jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false);
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
renderComponent(client, room);
|
||||||
|
|
||||||
|
await sleep(10);
|
||||||
|
expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when member leaves immediately after joining", async () => {
|
||||||
|
// Nobody in the room yet
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]);
|
||||||
|
jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId));
|
||||||
|
jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false);
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
renderComponent(client, room);
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
// ... but she immediately leaves, so we shouldn't show the warning any more
|
||||||
|
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 sleep(10); // give it some time to finish
|
||||||
|
expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// When we have multiple users whose identity needs approval, one user's
|
||||||
|
// identity no longer needs 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 () => {
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([
|
||||||
|
mockRoomMember("@alice:example.org", "Alice"),
|
||||||
|
mockRoomMember("@bob:example.org"),
|
||||||
|
]);
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue(
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderComponent(client, room);
|
||||||
|
// 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.
|
||||||
|
act(() => {
|
||||||
|
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 () => {
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([
|
||||||
|
mockRoomMember("@alice:example.org", "Alice"),
|
||||||
|
]);
|
||||||
|
jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice"));
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => {
|
||||||
|
act(() => {
|
||||||
|
client.emit(
|
||||||
|
CryptoEvent.UserTrustStatusChanged,
|
||||||
|
"@alice:example.org",
|
||||||
|
new UserVerificationStatus(false, false, false, true),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return Promise.resolve(new UserVerificationStatus(false, false, false, false));
|
||||||
|
});
|
||||||
|
renderComponent(client, room);
|
||||||
|
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 () => {
|
||||||
|
jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([
|
||||||
|
mockRoomMember("@alice:example.org", "Alice"),
|
||||||
|
]);
|
||||||
|
jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice"));
|
||||||
|
const crypto = client.getCrypto()!;
|
||||||
|
jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => {
|
||||||
|
act(() => {
|
||||||
|
client.emit(
|
||||||
|
CryptoEvent.UserTrustStatusChanged,
|
||||||
|
"@alice:example.org",
|
||||||
|
new UserVerificationStatus(false, false, false, false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return Promise.resolve(new UserVerificationStatus(false, false, false, true));
|
||||||
|
});
|
||||||
|
renderComponent(client, room);
|
||||||
|
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 a new issue