fix(admin): protect administrator access
Reject final-admin demotion and deletion at the API boundary. Require explicit confirmation before any administrator role removal.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { CssVarsProvider } from "@mui/material/styles";
|
||||
|
||||
import { api } from "./api";
|
||||
import { ConfirmProvider } from "./confirm";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { PromptProvider } from "./prompt";
|
||||
import { getTheme } from "./theme";
|
||||
import { ToastProvider } from "./toast";
|
||||
import AdminUsersPage from "./views/AdminUsersPage";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: { get: jest.fn(), put: jest.fn(), post: jest.fn(), delete: jest.fn() },
|
||||
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPage(users: unknown[]) {
|
||||
mockedApi.get.mockResolvedValue({ data: users } as any);
|
||||
mockedApi.put.mockResolvedValue({ data: null } as any);
|
||||
render(
|
||||
<CssVarsProvider theme={getTheme("light") as any} defaultMode="light">
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<AdminUsersPage />
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
</CssVarsProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
test("requires explicit confirmation before removing your own admin role", async () => {
|
||||
renderPage([{ id: "me", email: "me@example.com", userName: "me", roles: ["Admin"], emailConfirmed: true, isCurrentUser: true, canRemoveAdmin: true }]);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Remove admin" }));
|
||||
expect(await screen.findByText(/you will immediately lose access/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove admin" }));
|
||||
fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Remove admin" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/users/me/roles", { roles: [] }));
|
||||
});
|
||||
|
||||
test("warns before demoting another administrator", async () => {
|
||||
renderPage([{ id: "other", email: "other@example.com", userName: "other", roles: ["Admin"], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Remove admin" }));
|
||||
|
||||
expect(await screen.findByText(/they will immediately lose access/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("disables demotion and deletion for the final administrator", async () => {
|
||||
renderPage([{ id: "me", email: "me@example.com", userName: "me", roles: ["Admin"], emailConfirmed: true, isCurrentUser: true, canRemoveAdmin: false }]);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Remove admin" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||
});
|
||||
Reference in New Issue
Block a user