mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
Merge pull request #36333 from BerriAI/litellm_/elated-bhaskara-739752
fix(ui): hide admin-only Logs tabs from roles that cannot call their endpoints
This commit is contained in:
commit
3e680a4ffc
5 changed files with 254 additions and 35 deletions
|
|
@ -0,0 +1,104 @@
|
|||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import SpendLogsTable from "./index";
|
||||
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
|
||||
|
||||
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: useAuthorizedMock,
|
||||
}));
|
||||
|
||||
vi.mock("./RequestLogsPanel", () => ({
|
||||
default: function RequestLogsPanelMock() {
|
||||
return <div data-testid="request-logs-panel" />;
|
||||
},
|
||||
}));
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const jsonResponse = (body: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
|
||||
|
||||
const emptyAuditLogs = { audit_logs: [], total: 0, page: 1, page_size: 50, total_pages: 0 };
|
||||
|
||||
const defaultProps = {
|
||||
accessToken: "sk-test",
|
||||
token: "jwt-test",
|
||||
userRole: "Admin",
|
||||
userID: "user-1",
|
||||
premiumUser: true,
|
||||
};
|
||||
|
||||
const renderAs = (sessionRole: string) => {
|
||||
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userRole: sessionRole, premiumUser: true });
|
||||
return renderWithProviders(<SpendLogsTable {...defaultProps} userRole={sessionRole} />);
|
||||
};
|
||||
|
||||
describe("SpendLogsTable network access by role", () => {
|
||||
beforeEach(() => {
|
||||
testQueryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (String(url).includes("/audit")) {
|
||||
return jsonResponse(emptyAuditLogs);
|
||||
}
|
||||
if (String(url).includes("/v2/team/list")) {
|
||||
return jsonResponse({ teams: [] });
|
||||
}
|
||||
return jsonResponse({ keys: [], total_count: 0 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("fires neither the audit nor the deleted-teams request for an internal user", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAs("Internal User");
|
||||
|
||||
// Liveness gate: the sibling Deleted Keys panel does reach the network, so a
|
||||
// silent absence below means the gate worked, not that nothing rendered.
|
||||
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/key/list"))).toBe(true));
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Deleted Keys" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Request Logs" }));
|
||||
|
||||
expect(requestedUrls().filter((url) => url.includes("/audit"))).toEqual([]);
|
||||
expect(requestedUrls().filter((url) => url.includes("/v2/team/list"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("fetches deleted teams and audit logs for an admin", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAs("Admin");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(requestedUrls().some((url) => url.includes("/v2/team/list") && url.includes("status=deleted"))).toBe(true),
|
||||
);
|
||||
|
||||
expect(requestedUrls().filter((url) => url.includes("/audit"))).toEqual([]);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Audit Logs" }));
|
||||
|
||||
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/audit"))).toBe(true));
|
||||
});
|
||||
|
||||
it("leaves the audit request unsent when an admin selects a tab after Audit Logs", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAs("Admin");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Deleted Teams" }));
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Deleted Teams" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(requestedUrls().filter((url) => url.includes("/audit"))).toEqual([]);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Audit Logs" }));
|
||||
|
||||
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/audit"))).toBe(true));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import SpendLogsTable from "./index";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
|
||||
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: useAuthorizedMock,
|
||||
}));
|
||||
|
||||
vi.mock("./RequestLogsPanel", () => ({
|
||||
default: function RequestLogsPanelMock({ isActive }: { isActive: boolean }) {
|
||||
return <div data-testid="request-logs-panel">{isActive ? "active" : "inactive"}</div>;
|
||||
|
|
@ -36,9 +42,18 @@ const defaultProps = {
|
|||
premiumUser: false,
|
||||
};
|
||||
|
||||
const renderAs = (sessionRole: string) => {
|
||||
useAuthorizedMock.mockReturnValue({ userRole: sessionRole });
|
||||
return renderWithProviders(<SpendLogsTable {...defaultProps} userRole={sessionRole} />);
|
||||
};
|
||||
|
||||
describe("SpendLogsTable", () => {
|
||||
beforeEach(() => {
|
||||
useAuthorizedMock.mockReturnValue({ userRole: "Admin" });
|
||||
});
|
||||
|
||||
it("renders the four log tabs", () => {
|
||||
renderWithProviders(<SpendLogsTable {...defaultProps} />);
|
||||
renderAs("Admin");
|
||||
|
||||
for (const label of ["Request Logs", "Audit Logs", "Deleted Keys", "Deleted Teams"]) {
|
||||
expect(screen.getByRole("tab", { name: label })).toBeInTheDocument();
|
||||
|
|
@ -47,7 +62,7 @@ describe("SpendLogsTable", () => {
|
|||
|
||||
it("marks only the visible tab's panel active so background tabs do not query", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SpendLogsTable {...defaultProps} />);
|
||||
renderAs("Admin");
|
||||
|
||||
expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("active");
|
||||
|
||||
|
|
@ -57,8 +72,64 @@ describe("SpendLogsTable", () => {
|
|||
expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive");
|
||||
});
|
||||
|
||||
describe("admin-only tabs", () => {
|
||||
it.each(["Internal User", "Internal Viewer"])("hides Audit Logs and Deleted Teams from %s", (role) => {
|
||||
renderAs(role);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Request Logs" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Deleted Keys" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "Audit Logs" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "Deleted Teams" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never mounts the panels that call the admin-only endpoints for an internal user", () => {
|
||||
renderAs("Internal User");
|
||||
|
||||
expect(screen.queryByTestId("audit-logs-panel")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("deleted-teams-page")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("deleted-keys-page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tab index mapping", () => {
|
||||
it("activates the panel the admin selected, not the one at the old hardcoded index", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAs("Admin");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Deleted Keys" }));
|
||||
|
||||
expect(screen.getByTestId("audit-logs-panel")).toHaveTextContent("inactive");
|
||||
expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive");
|
||||
});
|
||||
|
||||
it("keeps the audit panel inert when an admin selects the last tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAs("Admin");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Deleted Teams" }));
|
||||
|
||||
expect(screen.getByTestId("audit-logs-panel")).toHaveTextContent("inactive");
|
||||
expect(screen.getByTestId("deleted-teams-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects the last visible tab for an internal user and returns to Request Logs", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAs("Internal User");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Deleted Keys" }));
|
||||
|
||||
expect(screen.getByTestId("deleted-keys-page")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Request Logs" }));
|
||||
|
||||
expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth-not-ready guard", () => {
|
||||
it("shows a loading spinner when credentials are not yet resolved", () => {
|
||||
useAuthorizedMock.mockReturnValue({ userRole: "Admin" });
|
||||
renderWithProviders(<SpendLogsTable {...defaultProps} accessToken={null} />);
|
||||
|
||||
expect(document.querySelector(".ant-spin")).toBeInTheDocument();
|
||||
|
|
@ -66,7 +137,7 @@ describe("SpendLogsTable", () => {
|
|||
});
|
||||
|
||||
it("renders the tabs (no spinner) once all credentials are present", () => {
|
||||
renderWithProviders(<SpendLogsTable {...defaultProps} />);
|
||||
renderAs("Admin");
|
||||
|
||||
expect(document.querySelector(".ant-spin")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Request Logs" })).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage";
|
||||
import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage";
|
||||
import AuditLogsPanel from "./AuditLogsPanel";
|
||||
|
|
@ -14,8 +15,22 @@ interface SpendLogsTableProps {
|
|||
premiumUser: boolean;
|
||||
}
|
||||
|
||||
type LogsTabId = "request logs" | "audit logs" | "deleted keys" | "deleted teams";
|
||||
|
||||
interface LogsTab {
|
||||
id: LogsTabId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const REQUEST_LOGS_TAB: LogsTab = { id: "request logs", label: "Request Logs" };
|
||||
const AUDIT_LOGS_TAB: LogsTab = { id: "audit logs", label: "Audit Logs" };
|
||||
const DELETED_KEYS_TAB: LogsTab = { id: "deleted keys", label: "Deleted Keys" };
|
||||
const DELETED_TEAMS_TAB: LogsTab = { id: "deleted teams", label: "Deleted Teams" };
|
||||
|
||||
export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) {
|
||||
const [activeTab, setActiveTab] = useState("request logs");
|
||||
const [activeTab, setActiveTab] = useState<LogsTabId>(REQUEST_LOGS_TAB.id);
|
||||
const canViewAuditLogs = useCan("viewAuditLogs");
|
||||
const canViewDeletedTeams = useCan("viewDeletedTeams");
|
||||
|
||||
if (!accessToken || !token || !userRole || !userID) {
|
||||
return (
|
||||
|
|
@ -25,41 +40,55 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
|
|||
);
|
||||
}
|
||||
|
||||
const tabs: LogsTab[] = [
|
||||
REQUEST_LOGS_TAB,
|
||||
...(canViewAuditLogs ? [AUDIT_LOGS_TAB] : []),
|
||||
DELETED_KEYS_TAB,
|
||||
...(canViewDeletedTeams ? [DELETED_TEAMS_TAB] : []),
|
||||
];
|
||||
|
||||
const renderPanel = (tabId: LogsTabId) => {
|
||||
switch (tabId) {
|
||||
case "request logs":
|
||||
return (
|
||||
<RequestLogsPanel
|
||||
accessToken={accessToken}
|
||||
token={token}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
isActive={activeTab === "request logs"}
|
||||
/>
|
||||
);
|
||||
case "audit logs":
|
||||
return (
|
||||
<AuditLogsPanel
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
token={token}
|
||||
accessToken={accessToken}
|
||||
isActive={activeTab === "audit logs"}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
);
|
||||
case "deleted keys":
|
||||
return <DeletedKeysPage />;
|
||||
case "deleted teams":
|
||||
return <DeletedTeamsPage />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full p-6 overflow-x-hidden box-border">
|
||||
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "request logs" : "audit logs")}>
|
||||
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(tabs[index].id)}>
|
||||
<TabList>
|
||||
<Tab>Request Logs</Tab>
|
||||
<Tab>Audit Logs</Tab>
|
||||
<Tab>Deleted Keys</Tab>
|
||||
<Tab>Deleted Teams</Tab>
|
||||
{tabs.map((tab) => (
|
||||
<Tab key={tab.id}>{tab.label}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<RequestLogsPanel
|
||||
accessToken={accessToken}
|
||||
token={token}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
isActive={activeTab === "request logs"}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<AuditLogsPanel
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
token={token}
|
||||
accessToken={accessToken}
|
||||
isActive={activeTab === "audit logs"}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<DeletedKeysPage />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<DeletedTeamsPage />
|
||||
</TabPanel>
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.id}>{renderPanel(tab.id)}</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,19 @@ describe("hasCapability", () => {
|
|||
);
|
||||
});
|
||||
|
||||
describe.each(["viewAuditLogs", "viewDeletedTeams"] as const)("hasCapability - %s", (capability) => {
|
||||
it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])("should grant it to %s", (role) => {
|
||||
expect(hasCapability(role, capability)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["Internal User", "Internal Viewer", "App User", "Org Admin", "Unknown Role", "", null, undefined])(
|
||||
"should deny it to %s",
|
||||
(role) => {
|
||||
expect(hasCapability(role, capability)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("rolesWithCapability", () => {
|
||||
it("should return a copy so callers cannot mutate the capability map", () => {
|
||||
const roles = rolesWithCapability("viewToolPolicies");
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { all_admin_roles } from "./roles";
|
|||
|
||||
const CAPABILITY_ROLES = {
|
||||
viewToolPolicies: all_admin_roles,
|
||||
viewAuditLogs: all_admin_roles,
|
||||
viewDeletedTeams: all_admin_roles,
|
||||
} as const satisfies Record<string, readonly string[]>;
|
||||
|
||||
export type Capability = keyof typeof CAPABILITY_ROLES;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue