MVVM userinfo: split header and verification components (#30214)
* feat: mvvm userinfo split header and verification * test: add userinfoheader tests * fix: userHeaderVerificationView verification method
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Device, type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { type Mocked } from "jest-mock";
|
||||
import { UserVerificationStatus, type CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
||||
import { renderHook, waitFor } from "jest-matrix-react";
|
||||
|
||||
import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
|
||||
import { useUserInfoVerificationViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel";
|
||||
|
||||
describe("useUserInfoVerificationHeaderViewModel", () => {
|
||||
const defaultRoomId = "!fkfk";
|
||||
const defaultUserId = "@user:example.com";
|
||||
|
||||
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||
|
||||
const defaultProps = {
|
||||
devices: [] as Device[],
|
||||
member: defaultMember,
|
||||
};
|
||||
let mockClient: MatrixClient;
|
||||
let mockCrypto: Mocked<CryptoApi>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCrypto = {
|
||||
bootstrapSecretStorage: jest.fn(),
|
||||
bootstrapCrossSigning: jest.fn(),
|
||||
getCrossSigningKeyId: jest.fn(),
|
||||
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
|
||||
getUserDeviceInfo: jest.fn(),
|
||||
getDeviceVerificationStatus: jest.fn(),
|
||||
getUserVerificationStatus: jest.fn(),
|
||||
isDehydrationSupported: jest.fn().mockResolvedValue(false),
|
||||
startDehydration: jest.fn(),
|
||||
getKeyBackupInfo: jest.fn().mockResolvedValue(null),
|
||||
userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
|
||||
} as unknown as Mocked<CryptoApi>;
|
||||
|
||||
mockClient = createTestClient();
|
||||
jest.spyOn(mockClient, "doesServerSupportUnstableFeature").mockResolvedValue(true);
|
||||
jest.spyOn(mockClient.secretStorage, "hasKey").mockResolvedValue(true);
|
||||
jest.spyOn(mockClient, "getCrypto").mockReturnValue(mockCrypto);
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const renderUserInfoHeaderVerificationHook = (props = defaultProps) => {
|
||||
return renderHook(
|
||||
() => useUserInfoVerificationViewModel(props.member, props.devices),
|
||||
withClientContextRenderOptions(mockClient),
|
||||
);
|
||||
};
|
||||
|
||||
it("should be able to verify user", async () => {
|
||||
const notMeId = "@notMe";
|
||||
const notMetMember = new RoomMember(defaultRoomId, notMeId);
|
||||
const device1 = new Device({
|
||||
deviceId: "d1",
|
||||
userId: notMeId,
|
||||
displayName: "my device",
|
||||
algorithms: [],
|
||||
keys: new Map(),
|
||||
});
|
||||
|
||||
// mock the user as not verified
|
||||
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
|
||||
new UserVerificationStatus(false, false, false),
|
||||
);
|
||||
|
||||
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
|
||||
|
||||
// the selected user is not the default user, so he can make user verification
|
||||
const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [device1] });
|
||||
await waitFor(() => {
|
||||
const canVerify = result.current.canVerify;
|
||||
|
||||
expect(canVerify).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be able to verify user if user is not me", async () => {
|
||||
const device1 = new Device({
|
||||
deviceId: "d1",
|
||||
userId: defaultMember.userId,
|
||||
displayName: "my device",
|
||||
algorithms: [],
|
||||
keys: new Map(),
|
||||
});
|
||||
|
||||
// mock the user as not verified
|
||||
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
|
||||
new UserVerificationStatus(false, false, false),
|
||||
);
|
||||
|
||||
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
|
||||
|
||||
const { result } = renderUserInfoHeaderVerificationHook({ member: defaultMember, devices: [device1] });
|
||||
await waitFor(() => {
|
||||
const canVerify = result.current.canVerify;
|
||||
|
||||
expect(canVerify).toBeFalsy();
|
||||
// if we cant verify the user the hasCrossSigningKeys value should also be undefined
|
||||
expect(result.current.hasCrossSigningKeys).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be able to verify user if im already verified", async () => {
|
||||
const notMeId = "@notMe";
|
||||
const notMetMember = new RoomMember(defaultRoomId, notMeId);
|
||||
const device1 = new Device({
|
||||
deviceId: "d1",
|
||||
userId: notMeId,
|
||||
displayName: "my device",
|
||||
algorithms: [],
|
||||
keys: new Map(),
|
||||
});
|
||||
|
||||
// mock the user as already verified
|
||||
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
|
||||
new UserVerificationStatus(true, true, false),
|
||||
);
|
||||
|
||||
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
|
||||
|
||||
// the selected user is not the default user, so he can make user verification
|
||||
const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [device1] });
|
||||
await waitFor(() => {
|
||||
const canVerify = result.current.canVerify;
|
||||
|
||||
expect(canVerify).toBeFalsy();
|
||||
// if we cant verify the user the hasCrossSigningKeys value should also be undefined
|
||||
expect(result.current.hasCrossSigningKeys).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be able to verify user there is no devices", async () => {
|
||||
const notMeId = "@notMe";
|
||||
const notMetMember = new RoomMember(defaultRoomId, notMeId);
|
||||
|
||||
// mock the user as not verified
|
||||
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
|
||||
new UserVerificationStatus(false, false, false),
|
||||
);
|
||||
|
||||
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
|
||||
|
||||
// the selected user is not the default user, so he can make user verification
|
||||
const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [] });
|
||||
await waitFor(() => {
|
||||
const canVerify = result.current.canVerify;
|
||||
|
||||
expect(canVerify).toBeFalsy();
|
||||
// if we cant verify the user the hasCrossSigningKeys value should also be undefined
|
||||
expect(result.current.hasCrossSigningKeys).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get correct hasCrossSigningKeys values", async () => {
|
||||
const notMeId = "@notMe";
|
||||
const notMetMember = new RoomMember(defaultRoomId, notMeId);
|
||||
const device1 = new Device({
|
||||
deviceId: "d1",
|
||||
userId: notMeId,
|
||||
displayName: "my device",
|
||||
algorithms: [],
|
||||
keys: new Map(),
|
||||
});
|
||||
|
||||
// mock the user as not verified
|
||||
jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
|
||||
new UserVerificationStatus(false, false, false),
|
||||
);
|
||||
|
||||
jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
|
||||
|
||||
jest.spyOn(mockCrypto, "userHasCrossSigningKeys").mockResolvedValue(true);
|
||||
const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [device1] });
|
||||
await waitFor(() => {
|
||||
const hasCrossSigningKeys = result.current.hasCrossSigningKeys;
|
||||
|
||||
expect(hasCrossSigningKeys).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked, type Mocked } from "jest-mock";
|
||||
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
||||
import { renderHook } from "jest-matrix-react";
|
||||
|
||||
import { withClientContextRenderOptions } from "../../../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
|
||||
import { useUserfoHeaderViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel";
|
||||
import * as UseTimezone from "../../../../../../src/hooks/useUserTimezone";
|
||||
import SdkConfig from "../../../../../../src/SdkConfig";
|
||||
import Modal from "../../../../../../src/Modal";
|
||||
import ImageView from "../../../../../../src/components/views/elements/ImageView";
|
||||
import * as Media from "../../../../../../src/customisations/Media";
|
||||
import { type IConfigOptions } from "../../../../../../src/IConfigOptions";
|
||||
|
||||
jest.mock("../../../../../../src/customisations/UserIdentifier", () => {
|
||||
return {
|
||||
getDisplayUserIdentifier: jest.fn().mockReturnValue("customUserIdentifier"),
|
||||
};
|
||||
});
|
||||
|
||||
describe("useUserInfoHeaderViewModel", () => {
|
||||
const defaultRoomId = "!fkfk";
|
||||
const defaultUserId = "@user:example.com";
|
||||
|
||||
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
|
||||
|
||||
const defaultProps = {
|
||||
member: defaultMember,
|
||||
roomId: defaultRoomId,
|
||||
};
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let mockCrypto: Mocked<CryptoApi>;
|
||||
|
||||
const mockAvatarUrl = "mock-avatar-url";
|
||||
const oldGet = SdkConfig.get;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCrypto = mocked({
|
||||
getDeviceVerificationStatus: jest.fn(),
|
||||
getUserDeviceInfo: jest.fn(),
|
||||
userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
|
||||
getUserVerificationStatus: jest.fn(),
|
||||
isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false),
|
||||
} as unknown as CryptoApi);
|
||||
|
||||
mockClient = mocked({
|
||||
getUser: jest.fn(),
|
||||
isGuest: jest.fn().mockReturnValue(false),
|
||||
isUserIgnored: jest.fn(),
|
||||
getIgnoredUsers: jest.fn(),
|
||||
setIgnoredUsers: jest.fn(),
|
||||
getUserId: jest.fn(),
|
||||
getSafeUserId: jest.fn(),
|
||||
getDomain: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
|
||||
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
|
||||
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
|
||||
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
|
||||
mxcUrlToHttp: jest.fn().mockReturnValue(mockAvatarUrl),
|
||||
removeListener: jest.fn(),
|
||||
currentState: {
|
||||
on: jest.fn(),
|
||||
},
|
||||
getRoom: jest.fn(),
|
||||
credentials: {},
|
||||
setPowerLevel: jest.fn(),
|
||||
getCrypto: jest.fn().mockReturnValue(mockCrypto),
|
||||
baseUrl: "homeserver.url",
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
|
||||
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const renderUserInfoHeaderViewModelHook = (props = defaultProps) => {
|
||||
return renderHook(() => useUserfoHeaderViewModel(props), withClientContextRenderOptions(mockClient));
|
||||
};
|
||||
it("should give user timezone info", () => {
|
||||
const defaultTZ = { timezone: "FR", friendly: "fr" };
|
||||
jest.spyOn(UseTimezone, "useUserTimezone").mockReturnValue(defaultTZ);
|
||||
|
||||
const { result } = renderUserInfoHeaderViewModelHook();
|
||||
const timezone = result.current.timezoneInfo;
|
||||
|
||||
expect(UseTimezone.useUserTimezone).toHaveBeenCalledWith(mockClient, defaultMember.userId);
|
||||
expect(timezone).toEqual(defaultTZ);
|
||||
});
|
||||
|
||||
it("should give correct showPresence value based on enablePresenceByHsUrl", () => {
|
||||
jest.spyOn(SdkConfig, "get").mockImplementation((key: string) => {
|
||||
if (key === "enable_presence_by_hs_url") {
|
||||
return {
|
||||
[mockClient.baseUrl]: false,
|
||||
};
|
||||
}
|
||||
return oldGet(key as keyof IConfigOptions);
|
||||
});
|
||||
const { result } = renderUserInfoHeaderViewModelHook();
|
||||
const showPresence = result.current.showPresence;
|
||||
expect(showPresence).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should have default value true for showPresence", () => {
|
||||
jest.spyOn(SdkConfig, "get").mockImplementation(() => false);
|
||||
const { result } = renderUserInfoHeaderViewModelHook();
|
||||
const showPresence = result.current.showPresence;
|
||||
expect(showPresence).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should open image dialog when avatar is clicked", () => {
|
||||
const props = Object.assign({}, defaultProps);
|
||||
const spyModale = jest.spyOn(Modal, "createDialog");
|
||||
const spyMedia = jest.spyOn(Media, "mediaFromMxc");
|
||||
jest.spyOn(props.member, "getMxcAvatarUrl").mockReturnValue(mockAvatarUrl);
|
||||
|
||||
const { result } = renderUserInfoHeaderViewModelHook(props);
|
||||
|
||||
result.current.onMemberAvatarClick();
|
||||
|
||||
expect(spyModale).toHaveBeenCalledWith(
|
||||
ImageView,
|
||||
{
|
||||
src: mockAvatarUrl,
|
||||
name: defaultMember.name,
|
||||
},
|
||||
"mx_Dialog_lightbox",
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(spyMedia).toHaveBeenCalledWith(mockAvatarUrl);
|
||||
});
|
||||
|
||||
it("should not open image dialog when avatar url is null", () => {
|
||||
const props = Object.assign({}, defaultProps);
|
||||
const spyModale = jest.spyOn(Modal, "createDialog");
|
||||
jest.spyOn(props.member, "getMxcAvatarUrl").mockReturnValue(mockAvatarUrl);
|
||||
jest.spyOn(Media, "mediaFromMxc").mockReturnValue({
|
||||
srcHttp: null,
|
||||
isEncrypted: false,
|
||||
srcMxc: "",
|
||||
thumbnailMxc: undefined,
|
||||
hasThumbnail: false,
|
||||
thumbnailHttp: null,
|
||||
getThumbnailHttp: function (width: number, height: number, mode?: "scale" | "crop"): string | null {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
getThumbnailOfSourceHttp: function (width: number, height: number, mode?: "scale" | "crop"): string | null {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
getSquareThumbnailHttp: function (dim: number): string | null {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
downloadSource: function (): Promise<Response> {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderUserInfoHeaderViewModelHook(props);
|
||||
|
||||
result.current.onMemberAvatarClick();
|
||||
|
||||
expect(spyModale).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user