Add a devtool for looking at users and their devices (#30983)

* add devtool for viewing users and their devices

* show number of devices

* apply changes from review

* Fix typo

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
Hubert Chathi
2025-10-28 16:18:10 -04:00
committed by GitHub
parent 5888dfd29d
commit b7db85146f
8 changed files with 1211 additions and 0 deletions

View File

@@ -83,6 +83,11 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = `
>
Active Widgets
</button>
<button
class="mx_DevTools_button"
>
Users
</button>
</div>
<div>
<h2

View File

@@ -0,0 +1,361 @@
/*
* 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 React from "react";
import { mocked } from "jest-mock";
import { Device, DeviceVerification, type MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix";
import { render, screen, waitFor } from "jest-matrix-react";
import { Room, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
import { type DeviceVerificationStatus, type UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { createTestClient } from "../../../../../test-utils";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { DevtoolsContext } from "../../../../../../src/components/views/dialogs/devtools/BaseTool";
import { UserList } from "../../../../../../src/components/views/dialogs/devtools/Users";
const userId = "@alice:example.com";
describe("<Users />", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = createTestClient();
});
it("should render a user list", () => {
const room = new Room("!roomId", matrixClient, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
room.getJoinedMembers = jest.fn().mockReturnValue([]);
const { asFragment } = render(
<MatrixClientContext.Provider value={matrixClient}>
<DevtoolsContext.Provider value={{ room }}>
<UserList onBack={() => {}} />
</DevtoolsContext.Provider>
</MatrixClientContext.Provider>,
);
expect(asFragment()).toMatchSnapshot();
});
it("should render a single user", async () => {
const room = new Room("!roomId", matrixClient, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const alice = new RoomMember("!roomId", userId);
alice.setMembershipEvent(
new MatrixEvent({
content: {
membership: "join",
},
state_key: userId,
room_id: "!roomId",
type: "m.room.member",
sender: userId,
}),
);
room.getJoinedMembers = jest.fn().mockReturnValue([alice]);
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
isCrossSigningVerified: jest.fn().mockReturnValue(true),
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
needsUserApproval: false,
} as unknown as UserVerificationStatus);
mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(
new Map([
[
userId,
new Map([
[
"VERIFIED",
new Device({
deviceId: "VERIFIED",
userId: userId,
algorithms: [],
keys: new Map([
["ed25519:VERIFIED", "an_ed25519_public_key"],
["curve25519:VERIFIED", "a_curve25519_public_key"],
]),
}),
],
[
"SIGNED",
new Device({
deviceId: "SIGNED",
userId: userId,
algorithms: [],
keys: new Map([
["ed25519:SIGNED", "an_ed25519_public_key"],
["curve25519:SIGNED", "a_curve25519_public_key"],
]),
}),
],
[
"UNSIGNED",
new Device({
deviceId: "UNSIGNED",
userId: userId,
algorithms: [],
keys: new Map([
["ed25519:UNSIGNED", "an_ed25519_public_key"],
["curve25519:UNSIGNED", "a_curve25519_public_key"],
]),
}),
],
]),
],
]),
);
mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockImplementation(
async (userId: string, deviceId: string) => {
switch (deviceId) {
case "VERIFIED":
return {
signedByOwner: true,
crossSigningVerified: true,
} as unknown as DeviceVerificationStatus;
case "SIGNED":
return {
signedByOwner: true,
crossSigningVerified: false,
} as unknown as DeviceVerificationStatus;
case "UNSIGNED":
return {
signedByOwner: false,
crossSigningVerified: false,
} as unknown as DeviceVerificationStatus;
default:
return null;
}
},
);
const { asFragment } = render(
<MatrixClientContext.Provider value={matrixClient}>
<DevtoolsContext.Provider value={{ room }}>
<UserList onBack={() => {}} />
</DevtoolsContext.Provider>
</MatrixClientContext.Provider>,
);
screen.getByRole("button", { name: userId }).click();
await waitFor(() => expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Verified/));
await waitFor(() => expect(screen.getByRole("button", { name: "VERIFIED" })).toBeInTheDocument());
expect(asFragment()).toMatchSnapshot();
});
it("should render a single device - verified by cross-signing", async () => {
const room = new Room("!roomId", matrixClient, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const alice = new RoomMember("!roomId", userId);
alice.setMembershipEvent(
new MatrixEvent({
content: {
membership: "join",
},
state_key: userId,
room_id: "!roomId",
type: "m.room.member",
sender: userId,
}),
);
room.getJoinedMembers = jest.fn().mockReturnValue([alice]);
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
isCrossSigningVerified: jest.fn().mockReturnValue(true),
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
needsUserApproval: false,
} as unknown as UserVerificationStatus);
mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(
new Map([
[
userId,
new Map([
[
"VERIFIED",
new Device({
deviceId: "VERIFIED",
userId: userId,
algorithms: [],
keys: new Map([
["ed25519:VERIFIED", "an_ed25519_public_key"],
["curve25519:VERIFIED", "a_curve25519_public_key"],
]),
}),
],
]),
],
]),
);
mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockResolvedValue({
signedByOwner: true,
crossSigningVerified: true,
} as unknown as DeviceVerificationStatus);
const { asFragment } = render(
<MatrixClientContext.Provider value={matrixClient}>
<DevtoolsContext.Provider value={{ room }}>
<UserList onBack={() => {}} />
</DevtoolsContext.Provider>
</MatrixClientContext.Provider>,
);
screen.getByRole("button", { name: userId }).click();
await waitFor(() => expect(screen.getByRole("button", { name: "VERIFIED" })).toBeInTheDocument());
screen.getByRole("button", { name: "VERIFIED" }).click();
await waitFor(() =>
expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Verified by cross-signing/),
);
expect(asFragment()).toMatchSnapshot();
});
it("should render a single device - signed by owner", async () => {
const room = new Room("!roomId", matrixClient, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const alice = new RoomMember("!roomId", userId);
alice.setMembershipEvent(
new MatrixEvent({
content: {
membership: "join",
},
state_key: userId,
room_id: "!roomId",
type: "m.room.member",
sender: userId,
}),
);
room.getJoinedMembers = jest.fn().mockReturnValue([alice]);
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
isCrossSigningVerified: jest.fn().mockReturnValue(true),
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
needsUserApproval: false,
} as unknown as UserVerificationStatus);
mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(
new Map([
[
userId,
new Map([
[
"SIGNED",
new Device({
deviceId: "SIGNED",
userId: userId,
algorithms: [],
keys: new Map([
["ed25519:SIGNED", "an_ed25519_public_key"],
["curve25519:SIGNED", "a_curve25519_public_key"],
]),
}),
],
]),
],
]),
);
mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockResolvedValue({
signedByOwner: true,
crossSigningVerified: false,
} as unknown as DeviceVerificationStatus);
const { asFragment } = render(
<MatrixClientContext.Provider value={matrixClient}>
<DevtoolsContext.Provider value={{ room }}>
<UserList onBack={() => {}} />
</DevtoolsContext.Provider>
</MatrixClientContext.Provider>,
);
screen.getByRole("button", { name: userId }).click();
await waitFor(() => expect(screen.getByRole("button", { name: "SIGNED" })).toBeInTheDocument());
screen.getByRole("button", { name: "SIGNED" }).click();
await waitFor(() => expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Signed by owner/));
expect(asFragment()).toMatchSnapshot();
});
it("should render a single device - unsigned", async () => {
const room = new Room("!roomId", matrixClient, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const alice = new RoomMember("!roomId", userId);
alice.setMembershipEvent(
new MatrixEvent({
content: {
membership: "join",
},
state_key: userId,
room_id: "!roomId",
type: "m.room.member",
sender: userId,
}),
);
room.getJoinedMembers = jest.fn().mockReturnValue([alice]);
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
isCrossSigningVerified: jest.fn().mockReturnValue(true),
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
needsUserApproval: false,
} as unknown as UserVerificationStatus);
mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(
new Map([
[
userId,
new Map([
[
"UNSIGNED",
new Device({
deviceId: "UNSIGNED",
userId: userId,
algorithms: [],
keys: new Map([
["ed25519:UNSIGNED", "an_ed25519_public_key"],
["curve25519:UNSIGNED", "a_curve25519_public_key"],
]),
verified: DeviceVerification.Verified,
}),
],
]),
],
]),
);
mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockResolvedValue({
signedByOwner: false,
crossSigningVerified: false,
} as unknown as DeviceVerificationStatus);
const { asFragment } = render(
<MatrixClientContext.Provider value={matrixClient}>
<DevtoolsContext.Provider value={{ room }}>
<UserList onBack={() => {}} />
</DevtoolsContext.Provider>
</MatrixClientContext.Provider>,
);
screen.getByRole("button", { name: userId }).click();
await waitFor(() => expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Verified/));
await waitFor(() => expect(screen.getByRole("button", { name: "UNSIGNED" })).toBeInTheDocument());
screen.getByRole("button", { name: "UNSIGNED" }).click();
await waitFor(() => expect(screen.getByText(/Verification status:/)).toHaveTextContent(/Not signed by owner/));
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,454 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Users /> should render a single device - signed by owner 1`] = `
<DocumentFragment>
<div
class="mx_DevTools_content"
>
<ul>
<li>
<div
class="mx_CopyableText"
>
User ID: @alice:example.com
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
<div
class="mx_CopyableText"
>
Device ID: SIGNED
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
Displayname:
</li>
<li>
<span>
Verification status:
<div
class="mx_E2EIcon mx_E2EIcon_normal mx_E2EIcon_inline"
data-testid="e2e-icon"
/>
Signed by owner
</span>
</li>
<li>
Dehydrated: No
</li>
<li>
Device keys
<ul>
<li>
<div
class="mx_CopyableText"
>
ed25519: an_ed25519_public_key
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
<div
class="mx_CopyableText"
>
curve25519: a_curve25519_public_key
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
</ul>
</li>
</ul>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
</div>
</DocumentFragment>
`;
exports[`<Users /> should render a single device - unsigned 1`] = `
<DocumentFragment>
<div
class="mx_DevTools_content"
>
<ul>
<li>
<div
class="mx_CopyableText"
>
User ID: @alice:example.com
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
<div
class="mx_CopyableText"
>
Device ID: UNSIGNED
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
Displayname:
</li>
<li>
<span>
Verification status:
<div
class="mx_E2EIcon mx_E2EIcon_warning mx_E2EIcon_inline"
data-testid="e2e-icon"
>
<div
class="mx_E2EIcon_normal"
/>
</div>
Not signed by owner
</span>
</li>
<li>
Dehydrated: No
</li>
<li>
Device keys
<ul>
<li>
<div
class="mx_CopyableText"
>
ed25519: an_ed25519_public_key
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
<div
class="mx_CopyableText"
>
curve25519: a_curve25519_public_key
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
</ul>
</li>
</ul>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
</div>
</DocumentFragment>
`;
exports[`<Users /> should render a single device - verified by cross-signing 1`] = `
<DocumentFragment>
<div
class="mx_DevTools_content"
>
<ul>
<li>
<div
class="mx_CopyableText"
>
User ID: @alice:example.com
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
<div
class="mx_CopyableText"
>
Device ID: VERIFIED
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
Displayname:
</li>
<li>
<span>
Verification status:
<div
class="mx_E2EIcon mx_E2EIcon_verified mx_E2EIcon_inline"
data-testid="e2e-icon"
>
<div
class="mx_E2EIcon_normal"
/>
</div>
Verified by cross-signing
</span>
</li>
<li>
Dehydrated: No
</li>
<li>
Device keys
<ul>
<li>
<div
class="mx_CopyableText"
>
ed25519: an_ed25519_public_key
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
<div
class="mx_CopyableText"
>
curve25519: a_curve25519_public_key
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
</ul>
</li>
</ul>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
</div>
</DocumentFragment>
`;
exports[`<Users /> should render a single user 1`] = `
<DocumentFragment>
<div
class="mx_DevTools_content"
>
<ul>
<li>
<div
class="mx_CopyableText"
>
User ID: @alice:example.com
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</li>
<li>
Membership: join
</li>
<li>
<span>
Displayname:
<i>
None
</i>
</span>
</li>
<li>
<span>
Avatar:
<i>
None
</i>
</span>
</li>
<li>
<span>
Verification status:
<div
class="mx_E2EIcon mx_E2EIcon_verified mx_E2EIcon_inline"
data-testid="e2e-icon"
>
<div
class="mx_E2EIcon_normal"
/>
</div>
Verified
</span>
</li>
</ul>
<section>
<h2>
Cryptographic devices (3)
</h2>
<ul>
<li>
<button
class="mx_DevTools_button"
>
<div
class="mx_E2EIcon mx_E2EIcon_verified mx_E2EIcon_inline"
data-testid="e2e-icon"
>
<div
class="mx_E2EIcon_normal"
/>
</div>
VERIFIED
</button>
</li>
<li>
<button
class="mx_DevTools_button"
>
<div
class="mx_E2EIcon mx_E2EIcon_normal mx_E2EIcon_inline"
data-testid="e2e-icon"
/>
SIGNED
</button>
</li>
<li>
<button
class="mx_DevTools_button"
>
<div
class="mx_E2EIcon mx_E2EIcon_warning mx_E2EIcon_inline"
data-testid="e2e-icon"
>
<div
class="mx_E2EIcon_normal"
/>
</div>
UNSIGNED
</button>
</li>
</ul>
</section>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
</div>
</DocumentFragment>
`;
exports[`<Users /> should render a user list 1`] = `
<DocumentFragment>
<div
class="mx_DevTools_content"
>
<div
class="mx_Field mx_Field_input mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
>
<input
autocomplete="off"
id="mx_Field_1"
label="Filter results"
placeholder="Filter results"
size="64"
type="text"
value=""
/>
<label
for="mx_Field_1"
>
Filter results
</label>
</div>
No results found
<div
class="mx_SettingsFlag"
>
<span
class="mx_SettingsFlag_label"
>
<div
id="mx_LabelledToggleSwitch__r_4_"
>
Only joined users
</div>
</span>
<div
aria-checked="true"
aria-disabled="false"
aria-labelledby="mx_LabelledToggleSwitch__r_4_"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on mx_ToggleSwitch_enabled"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
</div>
</DocumentFragment>
`;