Files
siftlode/frontend/src/components/adminInvites.dom.test.tsx
T
peter 2cba6b9fca docs: round-3 copy corrections, and pin the two behaviours nothing was guarding
Round 3's correctness/security pass came back clean; what was left said more than
the code does.

- emailOff promised the access-request list is where sign-ups show up. A sign-up
  on an address that is already approved or already pending adds nothing to it
  (roadmap UX-25), and "only" was falsified by this release's own inbox notice.
- deleteConfirm claimed the admin was dropping "an approval you already gave" —
  it is the confirmation for pending and denied rows too.
- sendFailed blamed the mail server for rejecting a message; the reason covers
  connect, DNS, TLS and login failures alike, so it now describes the symptom and
  leaves the diagnosis to the log.
- ADMIN_EMAILS re-promotion happens in the Google callback only, not on any
  sign-in.

Tests: the decided-list copy of the delete affordance was never rendered (both
new cases fail if that branch is dropped), and the throttle release — a
deliberate behaviour the notes advertise — was asserted by nothing; a drop with
no admin yet, or with a failing write, must not silence the following hour.
2026-09-02 00:06:01 +02:00

217 lines
8.0 KiB
TypeScript

// @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<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
// Echo the interpolation too, so a message that forgets its {{email}} is visible in the test.
t: (key: string, vars?: Record<string, unknown>) =>
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<typeof import("../lib/notifications")>();
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<typeof import("../lib/api")>();
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<Record<string, unknown>> = {}) => ({
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(
<QueryClientProvider client={qc}>
<AdminUsers />
</QueryClientProvider>,
);
};
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();
});
});