/** * @file UserEditDialog.test.tsx * @description The admin's account editor: Save stays off until something * changed AND the admin typed their own password; one PATCH carries only the * changed fields (profile, role, disable, workspace add/remove, sign-out); an * admin editing their own row cannot touch role or state; Generate fills a * 16-character password. * @author Nguyễn Ngọc Trí Vĩ */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ServerUserUsage } from "../../../lib/api"; const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() })); const confirmDialog = vi.hoisted(() => vi.fn()); vi.mock("../../../lib/confirm", () => ({ confirmDialog })); const lanes = vi.hoisted(() => ({ list: vi.fn() })); vi.mock("../../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, api: { users, lanes } }; }); import { UserEditDialog, generatePassword } from "../UserEditDialog"; const usage = { cost: 0, tokens: 0 }; const mia: ServerUserUsage = { id: 2, username: "mia", role: "developer", disabled: false, displayName: null, email: null, createdAt: "2026-10-01T00:00:00.000Z", workspaces: 1, sessions: 3, last7d: usage, last30d: usage, logins: 2, lastLoginAt: null, }; function open(user = mia, self = false) { const onDone = vi.fn(); const onClose = vi.fn(); render( ); return { onDone, onClose }; } const save = () => screen.getByRole("button", { name: "Save" }); beforeEach(() => { users.get.mockReset().mockResolvedValue({ user: mia, workspaces: [{ id: 5, title: "api", cwd: "/w/api" }], }); users.update.mockReset().mockResolvedValue({ user: mia }); lanes.list.mockReset().mockResolvedValue({ lanes: [ { id: 5, title: "api", cwd: "/w/api", owner_id: 2 }, { id: 7, title: "web", cwd: "/w/web", owner_id: null }, ], }); }); describe("UserEditDialog", () => { it("needs a change and the admin's own password before Save", async () => { const ue = userEvent.setup(); open(); await screen.findByText("api"); expect(save().hasAttribute("disabled")).toBe(true); await ue.type(screen.getByLabelText("Your password"), "boss-pass"); expect(save().hasAttribute("disabled")).toBe(true); // nothing changed yet await ue.type(screen.getByLabelText("Display name"), "Mia"); expect(save().hasAttribute("disabled")).toBe(false); }); it("sends one PATCH with only what changed", async () => { const ue = userEvent.setup(); const { onDone } = open(); await screen.findByText("api"); await ue.type(screen.getByLabelText("Email"), "mia@x.vn"); await ue.selectOptions(screen.getByLabelText("Role"), "analyst"); await ue.click(screen.getByRole("switch", { name: "Active" })); await ue.click(screen.getByRole("button", { name: "Remove api" })); await ue.selectOptions(screen.getByLabelText("Add workspace"), "7"); await ue.click(screen.getByRole("button", { name: "Add workspace" })); await ue.click(screen.getByRole("checkbox", { name: "Sign out everywhere" })); await ue.type(screen.getByLabelText("Your password"), "boss-pass"); await ue.click(save()); await waitFor(() => expect(users.update).toHaveBeenCalledWith( 2, { email: "mia@x.vn", role: "analyst", disabled: true, addWorkspaces: [7], removeWorkspaces: [5], signOut: true, }, "boss-pass" ) ); expect(onDone).toHaveBeenCalledWith("mia updated"); }); it("never lets an admin change their own role or disable themselves", async () => { open({ ...mia, role: "admin" }, true); await screen.findByText("api"); expect(screen.getByLabelText("Role").hasAttribute("disabled")).toBe(true); expect(screen.getByRole("switch", { name: "Active" }).hasAttribute("disabled")).toBe(true); }); it("accepts a short new password: there is no minimum length", async () => { const ue = userEvent.setup(); open(); await screen.findByText("api"); await ue.type(screen.getByLabelText("New password"), "abc"); await ue.type(screen.getByLabelText("Your password"), "boss-pass"); expect(save().hasAttribute("disabled")).toBe(false); }); it("deletes a user after the admin's password and a red confirm that says what goes", async () => { users.remove.mockReset().mockResolvedValue({ user: mia }); confirmDialog.mockReset().mockResolvedValue(true); const ue = userEvent.setup(); const { onDone } = open(); await screen.findByText("api"); // No password yet: asked for it, nothing sent. await ue.click(screen.getByRole("button", { name: "Delete user" })); expect(await screen.findByRole("alert")).toHaveTextContent("Enter your own password"); expect(users.remove).not.toHaveBeenCalled(); await ue.type(screen.getByLabelText("Your password"), "boss-pass"); await ue.click(screen.getByRole("button", { name: "Delete user" })); expect(confirmDialog).toHaveBeenCalledWith( expect.objectContaining({ destructive: true, message: expect.stringContaining("/srv/agenthub/users/mia"), }) ); await waitFor(() => expect(users.remove).toHaveBeenCalledWith(2, "boss-pass")); expect(onDone).toHaveBeenCalled(); }); it("sends nothing when the confirm is cancelled, and offers no delete for yourself", async () => { users.remove.mockReset(); confirmDialog.mockReset().mockResolvedValue(false); const ue = userEvent.setup(); open(); await screen.findByText("api"); await ue.type(screen.getByLabelText("Your password"), "boss-pass"); await ue.click(screen.getByRole("button", { name: "Delete user" })); expect(users.remove).not.toHaveBeenCalled(); }); it("has no Delete button on your own account", async () => { open(mia, true); await screen.findByText("api"); expect(screen.queryByRole("button", { name: "Delete user" })).toBeNull(); }); it("generates a 16-character password", async () => { expect(generatePassword()).toHaveLength(16); const ue = userEvent.setup(); open(); await screen.findByText("api"); await ue.click(screen.getByRole("button", { name: /Generate/ })); expect((screen.getByLabelText("New password") as HTMLInputElement).value).toHaveLength(16); }); });