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.
217 lines
8.0 KiB
TypeScript
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();
|
|
});
|
|
});
|