element-web/test/components/views/right_panel/UserInfo-test.tsx

1381 lines
52 KiB
TypeScript

/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { fireEvent, render, screen, waitFor, cleanup, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Mocked, mocked } from "jest-mock";
import {
Room,
User,
MatrixClient,
RoomMember,
MatrixEvent,
EventType,
CryptoApi,
DeviceVerificationStatus,
Device,
} from "matrix-js-sdk/src/matrix";
import {
Phase,
VerificationRequest,
VerificationRequestEvent,
} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import { defer } from "matrix-js-sdk/src/utils";
import { EventEmitter } from "events";
import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { TooltipProvider } from "@vector-im/compound-web";
import UserInfo, {
BanToggleButton,
DeviceItem,
disambiguateDevices,
getPowerLevels,
isMuted,
PowerLevelEditor,
RoomAdminToolsContainer,
RoomKickButton,
UserInfoHeader,
UserOptionsSection,
} from "../../../../src/components/views/right_panel/UserInfo";
import dis from "../../../../src/dispatcher/dispatcher";
import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import MultiInviter from "../../../../src/utils/MultiInviter";
import * as mockVerification from "../../../../src/verification";
import Modal from "../../../../src/Modal";
import { E2EStatus } from "../../../../src/utils/ShieldUtils";
import { DirectoryMember, startDmOnFirstMessage } from "../../../../src/utils/direct-messages";
import { clearAllModals, flushPromises } from "../../../test-utils";
import ErrorDialog from "../../../../src/components/views/dialogs/ErrorDialog";
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../src/settings/UIFeature";
jest.mock("../../../../src/utils/direct-messages", () => ({
...jest.requireActual("../../../../src/utils/direct-messages"),
startDmOnFirstMessage: jest.fn(),
}));
jest.mock("../../../../src/dispatcher/dispatcher");
jest.mock("../../../../src/customisations/UserIdentifier", () => {
return {
getDisplayUserIdentifier: jest.fn().mockReturnValue("customUserIdentifier"),
};
});
jest.mock("../../../../src/utils/DMRoomMap", () => {
const mock = {
getUserIdForRoomId: jest.fn(),
getDMRoomsForUserId: jest.fn(),
};
return {
shared: jest.fn().mockReturnValue(mock),
sharedInstance: mock,
};
});
jest.mock("../../../../src/customisations/helpers/UIComponents", () => {
const original = jest.requireActual("../../../../src/customisations/helpers/UIComponents");
return {
shouldShowComponent: jest.fn().mockImplementation(original.shouldShowComponent),
};
});
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultUser = new User(defaultUserId);
let mockRoom: Mocked<Room>;
let mockSpace: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
let mockCrypto: Mocked<CryptoApi>;
beforeEach(() => {
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockSpace = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue("m.space"),
isSpaceRoom: jest.fn().mockReturnValue(true),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockCrypto = mocked({
getDeviceVerificationStatus: jest.fn(),
getUserDeviceInfo: jest.fn(),
userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
getUserVerificationStatus: jest.fn(),
} as unknown as CryptoApi);
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
isCryptoEnabled: jest.fn(),
getUserId: jest.fn(),
getSafeUserId: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
isRoomEncrypted: jest.fn().mockReturnValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
downloadKeys: jest.fn(),
getCrypto: jest.fn().mockReturnValue(mockCrypto),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
describe("<UserInfo />", () => {
class MockVerificationRequest extends EventEmitter {
pending = true;
phase: Phase = Phase.Ready;
cancellationCode: string | null = null;
constructor(opts: Partial<VerificationRequest>) {
super();
Object.assign(this, {
channel: { transactionId: 1 },
otherPartySupportsMethod: jest.fn(),
generateQRCode: jest.fn().mockReturnValue(new Promise(() => {})),
...opts,
});
}
}
let verificationRequest: MockVerificationRequest;
const defaultProps = {
user: defaultUser,
// idk what is wrong with this type
phase: RightPanelPhases.RoomMemberInfo as RightPanelPhases.RoomMemberInfo,
onClose: jest.fn(),
};
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return (
<TooltipProvider>
<MatrixClientContext.Provider value={mockClient} {...wrapperProps} />
</TooltipProvider>
);
};
return render(<UserInfo {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
beforeEach(() => {
verificationRequest = new MockVerificationRequest({});
});
afterEach(async () => {
await clearAllModals();
jest.clearAllMocks();
});
it("closes on close button click", async () => {
renderComponent();
await userEvent.click(screen.getByTestId("base-card-close-button"));
expect(defaultProps.onClose).toHaveBeenCalled();
});
describe("without a room", () => {
it("does not render space header", () => {
renderComponent();
expect(screen.queryByTestId("space-header")).not.toBeInTheDocument();
});
it("renders user info", () => {
renderComponent();
expect(screen.getByRole("heading", { name: defaultUserId })).toBeInTheDocument();
});
it("renders encryption info panel without pending verification", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel });
expect(screen.getByRole("heading", { name: /encryption/i })).toBeInTheDocument();
});
it("renders encryption verification panel with pending verification", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest });
expect(screen.queryByRole("heading", { name: /encryption/i })).not.toBeInTheDocument();
// the verificationRequest has phase of Phase.Ready but .otherPartySupportsMethod
// will not return true, so we expect to see the noCommonMethod error from VerificationPanel
expect(screen.getByText(/try with a different client/i)).toBeInTheDocument();
});
it("should show error modal when the verification request is cancelled with a mismatch", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest });
const spy = jest.spyOn(Modal, "createDialog");
act(() => {
verificationRequest.phase = Phase.Cancelled;
verificationRequest.cancellationCode = "m.key_mismatch";
verificationRequest.emit(VerificationRequestEvent.Change);
});
expect(spy).toHaveBeenCalledWith(
ErrorDialog,
expect.objectContaining({ title: "Your messages are not secure" }),
);
});
it("should not show error modal when the verification request is changed for some other reason", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest });
const spy = jest.spyOn(Modal, "createDialog");
// change to "started"
act(() => {
verificationRequest.phase = Phase.Started;
verificationRequest.emit(VerificationRequestEvent.Change);
});
// cancelled for some other reason
act(() => {
verificationRequest.phase = Phase.Cancelled;
verificationRequest.cancellationCode = "changed my mind";
verificationRequest.emit(VerificationRequestEvent.Change);
});
expect(spy).not.toHaveBeenCalled();
});
it("renders close button correctly when encryption panel with a pending verification request", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest });
expect(screen.getByTestId("base-card-close-button")).toHaveAttribute("title", "Cancel");
});
});
describe("with a room", () => {
it("renders user info", () => {
renderComponent({ room: mockRoom });
expect(screen.getByRole("heading", { name: defaultUserId })).toBeInTheDocument();
});
it("does not render space header when room is not a space room", () => {
renderComponent({ room: mockRoom });
expect(screen.queryByTestId("space-header")).not.toBeInTheDocument();
});
it("renders space header when room is a space room", () => {
const spaceRoom = {
...mockRoom,
isSpaceRoom: jest.fn().mockReturnValue(true),
};
renderComponent({ room: spaceRoom });
expect(screen.getByTestId("space-header")).toBeInTheDocument();
});
it("renders encryption info panel without pending verification", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel, room: mockRoom });
expect(screen.getByRole("heading", { name: /encryption/i })).toBeInTheDocument();
});
it("renders encryption verification panel with pending verification", () => {
renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest, room: mockRoom });
expect(screen.queryByRole("heading", { name: /encryption/i })).not.toBeInTheDocument();
// the verificationRequest has phase of Phase.Ready but .otherPartySupportsMethod
// will not return true, so we expect to see the noCommonMethod error from VerificationPanel
expect(screen.getByText(/try with a different client/i)).toBeInTheDocument();
});
it("renders the message button", () => {
render(
<MatrixClientContext.Provider value={mockClient}>
<UserInfo {...defaultProps} />
</MatrixClientContext.Provider>,
);
screen.getByRole("button", { name: "Message" });
});
it("hides the message button if the visibility customisation hides all create room features", () => {
mocked(shouldShowComponent).withImplementation(
(component) => {
return component !== UIComponent.CreateRooms;
},
() => {
render(
<MatrixClientContext.Provider value={mockClient}>
<UserInfo {...defaultProps} />
</MatrixClientContext.Provider>,
);
expect(screen.queryByRole("button", { name: "Message" })).toBeNull();
},
);
});
});
describe("with crypto enabled", () => {
beforeEach(() => {
mockClient.isCryptoEnabled.mockReturnValue(true);
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(true);
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
const device = new Device({
deviceId: "d1",
userId: defaultUserId,
displayName: "my device",
algorithms: [],
keys: new Map(),
});
const devicesMap = new Map<string, Device>([[device.deviceId, device]]);
const userDeviceMap = new Map<string, Map<string, Device>>([[defaultUserId, devicesMap]]);
mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap);
});
it("renders a device list which can be expanded", async () => {
renderComponent();
await act(flushPromises);
// check the button exists with the expected text
const devicesButton = screen.getByRole("button", { name: "1 session" });
// click it
await userEvent.click(devicesButton);
// there should now be a button with the device id ...
const deviceButton = screen.getByRole("button", { description: "d1" });
// ... which should contain the device name
expect(within(deviceButton).getByText("my device")).toBeInTheDocument();
});
it("renders <BasicUserInfo />", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
const { container } = renderComponent({
phase: RightPanelPhases.SpaceMemberInfo,
verificationRequest,
room: mockRoom,
});
await act(flushPromises);
await waitFor(() => expect(screen.getByRole("button", { name: "Verify" })).toBeInTheDocument());
expect(container).toMatchSnapshot();
});
});
describe("with an encrypted room", () => {
beforeEach(() => {
mockClient.isCryptoEnabled.mockReturnValue(true);
mockClient.isRoomEncrypted.mockReturnValue(true);
});
it("renders unverified user info", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false));
renderComponent({ room: mockRoom });
await act(flushPromises);
const userHeading = screen.getByRole("heading", { name: /@user:example.com/ });
// there should be a "normal" E2E padlock
expect(userHeading.getElementsByClassName("mx_E2EIcon_normal")).toHaveLength(1);
});
it("renders verified user info", async () => {
mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, false, false));
renderComponent({ room: mockRoom });
await act(flushPromises);
const userHeading = screen.getByRole("heading", { name: /@user:example.com/ });
// there should be a "verified" E2E padlock
expect(userHeading.getElementsByClassName("mx_E2EIcon_verified")).toHaveLength(1);
});
});
});
describe("<UserInfoHeader />", () => {
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = {
member: defaultMember,
roomId: defaultRoomId,
};
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return (
<TooltipProvider>
<MatrixClientContext.Provider value={mockClient} {...wrapperProps} />
</TooltipProvider>
);
};
return render(<UserInfoHeader {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
it("does not render an e2e icon in the header if e2eStatus prop is undefined", () => {
renderComponent();
const header = screen.getByRole("heading", { name: defaultUserId });
expect(header.getElementsByClassName("mx_E2EIcon")).toHaveLength(0);
});
it("renders an e2e icon in the header if e2eStatus prop is defined", () => {
renderComponent({ e2eStatus: E2EStatus.Normal });
const header = screen.getByRole("heading");
expect(header.getElementsByClassName("mx_E2EIcon")).toHaveLength(1);
});
it("renders custom user identifiers in the header", () => {
renderComponent();
expect(screen.getByText("customUserIdentifier")).toBeInTheDocument();
});
});
describe("<DeviceItem />", () => {
const device = { deviceId: "deviceId", displayName: "deviceName" } as Device;
const defaultProps = {
userId: defaultUserId,
device,
isUserVerified: false,
};
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<DeviceItem {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
const setMockDeviceTrust = (isVerified = false, isCrossSigningVerified = false) => {
mockCrypto.getDeviceVerificationStatus.mockResolvedValue({
isVerified: () => isVerified,
crossSigningVerified: isCrossSigningVerified,
} as DeviceVerificationStatus);
};
const mockVerifyDevice = jest.spyOn(mockVerification, "verifyDevice");
beforeEach(() => {
setMockDeviceTrust();
});
afterEach(() => {
mockCrypto.getDeviceVerificationStatus.mockReset();
mockVerifyDevice.mockClear();
});
afterAll(() => {
mockVerifyDevice.mockRestore();
});
it("with unverified user and device, displays button without a label", async () => {
renderComponent();
await act(flushPromises);
expect(screen.getByRole("button", { name: device.displayName! })).toBeInTheDocument();
expect(screen.queryByText(/trusted/i)).not.toBeInTheDocument();
});
it("with verified user only, displays button with a 'Not trusted' label", async () => {
renderComponent({ isUserVerified: true });
await act(flushPromises);
expect(screen.getByRole("button", { name: `${device.displayName} Not trusted` })).toBeInTheDocument();
});
it("with verified device only, displays no button without a label", async () => {
setMockDeviceTrust(true);
renderComponent();
await act(flushPromises);
expect(screen.getByText(device.displayName!)).toBeInTheDocument();
expect(screen.queryByText(/trusted/)).not.toBeInTheDocument();
});
it("when userId is the same as userId from client, uses isCrossSigningVerified to determine if button is shown", async () => {
const deferred = defer<DeviceVerificationStatus>();
mockCrypto.getDeviceVerificationStatus.mockReturnValue(deferred.promise);
mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId);
mockClient.getUserId.mockReturnValueOnce(defaultUserId);
renderComponent();
await act(flushPromises);
// set trust to be false for isVerified, true for isCrossSigningVerified
deferred.resolve({
isVerified: () => false,
crossSigningVerified: true,
} as DeviceVerificationStatus);
await expect(screen.findByText(device.displayName!)).resolves.toBeInTheDocument();
// expect to see no button in this case
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("with verified user and device, displays no button and a 'Trusted' label", async () => {
setMockDeviceTrust(true);
renderComponent({ isUserVerified: true });
await act(flushPromises);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.getByText(device.displayName!)).toBeInTheDocument();
expect(screen.getByText("Trusted")).toBeInTheDocument();
});
it("does not call verifyDevice if client.getUser returns null", async () => {
mockClient.getUser.mockReturnValueOnce(null);
renderComponent();
await act(flushPromises);
const button = screen.getByRole("button", { name: device.displayName! });
expect(button).toBeInTheDocument();
await userEvent.click(button);
expect(mockVerifyDevice).not.toHaveBeenCalled();
});
it("calls verifyDevice if client.getUser returns an object", async () => {
mockClient.getUser.mockReturnValueOnce(defaultUser);
// set mock return of isGuest to short circuit verifyDevice call to avoid
// even more mocking
mockClient.isGuest.mockReturnValueOnce(true);
renderComponent();
await act(flushPromises);
const button = screen.getByRole("button", { name: device.displayName! });
expect(button).toBeInTheDocument();
await userEvent.click(button);
expect(mockVerifyDevice).toHaveBeenCalledTimes(1);
expect(mockVerifyDevice).toHaveBeenCalledWith(mockClient, defaultUser, device);
});
it("with display name", async () => {
const { container } = renderComponent();
await act(flushPromises);
expect(container).toMatchSnapshot();
});
it("without display name", async () => {
const device = { deviceId: "deviceId" } as Device;
const { container } = renderComponent({ device, userId: defaultUserId });
await act(flushPromises);
expect(container).toMatchSnapshot();
});
it("ambiguous display name", async () => {
const device = { deviceId: "deviceId", ambiguous: true, displayName: "my display name" };
const { container } = renderComponent({ device, userId: defaultUserId });
await act(flushPromises);
expect(container).toMatchSnapshot();
});
});
describe("<UserOptionsSection />", () => {
const member = new RoomMember(defaultRoomId, defaultUserId);
const defaultProps = { member, isIgnored: false, canInvite: false, isSpace: false };
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<UserOptionsSection {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
const inviteSpy = jest.spyOn(MultiInviter.prototype, "invite");
beforeEach(() => {
inviteSpy.mockReset();
mockClient.setIgnoredUsers.mockClear();
});
afterEach(async () => {
await clearAllModals();
});
afterAll(() => {
inviteSpy.mockRestore();
});
it("always shows share user button", () => {
renderComponent();
expect(screen.getByRole("button", { name: /share link to user/i })).toBeInTheDocument();
});
it("does not show ignore or direct message buttons when member userId matches client userId", () => {
mockClient.getSafeUserId.mockReturnValueOnce(member.userId);
mockClient.getUserId.mockReturnValueOnce(member.userId);
renderComponent();
expect(screen.queryByRole("button", { name: /ignore/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /message/i })).not.toBeInTheDocument();
});
it("shows ignore, direct message and mention buttons when member userId does not match client userId", () => {
// call to client.getUserId returns undefined, which will not match member.userId
renderComponent();
expect(screen.getByRole("button", { name: /ignore/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /message/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /mention/i })).toBeInTheDocument();
});
it("when call to client.getRoom is null, does not show read receipt button", () => {
mockClient.getRoom.mockReturnValueOnce(null);
renderComponent();
expect(screen.queryByRole("button", { name: /jump to read receipt/i })).not.toBeInTheDocument();
});
it("when call to client.getRoom is non-null and room.getEventReadUpTo is null, does not show read receipt button", () => {
mockRoom.getEventReadUpTo.mockReturnValueOnce(null);
mockClient.getRoom.mockReturnValueOnce(mockRoom);
renderComponent();
expect(screen.queryByRole("button", { name: /jump to read receipt/i })).not.toBeInTheDocument();
});
it("when calls to client.getRoom and room.getEventReadUpTo are non-null, shows read receipt button", () => {
mockRoom.getEventReadUpTo.mockReturnValueOnce("1234");
mockClient.getRoom.mockReturnValueOnce(mockRoom);
renderComponent();
expect(screen.getByRole("button", { name: /jump to read receipt/i })).toBeInTheDocument();
});
it("clicking the read receipt button calls dispatch with correct event_id", async () => {
const mockEventId = "1234";
mockRoom.getEventReadUpTo.mockReturnValue(mockEventId);
mockClient.getRoom.mockReturnValue(mockRoom);
renderComponent();
const readReceiptButton = screen.getByRole("button", { name: /jump to read receipt/i });
expect(readReceiptButton).toBeInTheDocument();
await userEvent.click(readReceiptButton);
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_room",
event_id: mockEventId,
highlighted: true,
metricsTrigger: undefined,
room_id: "!fkfk",
});
mockRoom.getEventReadUpTo.mockReset();
mockClient.getRoom.mockReset();
});
it("firing the read receipt event handler with a null event_id calls dispatch with undefined not null", async () => {
const mockEventId = "1234";
// the first call is the check to see if we should render the button, second call is
// when the button is clicked
mockRoom.getEventReadUpTo.mockReturnValueOnce(mockEventId).mockReturnValueOnce(null);
mockClient.getRoom.mockReturnValue(mockRoom);
renderComponent();
const readReceiptButton = screen.getByRole("button", { name: /jump to read receipt/i });
expect(readReceiptButton).toBeInTheDocument();
await userEvent.click(readReceiptButton);
expect(dis.dispatch).toHaveBeenCalledWith({
action: "view_room",
event_id: undefined,
highlighted: true,
metricsTrigger: undefined,
room_id: "!fkfk",
});
mockClient.getRoom.mockReset();
});
it("does not show the invite button when canInvite is false", () => {
renderComponent();
expect(screen.queryByRole("button", { name: /invite/i })).not.toBeInTheDocument();
});
it("shows the invite button when canInvite is true", () => {
renderComponent({ canInvite: true });
expect(screen.getByRole("button", { name: /invite/i })).toBeInTheDocument();
});
it("clicking the invite button will call MultiInviter.invite", async () => {
// to save mocking, we will reject the call to .invite
const mockErrorMessage = new Error("test error message");
inviteSpy.mockRejectedValue(mockErrorMessage);
// render the component and click the button
renderComponent({ canInvite: true });
const inviteButton = screen.getByRole("button", { name: /invite/i });
expect(inviteButton).toBeInTheDocument();
await userEvent.click(inviteButton);
// check that we have called .invite
expect(inviteSpy).toHaveBeenCalledWith([member.userId]);
// check that the test error message is displayed
await waitFor(() => {
expect(screen.getByText(mockErrorMessage.message)).toBeInTheDocument();
});
});
it("if calling .invite throws something strange, show default error message", async () => {
inviteSpy.mockRejectedValue({ this: "could be anything" });
// render the component and click the button
renderComponent({ canInvite: true });
const inviteButton = screen.getByRole("button", { name: /invite/i });
expect(inviteButton).toBeInTheDocument();
await userEvent.click(inviteButton);
// check that the default test error message is displayed
await waitFor(() => {
expect(screen.getByText(/operation failed/i)).toBeInTheDocument();
});
});
it("shows a modal before ignoring the user", async () => {
const originalCreateDialog = Modal.createDialog;
const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({
finished: Promise.resolve([true]),
close: () => {},
}));
try {
mockClient.getIgnoredUsers.mockReturnValue([]);
renderComponent({ isIgnored: false });
await userEvent.click(screen.getByRole("button", { name: "Ignore" }));
expect(modalSpy).toHaveBeenCalled();
expect(mockClient.setIgnoredUsers).toHaveBeenLastCalledWith([member.userId]);
} finally {
Modal.createDialog = originalCreateDialog;
}
});
it("cancels ignoring the user", async () => {
const originalCreateDialog = Modal.createDialog;
const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({
finished: Promise.resolve([false]),
close: () => {},
}));
try {
mockClient.getIgnoredUsers.mockReturnValue([]);
renderComponent({ isIgnored: false });
await userEvent.click(screen.getByRole("button", { name: "Ignore" }));
expect(modalSpy).toHaveBeenCalled();
expect(mockClient.setIgnoredUsers).not.toHaveBeenCalled();
} finally {
Modal.createDialog = originalCreateDialog;
}
});
it("unignores the user", async () => {
mockClient.getIgnoredUsers.mockReturnValue([member.userId]);
renderComponent({ isIgnored: true });
await userEvent.click(screen.getByRole("button", { name: "Unignore" }));
expect(mockClient.setIgnoredUsers).toHaveBeenCalledWith([]);
});
it.each([
["for a RoomMember", member, member.getMxcAvatarUrl()],
["for a User", defaultUser, defaultUser.avatarUrl],
])(
"clicking »message« %s should start a DM",
async (test: string, member: RoomMember | User, expectedAvatarUrl: string | undefined) => {
const deferred = defer<string>();
mocked(startDmOnFirstMessage).mockReturnValue(deferred.promise);
renderComponent({ member });
await userEvent.click(screen.getByText("Message"));
// Checking the attribute, because the button is a DIV and toBeDisabled() does not work.
expect(screen.getByText("Message")).toHaveAttribute("disabled");
expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockClient, [
new DirectoryMember({
user_id: member.userId,
display_name: member.rawDisplayName,
avatar_url: expectedAvatarUrl,
}),
]);
await act(async () => {
deferred.resolve("!dm:example.com");
await flushPromises();
});
// Checking the attribute, because the button is a DIV and toBeDisabled() does not work.
expect(screen.getByText("Message")).not.toHaveAttribute("disabled");
},
);
});
describe("<PowerLevelEditor />", () => {
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let defaultProps: Parameters<typeof PowerLevelEditor>[0];
beforeEach(() => {
defaultProps = {
user: defaultMember,
room: mockRoom,
roomPermissions: {
modifyLevelMax: 100,
canEdit: false,
canInvite: false,
},
};
});
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<PowerLevelEditor {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
it("renders a power level combobox", () => {
renderComponent();
expect(screen.getByRole("combobox", { name: "Power level" })).toBeInTheDocument();
});
it("renders a combobox and attempts to change power level on change of the combobox", async () => {
const startPowerLevel = 999;
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 },
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId);
mockClient.getUserId.mockReturnValueOnce(defaultUserId);
mockClient.setPowerLevel.mockResolvedValueOnce({ event_id: "123" });
renderComponent();
const changedPowerLevel = 100;
fireEvent.change(screen.getByRole("combobox", { name: "Power level" }), {
target: { value: changedPowerLevel },
});
await screen.findByText("Demote", { exact: true });
// firing the event will raise a dialog warning about self demotion, wait for this to appear then click on it
await userEvent.click(await screen.findByText("Demote", { exact: true }));
expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1);
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(
mockRoom.roomId,
defaultMember.userId,
changedPowerLevel,
powerLevelEvent,
);
});
});
describe("<RoomKickButton />", () => {
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const memberWithInviteMembership = { ...defaultMember, membership: "invite" };
const memberWithJoinMembership = { ...defaultMember, membership: "join" };
let defaultProps: Parameters<typeof RoomKickButton>[0];
beforeEach(() => {
defaultProps = {
room: mockRoom,
member: defaultMember,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
isUpdating: false,
};
});
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<RoomKickButton {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog");
afterEach(() => {
createDialogSpy.mockReset();
});
it("renders nothing if member.membership is undefined", () => {
// .membership is undefined in our member by default
const { container } = renderComponent();
expect(container).toBeEmptyDOMElement();
});
it("renders something if member.membership is 'invite' or 'join'", () => {
let result = renderComponent({ member: memberWithInviteMembership });
expect(result.container).not.toBeEmptyDOMElement();
cleanup();
result = renderComponent({ member: memberWithJoinMembership });
expect(result.container).not.toBeEmptyDOMElement();
});
it("renders the correct label", () => {
// test for room
renderComponent({ member: memberWithJoinMembership });
expect(screen.getByText(/remove from room/i)).toBeInTheDocument();
cleanup();
renderComponent({ member: memberWithInviteMembership });
expect(screen.getByText(/disinvite from room/i)).toBeInTheDocument();
cleanup();
// test for space
mockRoom.isSpaceRoom.mockReturnValue(true);
renderComponent({ member: memberWithJoinMembership });
expect(screen.getByText(/remove from space/i)).toBeInTheDocument();
cleanup();
renderComponent({ member: memberWithInviteMembership });
expect(screen.getByText(/disinvite from space/i)).toBeInTheDocument();
cleanup();
mockRoom.isSpaceRoom.mockReturnValue(false);
});
it("clicking the kick button calls Modal.createDialog with the correct arguments", async () => {
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
renderComponent({ room: mockSpace, member: memberWithInviteMembership });
await userEvent.click(screen.getByText(/disinvite from/i));
// check the last call arguments and the presence of the spaceChildFilter callback
expect(createDialogSpy).toHaveBeenLastCalledWith(
expect.any(Function),
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
"mx_ConfirmSpaceUserActionDialog_wrapper",
);
// test the spaceChildFilter callback
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
// make dummy values for myMember and theirMember, then we will test
// null vs their member followed by
// my member vs their member
const mockMyMember = { powerLevel: 1 };
const mockTheirMember = { membership: "invite", powerLevel: 0 };
const mockRoom = {
getMember: jest
.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(mockTheirMember)
.mockReturnValueOnce(mockMyMember)
.mockReturnValueOnce(mockTheirMember),
currentState: {
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
},
};
expect(callback(mockRoom)).toBe(false);
expect(callback(mockRoom)).toBe(true);
});
});
describe("<BanToggleButton />", () => {
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const memberWithBanMembership = { ...defaultMember, membership: "ban" };
let defaultProps: Parameters<typeof BanToggleButton>[0];
beforeEach(() => {
defaultProps = {
room: mockRoom,
member: defaultMember,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
isUpdating: false,
};
});
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<BanToggleButton {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog");
afterEach(() => {
createDialogSpy.mockReset();
});
it("renders the correct labels for banned and unbanned members", () => {
// test for room
// defaultMember is not banned
renderComponent();
expect(screen.getByText("Ban from room")).toBeInTheDocument();
cleanup();
renderComponent({ member: memberWithBanMembership });
expect(screen.getByText("Unban from room")).toBeInTheDocument();
cleanup();
// test for space
mockRoom.isSpaceRoom.mockReturnValue(true);
renderComponent();
expect(screen.getByText("Ban from space")).toBeInTheDocument();
cleanup();
renderComponent({ member: memberWithBanMembership });
expect(screen.getByText("Unban from space")).toBeInTheDocument();
cleanup();
mockRoom.isSpaceRoom.mockReturnValue(false);
});
it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user is not banned", async () => {
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
renderComponent({ room: mockSpace });
await userEvent.click(screen.getByText(/ban from/i));
// check the last call arguments and the presence of the spaceChildFilter callback
expect(createDialogSpy).toHaveBeenLastCalledWith(
expect.any(Function),
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
"mx_ConfirmSpaceUserActionDialog_wrapper",
);
// test the spaceChildFilter callback
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
// make dummy values for myMember and theirMember, then we will test
// null vs their member followed by
// truthy my member vs their member
const mockMyMember = { powerLevel: 1 };
const mockTheirMember = { membership: "is not ban", powerLevel: 0 };
const mockRoom = {
getMember: jest
.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(mockTheirMember)
.mockReturnValueOnce(mockMyMember)
.mockReturnValueOnce(mockTheirMember),
currentState: {
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
},
};
expect(callback(mockRoom)).toBe(false);
expect(callback(mockRoom)).toBe(true);
});
it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user _is_ banned", async () => {
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
renderComponent({ room: mockSpace, member: memberWithBanMembership });
await userEvent.click(screen.getByText(/ban from/i));
// check the last call arguments and the presence of the spaceChildFilter callback
expect(createDialogSpy).toHaveBeenLastCalledWith(
expect.any(Function),
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
"mx_ConfirmSpaceUserActionDialog_wrapper",
);
// test the spaceChildFilter callback
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
// make dummy values for myMember and theirMember, then we will test
// null vs their member followed by
// my member vs their member
const mockMyMember = { powerLevel: 1 };
const mockTheirMember = { membership: "ban", powerLevel: 0 };
const mockRoom = {
getMember: jest
.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(mockTheirMember)
.mockReturnValueOnce(mockMyMember)
.mockReturnValueOnce(mockTheirMember),
currentState: {
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
},
};
expect(callback(mockRoom)).toBe(false);
expect(callback(mockRoom)).toBe(true);
});
});
describe("<RoomAdminToolsContainer />", () => {
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
defaultMember.membership = "invite";
let defaultProps: Parameters<typeof RoomAdminToolsContainer>[0];
beforeEach(() => {
defaultProps = {
room: mockRoom,
member: defaultMember,
isUpdating: false,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
powerLevels: {},
};
});
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<RoomAdminToolsContainer {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
it("returns a single empty div if room.getMember is falsy", () => {
const { asFragment } = renderComponent();
expect(asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<div />
</DocumentFragment>
`);
});
it("can return a single empty div in case where room.getMember is not falsy", () => {
mockRoom.getMember.mockReturnValueOnce(defaultMember);
const { asFragment } = renderComponent();
expect(asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<div />
</DocumentFragment>
`);
});
it("returns kick, redact messages, ban buttons if conditions met", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
const defaultMemberWithPowerLevel = { ...defaultMember, powerLevel: 0 };
renderComponent({ member: defaultMemberWithPowerLevel });
expect(screen.getByRole("heading", { name: /admin tools/i })).toBeInTheDocument();
expect(screen.getByText(/disinvite from room/i)).toBeInTheDocument();
expect(screen.getByText(/ban from room/i)).toBeInTheDocument();
expect(screen.getByText(/remove recent messages/i)).toBeInTheDocument();
});
it("returns mute toggle button if conditions met", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
const defaultMemberWithPowerLevelAndJoinMembership = { ...defaultMember, powerLevel: 0, membership: "join" };
renderComponent({
member: defaultMemberWithPowerLevelAndJoinMembership,
powerLevels: { events: { "m.room.power_levels": 1 } },
});
const button = screen.getByText(/mute/i);
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(defaultProps.startUpdating).toHaveBeenCalled();
});
it("should disable buttons when isUpdating=true", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
const defaultMemberWithPowerLevelAndJoinMembership = { ...defaultMember, powerLevel: 0, membership: "join" };
renderComponent({
member: defaultMemberWithPowerLevelAndJoinMembership,
powerLevels: { events: { "m.room.power_levels": 1 } },
isUpdating: true,
});
const button = screen.getByText(/mute/i);
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute("disabled");
expect(button).toHaveAttribute("aria-disabled", "true");
});
it("should not show mute button for one's own member", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, mockClient.getSafeUserId());
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
renderComponent({
member: mockMeMember,
powerLevels: { events: { "m.room.power_levels": 100 } },
});
const button = screen.queryByText(/mute/i);
expect(button).not.toBeInTheDocument();
});
});
describe("disambiguateDevices", () => {
it("does not add ambiguous key to unique names", () => {
const initialDevices = [
{ deviceId: "id1", displayName: "name1" } as Device,
{ deviceId: "id2", displayName: "name2" } as Device,
{ deviceId: "id3", displayName: "name3" } as Device,
];
disambiguateDevices(initialDevices);
// mutates input so assert against initialDevices
initialDevices.forEach((device) => {
expect(device).not.toHaveProperty("ambiguous");
});
});
it("adds ambiguous key to all ids with non-unique names", () => {
const uniqueNameDevices = [
{ deviceId: "id3", displayName: "name3" } as Device,
{ deviceId: "id4", displayName: "name4" } as Device,
{ deviceId: "id6", displayName: "name6" } as Device,
];
const nonUniqueNameDevices = [
{ deviceId: "id1", displayName: "nonUnique" } as Device,
{ deviceId: "id2", displayName: "nonUnique" } as Device,
{ deviceId: "id5", displayName: "nonUnique" } as Device,
];
const initialDevices = [...uniqueNameDevices, ...nonUniqueNameDevices];
disambiguateDevices(initialDevices);
// mutates input so assert against initialDevices
uniqueNameDevices.forEach((device) => {
expect(device).not.toHaveProperty("ambiguous");
});
nonUniqueNameDevices.forEach((device) => {
expect(device).toHaveProperty("ambiguous", true);
});
});
});
describe("isMuted", () => {
// this member has a power level of 0
const isMutedMember = new RoomMember(defaultRoomId, defaultUserId);
it("returns false if either argument is falsy", () => {
// @ts-ignore to let us purposely pass incorrect args
expect(isMuted(isMutedMember, null)).toBe(false);
// @ts-ignore to let us purposely pass incorrect args
expect(isMuted(null, {})).toBe(false);
});
it("when powerLevelContent.events and .events_default are undefined, returns false", () => {
const powerLevelContents = {};
expect(isMuted(isMutedMember, powerLevelContents)).toBe(false);
});
it("when powerLevelContent.events is undefined, uses .events_default", () => {
const higherPowerLevelContents = { events_default: 10 };
expect(isMuted(isMutedMember, higherPowerLevelContents)).toBe(true);
const lowerPowerLevelContents = { events_default: -10 };
expect(isMuted(isMutedMember, lowerPowerLevelContents)).toBe(false);
});
it("when powerLevelContent.events is defined but '.m.room.message' isn't, uses .events_default", () => {
const higherPowerLevelContents = { events: {}, events_default: 10 };
expect(isMuted(isMutedMember, higherPowerLevelContents)).toBe(true);
const lowerPowerLevelContents = { events: {}, events_default: -10 };
expect(isMuted(isMutedMember, lowerPowerLevelContents)).toBe(false);
});
it("when powerLevelContent.events and '.m.room.message' are defined, uses the value", () => {
const higherPowerLevelContents = { events: { "m.room.message": -10 }, events_default: 10 };
expect(isMuted(isMutedMember, higherPowerLevelContents)).toBe(false);
const lowerPowerLevelContents = { events: { "m.room.message": 10 }, events_default: -10 };
expect(isMuted(isMutedMember, lowerPowerLevelContents)).toBe(true);
});
});
describe("getPowerLevels", () => {
it("returns an empty object when room.currentState.getStateEvents return null", () => {
mockRoom.currentState.getStateEvents.mockReturnValueOnce(null);
expect(getPowerLevels(mockRoom)).toEqual({});
});
});