// @vitest-environment jsdom // // The Access-requests panel's delete affordance and its three outcomes. The control's whole point is // that it explains itself: an entry backed by an account keeps a focusable, labelled control instead // of quietly missing one, and an address the server's env still allow-lists gets a warning BEFORE the // irreversible click, not a correction after it. // // i18n is a key-passthrough so assertions read stable keys; the API and the confirm/notify // singletons are stubbed so the panel can be driven without a server. import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ // Echo the interpolation too, so a message that forgets its {{email}} is visible in the test. t: (key: string, vars?: Record) => vars?.email ? `${key}:${String(vars.email)}` : key, i18n: { language: "en" }, }), }; }); type ConfirmOpts = { title?: string; message?: string; confirmLabel?: string; danger?: boolean }; const confirmMock = vi.fn(async (_opts: ConfirmOpts) => true); vi.mock("./ConfirmProvider", () => ({ useConfirm: () => confirmMock, ConfirmProvider: ({ children }: { children: React.ReactNode }) => children, })); const notifyMock = vi.fn(); vi.mock("../lib/notifications", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) }; }); const meMock = { pending_invites: 0, email_configured: true, id: 1, role: "admin" }; vi.mock("../lib/useMe", () => ({ useMe: () => meMock })); // The tab strip is rendered into an app-level toolbar slot that does not exist here, so pin the // active tab directly — this file is about the Access-requests panel, not the tab chrome. vi.mock("./Tabs", () => ({ default: () => null, usePersistedTab: () => ["access", () => {}], })); const listMock = vi.fn(); const deleteMock = vi.fn(); vi.mock("../lib/api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, api: { ...actual.api, adminInvites: () => listMock(), deleteInvite: (id: number) => deleteMock(id), }, }; }); import { HttpError } from "../lib/api"; import AdminUsers from "./AdminUsers"; const invite = (over: Partial> = {}) => ({ id: 1, email: "asker@example.com", status: "pending", note: null, requested_at: null, decided_at: null, decided_by: null, has_account: false, still_allowed_by_env: false, ...over, }); const renderPanel = () => { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( , ); }; beforeEach(() => { confirmMock.mockClear().mockResolvedValue(true); notifyMock.mockClear(); deleteMock.mockReset().mockResolvedValue({ deleted: 1, still_allowed_by_env: false }); listMock.mockReset().mockResolvedValue([invite()]); meMock.email_configured = true; }); afterEach(cleanup); const openAccessTab = async () => { await screen.findByText("settings.invites.title"); }; describe("Access requests — the delete affordance", () => { it("offers a labelled delete on an entry with no account behind it", async () => { renderPanel(); await openAccessTab(); const btn = await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"); expect(btn.getAttribute("aria-disabled")).toBeNull(); }); it("keeps the control present and focusable — not missing — when the address has an account", async () => { listMock.mockResolvedValue([invite({ has_account: true })]); renderPanel(); await openAccessTab(); const btn = await screen.findByLabelText("settings.invites.deleteHasAccount"); // aria-disabled, not `disabled`: a disabled button leaves the tab order and takes its // explanation with it, which is the whole reason the control is shown at all. expect(btn.getAttribute("aria-disabled")).toBe("true"); expect((btn as HTMLButtonElement).disabled).toBe(false); expect(btn.getAttribute("title")).toBe("settings.invites.deleteHasAccount"); expect(screen.queryByLabelText(/deleteLabel/)).toBeNull(); }); it("keeps the same control on the previously-decided list, where the case is commonest", async () => { // The affordance exists twice — the pending rows and the collapsed "decided" list — and an // approved address that has since registered lands in the second one. Both must behave alike. listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]); renderPanel(); await openAccessTab(); fireEvent.click(await screen.findByText(/settings\.invites\.decided/)); const btn = await screen.findByLabelText("settings.invites.deleteHasAccount"); expect(btn.getAttribute("aria-disabled")).toBe("true"); expect((btn as HTMLButtonElement).disabled).toBe(false); }); it("offers a working delete on a decided entry with no account", async () => { listMock.mockResolvedValue([invite({ status: "denied" })]); renderPanel(); await openAccessTab(); fireEvent.click(await screen.findByText(/settings\.invites\.decided/)); fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com")); await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(1)); }); it("warns in the confirmation — before the click is irreversible — when the env still allows it", async () => { listMock.mockResolvedValue([invite({ still_allowed_by_env: true })]); renderPanel(); await openAccessTab(); fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com")); expect(confirmMock).toHaveBeenCalledTimes(1); expect(confirmMock.mock.calls[0]![0]).toMatchObject({ message: "settings.invites.deleteConfirmStillAllowed:asker@example.com", danger: true, }); }); it("uses the plain confirmation otherwise", async () => { renderPanel(); await openAccessTab(); fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com")); expect(confirmMock.mock.calls[0]![0]).toMatchObject({ message: "settings.invites.deleteConfirm:asker@example.com", }); }); it("says the entry kept its access when the server reports the env allow-list", async () => { deleteMock.mockResolvedValue({ deleted: 1, still_allowed_by_env: true }); renderPanel(); await openAccessTab(); fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com")); await waitFor(() => expect(notifyMock).toHaveBeenCalledWith({ level: "warning", message: "settings.invites.deletedStillAllowed", }), ); }); it("turns a 409 into the actionable reason rather than a generic failure", async () => { // The stale-list case: someone registered on the address after the page loaded. deleteMock.mockRejectedValue(new HttpError(409, "That address already has an account")); renderPanel(); await openAccessTab(); fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com")); await waitFor(() => expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "settings.invites.deleteHasAccount", }), ); }); it("shows the mail-is-off warning only while email is unconfigured", async () => { renderPanel(); await openAccessTab(); expect(screen.queryByText("settings.invites.emailOff")).toBeNull(); cleanup(); meMock.email_configured = false; renderPanel(); await openAccessTab(); expect(await screen.findByText("settings.invites.emailOff")).toBeTruthy(); }); });