243 lines
11 KiB
TypeScript
243 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
|
import i18next from "i18next";
|
|
import { UserDetailPage } from "./UserDetailPage";
|
|
import type { UserDetail } from "@/lib/types";
|
|
|
|
const calls = vi.hoisted(() => ({
|
|
getUser: vi.fn(),
|
|
getUserQuotas: vi.fn(),
|
|
listUserSessions: vi.fn(),
|
|
revokeUserSession: vi.fn(),
|
|
revokeUserSessions: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/tier", () => ({
|
|
useTier: () => ({ loading: false, identity: { user_id: "owner-1", role: "owner" }, isAdmin: true, isOwner: true }),
|
|
}));
|
|
vi.mock("@/lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/lib/api")>();
|
|
return { ...actual, api: { ...actual.api, ...calls } };
|
|
});
|
|
|
|
const VERIFIED = "2026-03-04T05:06:07Z";
|
|
const EXPIRES = "2026-11-12T13:14:15Z";
|
|
|
|
const USER: UserDetail = {
|
|
id: "u-1",
|
|
username: "steve",
|
|
email: "[email protected]",
|
|
role: "user",
|
|
disabled: false,
|
|
email_verified: true,
|
|
server_count: 0,
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
linked_accounts: [{ mc_uuid: "069a79f4-44e9-4726-a5be-fca90e38aaf5", auth_source: "thirdparty", verified_at: VERIFIED }],
|
|
};
|
|
|
|
function renderPage(id = "u-1") {
|
|
return render(
|
|
<MemoryRouter initialEntries={[`/admin/users/${id}`]}>
|
|
<Routes>
|
|
<Route path="/admin/users/:id" element={<UserDetailPage />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
for (const fn of Object.values(calls)) fn.mockReset();
|
|
calls.getUserQuotas.mockResolvedValue({ user_id: "u-1" });
|
|
calls.listUserSessions.mockResolvedValue([]);
|
|
});
|
|
afterEach(() => i18next.changeLanguage("en-US"));
|
|
|
|
describe("UserDetailPage", () => {
|
|
it("says the user is gone and leads back to the list, without the server's English", async () => {
|
|
calls.getUser.mockRejectedValue({ status: 404, code: "not_found", message: "user not found" });
|
|
await i18next.changeLanguage("zh-CN");
|
|
renderPage();
|
|
|
|
expect(await screen.findByText("用户不存在")).toBeTruthy();
|
|
expect(screen.getByRole("link", { name: "返回用户列表" }).getAttribute("href")).toBe("/admin/users");
|
|
expect(screen.queryByRole("button", { name: i18next.t("common:try_again") })).toBeNull();
|
|
expect(document.body.textContent).not.toMatch(/user not found/i);
|
|
});
|
|
|
|
it("keeps the retry for a failure that is not a missing user", async () => {
|
|
calls.getUser.mockRejectedValue({ status: 409, code: "test", message: "backend refused" });
|
|
renderPage();
|
|
|
|
expect((await screen.findByRole("alert")).textContent).toBe("backend refused");
|
|
expect(screen.getByRole("button", { name: i18next.t("common:try_again") })).toBeTruthy();
|
|
expect(screen.queryByText(i18next.t("admin:user_not_found_title"))).toBeNull();
|
|
});
|
|
|
|
it("names the auth source and writes dates in the UI language", async () => {
|
|
calls.getUser.mockResolvedValue(USER);
|
|
calls.listUserSessions.mockResolvedValue([
|
|
{
|
|
token_hash: "abcdef0123456789abcdef0123456789",
|
|
created_at: VERIFIED,
|
|
expires_at: EXPIRES,
|
|
last_seen_at: VERIFIED,
|
|
user_agent: "",
|
|
client_ip: "",
|
|
},
|
|
]);
|
|
await i18next.changeLanguage("zh-CN");
|
|
renderPage();
|
|
|
|
const zhVerified = new Date(VERIFIED).toLocaleString("zh-CN");
|
|
const zhExpires = new Date(EXPIRES).toLocaleString("zh-CN");
|
|
expect(zhVerified).not.toBe(new Date(VERIFIED).toLocaleString("en-US"));
|
|
|
|
expect(await screen.findByText(`第三方 Yggdrasil · ${zhVerified}`)).toBeTruthy();
|
|
expect(await screen.findByText(zhExpires, { exact: false })).toBeTruthy();
|
|
});
|
|
|
|
describe("sessions card", () => {
|
|
const seen = new Date(Date.now() - 2 * 3_600_000).toISOString();
|
|
const phone = {
|
|
token_hash: "h-phone",
|
|
created_at: VERIFIED,
|
|
expires_at: EXPIRES,
|
|
last_seen_at: seen,
|
|
user_agent:
|
|
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36",
|
|
client_ip: "198.51.100.9",
|
|
};
|
|
const pc = { ...phone, token_hash: "h-pc", user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Firefox/130.0", client_ip: "" };
|
|
|
|
it("names each device with its address and last activity", async () => {
|
|
calls.getUser.mockResolvedValue(USER);
|
|
calls.listUserSessions.mockResolvedValue([phone, pc]);
|
|
renderPage();
|
|
|
|
const label = await screen.findByText("Chrome on Android");
|
|
const row = label.closest("div.rounded-md") as HTMLElement;
|
|
expect(label.getAttribute("title")).toBe(phone.user_agent);
|
|
expect(within(row).getByText("198.51.100.9")).toBeTruthy();
|
|
expect(within(row).getByText("Active 2 hours ago")).toBeTruthy();
|
|
expect(screen.getByText("Firefox on Linux")).toBeTruthy();
|
|
expect(screen.getByRole("button", { name: "Revoke the session on Chrome on Android" })).toBeTruthy();
|
|
expect(screen.getByRole("button", { name: "Revoke the session on Firefox on Linux" })).toBeTruthy();
|
|
});
|
|
|
|
it("closes the confirmation and refreshes when the session had already ended", async () => {
|
|
calls.getUser.mockResolvedValue(USER);
|
|
calls.listUserSessions.mockResolvedValueOnce([phone, pc]).mockResolvedValue([pc]);
|
|
calls.revokeUserSession.mockRejectedValue({ status: 404, code: "session_not_found", message: "gone" });
|
|
renderPage();
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: "Revoke the session on Chrome on Android" }));
|
|
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Revoke" }));
|
|
|
|
expect(calls.revokeUserSession).toHaveBeenCalledWith("u-1", "h-phone");
|
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
await waitFor(() => expect(screen.queryByText("Chrome on Android")).toBeNull());
|
|
expect(screen.queryByText("That session has already ended.")).toBeNull();
|
|
});
|
|
|
|
it("shows why a revoke failed for any other reason", async () => {
|
|
calls.getUser.mockResolvedValue(USER);
|
|
calls.listUserSessions.mockResolvedValue([phone]);
|
|
calls.revokeUserSession.mockRejectedValue({ status: 409, code: "test", message: "backend refused" });
|
|
renderPage();
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: "Revoke the session on Chrome on Android" }));
|
|
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Revoke" }));
|
|
|
|
// Inside the dialog: the modal hides the card behind it.
|
|
const dialog = screen.getByRole("dialog");
|
|
expect((await within(dialog).findByRole("alert")).textContent).toBe("backend refused");
|
|
await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
|
expect(screen.getByText("Chrome on Android")).toBeTruthy();
|
|
|
|
// Asking again starts clean, without the last attempt's failure.
|
|
await userEvent.click(screen.getByRole("button", { name: "Revoke the session on Chrome on Android" }));
|
|
expect(within(screen.getByRole("dialog")).queryByRole("alert")).toBeNull();
|
|
});
|
|
|
|
it("shows why revoking every session failed inside the confirmation", async () => {
|
|
calls.getUser.mockResolvedValue(USER);
|
|
calls.listUserSessions.mockResolvedValue([phone]);
|
|
calls.revokeUserSessions.mockRejectedValue({ status: 409, code: "test", message: "backend refused" });
|
|
renderPage();
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: "Revoke All" }));
|
|
const dialog = screen.getByRole("dialog");
|
|
await userEvent.click(within(dialog).getByRole("button", { name: "Revoke" }));
|
|
|
|
expect((await within(dialog).findByRole("alert")).textContent).toBe("backend refused");
|
|
});
|
|
});
|
|
|
|
describe("danger zone for accounts the server protects", () => {
|
|
const SELF_REASON = "You can't disable or delete the account you're signed in with.";
|
|
const OWNER_REASON =
|
|
"The owner account can't be disabled or deleted from the panel. Only the host's break-glass console (sudo felis breakGlass) manages it.";
|
|
|
|
const button = (name: string) => screen.getByRole("button", { name }) as HTMLButtonElement;
|
|
const describedBy = (b: HTMLElement) =>
|
|
document.getElementById(b.getAttribute("aria-describedby") ?? "")?.textContent;
|
|
|
|
it("locks disable and delete on the signed-in owner's own row and says why", async () => {
|
|
calls.getUser.mockResolvedValue({ ...USER, id: "owner-1", username: "root", role: "owner" });
|
|
renderPage("owner-1");
|
|
|
|
expect(await screen.findByText(SELF_REASON)).toBeTruthy();
|
|
for (const name of ["Disable User", "Delete User"]) {
|
|
expect(button(name).disabled).toBe(true);
|
|
expect(describedBy(button(name))).toBe(SELF_REASON);
|
|
expect(button(name).parentElement?.getAttribute("title")).toBe(SELF_REASON);
|
|
}
|
|
expect(button("Unbind passkeys").disabled).toBe(false);
|
|
expect(screen.getByText("You can't change your own role.")).toBeTruthy();
|
|
expect(screen.queryByRole("combobox")).toBeNull();
|
|
});
|
|
|
|
it("locks disable and delete on another owner with the break-glass reason", async () => {
|
|
calls.getUser.mockResolvedValue({ ...USER, id: "owner-2", username: "co-owner", role: "owner" });
|
|
renderPage("owner-2");
|
|
|
|
expect(await screen.findByText(OWNER_REASON)).toBeTruthy();
|
|
expect(screen.queryByText(SELF_REASON)).toBeNull();
|
|
expect(button("Disable User").disabled).toBe(true);
|
|
expect(button("Delete User").disabled).toBe(true);
|
|
expect(describedBy(button("Delete User"))).toBe(OWNER_REASON);
|
|
expect(
|
|
screen.getByText(
|
|
"The owner's role is fixed. Only the host's break-glass console (sudo felis breakGlass) manages the owner.",
|
|
),
|
|
).toBeTruthy();
|
|
expect(screen.queryByRole("combobox")).toBeNull();
|
|
});
|
|
|
|
it("still lets a disabled owner be re-enabled", async () => {
|
|
calls.getUser.mockResolvedValue({ ...USER, id: "owner-2", role: "owner", disabled: true });
|
|
renderPage("owner-2");
|
|
|
|
expect((await screen.findByRole("button", { name: "Enable" }) as HTMLButtonElement).disabled).toBe(false);
|
|
expect(button("Enable").getAttribute("aria-describedby")).toBeNull();
|
|
expect(button("Delete User").disabled).toBe(true);
|
|
});
|
|
|
|
it("leaves every action open on an ordinary user", async () => {
|
|
calls.getUser.mockResolvedValue(USER);
|
|
renderPage();
|
|
|
|
expect((await screen.findByRole("button", { name: "Disable User" }) as HTMLButtonElement).disabled).toBe(false);
|
|
expect(button("Delete User").disabled).toBe(false);
|
|
expect(button("Delete User").parentElement?.getAttribute("title")).toBeNull();
|
|
expect(screen.queryByText(SELF_REASON)).toBeNull();
|
|
expect(screen.queryByText(OWNER_REASON)).toBeNull();
|
|
const role = screen.getByRole("combobox");
|
|
expect(within(role).getByText("User")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|