mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #36475 from BerriAI/litellm_/epic-turing-e9b2f0
fix(ui): gate four sidebar pages on the roles their endpoints allow
This commit is contained in:
commit
4e489a5ed1
15 changed files with 515 additions and 71 deletions
|
|
@ -1,5 +1,11 @@
|
|||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: useAuthorizedMock,
|
||||
}));
|
||||
|
||||
vi.mock("./UsageTab", () => ({ __esModule: true, default: () => <div data-testid="usage-tab" /> }));
|
||||
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div data-testid="compression-tab" /> }));
|
||||
|
|
@ -11,9 +17,16 @@ vi.mock("./AutoRouterBenchmarksTab", () => ({
|
|||
|
||||
import CostOptimizationView from "./CostOptimizationView";
|
||||
|
||||
const renderView = () => render(<CostOptimizationView accessToken="test-token" userId="u1" userRole="proxy_admin" />);
|
||||
const renderView = (userRole = "Admin") => {
|
||||
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole });
|
||||
return render(<CostOptimizationView accessToken="test-token" userId="u1" userRole={userRole} />);
|
||||
};
|
||||
|
||||
describe("CostOptimizationView", () => {
|
||||
beforeEach(() => {
|
||||
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "Admin" });
|
||||
});
|
||||
|
||||
it("renders the four cost-optimization tabs", () => {
|
||||
const { getByText } = renderView();
|
||||
|
||||
|
|
@ -34,4 +47,29 @@ describe("CostOptimizationView", () => {
|
|||
expect(getByRole("tab", { name: "Overall" })).toHaveAttribute("aria-selected", "false");
|
||||
expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
// Unlike the other three pages in this cleanup, Cost Optimization keeps its
|
||||
// nav entry for internal users: the Overall tab runs on /user/daily/activity,
|
||||
// which every role may call. Only the tabs reading proxy-wide config and
|
||||
// telemetry (/config/list, /auto_router/benchmarks, guardrail management)
|
||||
// are proxy-admin-only, so those are what disappear.
|
||||
describe("proxy-admin-only tabs", () => {
|
||||
it.each(["Internal User", "Internal Viewer", "Org Admin"])("shows %s the Overall tab only", (userRole) => {
|
||||
const { getByRole, queryByRole } = renderView(userRole);
|
||||
|
||||
expect(getByRole("tab", { name: "Overall" })).toBeInTheDocument();
|
||||
expect(queryByRole("tab", { name: "Prompt Compression" })).not.toBeInTheDocument();
|
||||
expect(queryByRole("tab", { name: "Prompt Caching" })).not.toBeInTheDocument();
|
||||
expect(queryByRole("tab", { name: "Auto-Router" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never mounts the panels behind the admin-only endpoints for an internal user", () => {
|
||||
const { getByTestId, queryByTestId } = renderView("Internal User");
|
||||
|
||||
expect(getByTestId("usage-tab")).toBeInTheDocument();
|
||||
expect(queryByTestId("compression-tab")).not.toBeInTheDocument();
|
||||
expect(queryByTestId("caching-tab")).not.toBeInTheDocument();
|
||||
expect(queryByTestId("autorouter-benchmarks-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import React from "react";
|
|||
import { PiggyBank } from "lucide-react";
|
||||
import { Alert, Tabs } from "antd";
|
||||
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
import UsageTab from "./UsageTab";
|
||||
import PromptCompressionTab from "./PromptCompressionTab";
|
||||
import PromptCachingTab from "./PromptCachingTab";
|
||||
|
|
@ -18,6 +19,7 @@ interface CostOptimizationViewProps {
|
|||
|
||||
const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken, userId, userRole }) => {
|
||||
const activity = useDailyActivityRange(accessToken, userId, userRole);
|
||||
const canViewProxyWideCostData = useCan("viewProxyWideCostData");
|
||||
|
||||
const items = [
|
||||
{
|
||||
|
|
@ -25,21 +27,25 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
|
|||
label: "Overall",
|
||||
children: <UsageTab accessToken={accessToken} activity={activity} />,
|
||||
},
|
||||
{
|
||||
key: "compression",
|
||||
label: "Prompt Compression",
|
||||
children: <PromptCompressionTab accessToken={accessToken} />,
|
||||
},
|
||||
{
|
||||
key: "caching",
|
||||
label: "Prompt Caching",
|
||||
children: <PromptCachingTab accessToken={accessToken} activity={activity} />,
|
||||
},
|
||||
{
|
||||
key: "autorouter-usage",
|
||||
label: "Auto-Router",
|
||||
children: <AutoRouterBenchmarksTab accessToken={accessToken} />,
|
||||
},
|
||||
...(canViewProxyWideCostData
|
||||
? [
|
||||
{
|
||||
key: "compression",
|
||||
label: "Prompt Compression",
|
||||
children: <PromptCompressionTab accessToken={accessToken} />,
|
||||
},
|
||||
{
|
||||
key: "caching",
|
||||
label: "Prompt Caching",
|
||||
children: <PromptCachingTab accessToken={accessToken} activity={activity} />,
|
||||
},
|
||||
{
|
||||
key: "autorouter-usage",
|
||||
label: "Auto-Router",
|
||||
children: <AutoRouterBenchmarksTab accessToken={accessToken} />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
|
|||
|
||||
const mockGetToolSpend = vi.fn();
|
||||
|
||||
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: useAuthorizedMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getToolSpend: (...args: unknown[]) => mockGetToolSpend(...args),
|
||||
}));
|
||||
|
|
@ -88,11 +94,18 @@ interface RenderOptions {
|
|||
toolSpend?: ToolSpendResponse;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
const renderWith = (results: DailyData[], options: RenderOptions = {}) => {
|
||||
const { toolSpend = emptyToolSpend, from = new Date(2026, 6, 1), to = new Date(2026, 6, 14) } = options;
|
||||
const {
|
||||
toolSpend = emptyToolSpend,
|
||||
from = new Date(2026, 6, 1),
|
||||
to = new Date(2026, 6, 14),
|
||||
userRole = "Admin",
|
||||
} = options;
|
||||
mockGetToolSpend.mockResolvedValue(toolSpend);
|
||||
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole });
|
||||
return render(
|
||||
<UsageTab
|
||||
accessToken="test-token"
|
||||
|
|
@ -374,4 +387,38 @@ describe("UsageTab", () => {
|
|||
const toolLegends = getAllByTestId("chart-legend").filter((legend) => legend.textContent === "search,read_file");
|
||||
expect(toolLegends).toHaveLength(1);
|
||||
});
|
||||
|
||||
// `/v1/tool/spend` is proxy-admin-only while the daily-activity charts around
|
||||
// it are not, so this one card is dropped rather than the whole tab.
|
||||
describe("proxy-admin-only spend-by-tool card", () => {
|
||||
const toolSpend = {
|
||||
by_tool: [{ tool_name: "search", spend: 4.0, call_count: 3, total_tokens: 150 }],
|
||||
daily: [{ date: "2026-07-12", tool_name: "search", spend: 4.0, call_count: 3 }],
|
||||
start_date: "2026-07-12",
|
||||
end_date: "2026-07-12",
|
||||
};
|
||||
|
||||
it.each(["Internal User", "Internal Viewer", "Org Admin"])(
|
||||
"hides the card and never calls the endpoint for %s",
|
||||
async (userRole) => {
|
||||
const { queryByText, getByTestId } = renderWith([day("2026-07-12", { compression_savings_spend: 0.04 })], {
|
||||
toolSpend,
|
||||
userRole,
|
||||
});
|
||||
|
||||
// Liveness gate: the daily-activity charts still render for this role,
|
||||
// so the absence below is the gate, not an empty tab.
|
||||
expect(getByTestId("donut-chart")).toBeInTheDocument();
|
||||
expect(queryByText("Spend by tool")).not.toBeInTheDocument();
|
||||
await vi.waitFor(() => expect(mockGetToolSpend).not.toHaveBeenCalled());
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the card and the endpoint call for an admin", async () => {
|
||||
const { findByText } = renderWith([day("2026-07-12", { compression_savings_spend: 0.04 })], { toolSpend });
|
||||
|
||||
expect(await findByText("Spend by tool")).toBeInTheDocument();
|
||||
expect(mockGetToolSpend).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
|
|||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
import { getToolSpend, ToolSpendResponse } from "@/components/networking";
|
||||
import { SpendMetrics } from "@/components/UsagePage/types";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
|
@ -82,12 +83,13 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
|
|||
const startTime = dateValue.from ?? null;
|
||||
const endTime = dateValue.to ?? null;
|
||||
|
||||
const toolSpendEnabled = !!accessToken && !!startTime && !!endTime;
|
||||
const canViewProxyWideCostData = useCan("viewProxyWideCostData");
|
||||
const toolSpendEnabled = canViewProxyWideCostData && !!accessToken && !!startTime && !!endTime;
|
||||
const rangeKey = startTime && endTime ? `${isoDay(startTime)}|${isoDay(endTime)}` : "";
|
||||
const [toolSpendState, setToolSpendState] = useState<{ key: string; data: ToolSpendResponse } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !startTime || !endTime) return;
|
||||
if (!canViewProxyWideCostData || !accessToken || !startTime || !endTime) return;
|
||||
let cancelled = false;
|
||||
getToolSpend(accessToken, isoDay(startTime), isoDay(endTime))
|
||||
.then((res) => {
|
||||
|
|
@ -99,7 +101,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken, startTime, endTime, rangeKey]);
|
||||
}, [canViewProxyWideCostData, accessToken, startTime, endTime, rangeKey]);
|
||||
|
||||
const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : null;
|
||||
const toolSpendLoading = toolSpendEnabled && toolSpend === null;
|
||||
|
|
@ -273,55 +275,57 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Spend by tool</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Spend on requests that invoked each tool (MCP and client-side tools); declaring a tool without invoking it
|
||||
does not count. A request that invoked multiple tools counts its full spend toward each, so this attributes
|
||||
rather than partitions spend.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{topTools.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{toolSpendLoading ? "Loading..." : "No tool usage in this range."}
|
||||
{canViewProxyWideCostData && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Spend by tool</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Spend on requests that invoked each tool (MCP and client-side tools); declaring a tool without invoking it
|
||||
does not count. A request that invoked multiple tools counts its full spend toward each, so this
|
||||
attributes rather than partitions spend.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-muted-foreground">Total by tool</p>
|
||||
<BarChart
|
||||
data={topToolsChart}
|
||||
index="tool_name"
|
||||
categories={["spend"]}
|
||||
colors={toolColors}
|
||||
colorByDatum
|
||||
layout="vertical"
|
||||
yAxisWidth={140}
|
||||
maxBarSize={64}
|
||||
showLegend={false}
|
||||
valueFormatter={usd}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{topTools.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{toolSpendLoading ? "Loading..." : "No tool usage in this range."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-muted-foreground">Total by tool</p>
|
||||
<BarChart
|
||||
data={topToolsChart}
|
||||
index="tool_name"
|
||||
categories={["spend"]}
|
||||
colors={toolColors}
|
||||
colorByDatum
|
||||
layout="vertical"
|
||||
yAxisWidth={140}
|
||||
maxBarSize={64}
|
||||
showLegend={false}
|
||||
valueFormatter={usd}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-muted-foreground">Daily spend by tool</p>
|
||||
<CustomLegend categories={topToolNames} colors={toolColors} />
|
||||
<BarChart
|
||||
data={dailyToolSeries}
|
||||
index="date"
|
||||
categories={topToolNames}
|
||||
colors={toolColors}
|
||||
stack
|
||||
maxBarSize={64}
|
||||
valueFormatter={usd}
|
||||
showLegend={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-muted-foreground">Daily spend by tool</p>
|
||||
<CustomLegend categories={topToolNames} colors={toolColors} />
|
||||
<BarChart
|
||||
data={dailyToolSeries}
|
||||
index="date"
|
||||
categories={topToolNames}
|
||||
colors={toolColors}
|
||||
stack
|
||||
maxBarSize={64}
|
||||
valueFormatter={usd}
|
||||
showLegend={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import GuardrailsMonitor from "./page";
|
||||
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
|
||||
|
||||
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: useAuthorizedMock,
|
||||
}));
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
|
||||
|
||||
const renderAs = (userRole: string) => {
|
||||
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
|
||||
return renderWithProviders(<GuardrailsMonitor />);
|
||||
};
|
||||
|
||||
// `/guardrails/usage/*` aggregates across tenants and is listed in
|
||||
// admin_viewer_routes, so it is proxy-admin-only. Nothing on this page works
|
||||
// for a non-admin, hence the whole page is gated rather than a section of it.
|
||||
describe("Guardrails Monitor page access by role", () => {
|
||||
beforeEach(() => {
|
||||
testQueryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ rows: [], chart: [], totalRequests: 0, totalBlocked: 0, passRate: 100 }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("fetches the guardrails usage overview for an admin", async () => {
|
||||
renderAs("Admin");
|
||||
|
||||
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/guardrails/usage/overview"))).toBe(true));
|
||||
});
|
||||
|
||||
it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])(
|
||||
"renders the admin-only notice and fires no usage request for %s",
|
||||
async (userRole) => {
|
||||
renderAs(userRole);
|
||||
|
||||
expect(await screen.findByText("Guardrails Monitor is only available to admin users.")).toBeInTheDocument();
|
||||
await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
|
||||
expect(requestedUrls().filter((url) => url.includes("/guardrails/usage"))).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1,9 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import GuardrailsMonitorView from "./_components/GuardrailsMonitorView";
|
||||
import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
|
||||
export default function GuardrailsMonitor() {
|
||||
const { accessToken } = useAuthorized();
|
||||
const canViewGuardrailUsage = useCan("viewGuardrailUsage");
|
||||
|
||||
if (!canViewGuardrailUsage) {
|
||||
return <AdminOnlyNotice pageTitle="Guardrails Monitor" />;
|
||||
}
|
||||
|
||||
return <GuardrailsMonitorView accessToken={accessToken} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Memory from "./page";
|
||||
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
|
||||
|
||||
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: useAuthorizedMock,
|
||||
}));
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
|
||||
|
||||
const renderAs = (userRole: string) => {
|
||||
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
|
||||
return renderWithProviders(<Memory />);
|
||||
};
|
||||
|
||||
// `/v1/memory` scopes rows per caller in the handler, but the route gate keeps
|
||||
// it proxy-admin-only, so a non-admin deep-linking to /ui/memory gets a 401.
|
||||
describe("Memory page access by role", () => {
|
||||
beforeEach(() => {
|
||||
testQueryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
text: async () => "",
|
||||
json: async () => ({ memories: [], total: 0 }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("lists memory entries for an admin", async () => {
|
||||
renderAs("Admin");
|
||||
|
||||
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/v1/memory"))).toBe(true));
|
||||
});
|
||||
|
||||
it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])(
|
||||
"renders the admin-only notice and fires no memory request for %s",
|
||||
async (userRole) => {
|
||||
renderAs(userRole);
|
||||
|
||||
expect(await screen.findByText("Memory is only available to admin users.")).toBeInTheDocument();
|
||||
await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
|
||||
expect(requestedUrls().filter((url) => url.includes("/v1/memory"))).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("hides the deprecation banner along with the page body for a denied role", () => {
|
||||
renderAs("Internal User");
|
||||
|
||||
expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,10 +2,18 @@
|
|||
|
||||
import { MemoryView } from "./_components/MemoryView";
|
||||
import { DeprecationBanner } from "@/components/DeprecationBanner";
|
||||
import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
|
||||
export default function Memory() {
|
||||
const { accessToken, userRole, userId } = useAuthorized();
|
||||
const canViewMemory = useCan("viewMemory");
|
||||
|
||||
if (!canViewMemory) {
|
||||
return <AdminOnlyNotice pageTitle="Memory" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeprecationBanner featureName="Memory" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Workflows from "./page";
|
||||
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
|
||||
|
||||
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: useAuthorizedMock,
|
||||
}));
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
|
||||
|
||||
const renderAs = (userRole: string) => {
|
||||
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
|
||||
return renderWithProviders(<Workflows />);
|
||||
};
|
||||
|
||||
// Deep-linking to /ui/workflows bypasses the sidebar, so the page itself has to
|
||||
// refuse the render. `/v1/workflows/runs` is proxy-admin-only, so any request
|
||||
// from a non-admin is the 401 this gate exists to stop.
|
||||
describe("Workflows page access by role", () => {
|
||||
beforeEach(() => {
|
||||
testQueryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ runs: [], count: 0 }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("lists workflow runs for an admin", async () => {
|
||||
renderAs("Admin");
|
||||
|
||||
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/v1/workflows/runs"))).toBe(true));
|
||||
});
|
||||
|
||||
it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])(
|
||||
"renders the admin-only notice and fires no workflow request for %s",
|
||||
async (userRole) => {
|
||||
renderAs(userRole);
|
||||
|
||||
expect(await screen.findByText("Workflow Runs is only available to admin users.")).toBeInTheDocument();
|
||||
await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
|
||||
expect(requestedUrls().filter((url) => url.includes("/v1/workflows"))).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("hides the deprecation banner along with the page body for a denied role", () => {
|
||||
renderAs("Internal User");
|
||||
|
||||
expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,10 +2,18 @@
|
|||
|
||||
import WorkflowRuns from "./WorkflowRuns";
|
||||
import { DeprecationBanner } from "@/components/DeprecationBanner";
|
||||
import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
|
||||
export default function Workflows() {
|
||||
const { accessToken } = useAuthorized();
|
||||
const canViewWorkflowRuns = useCan("viewWorkflowRuns");
|
||||
|
||||
if (!canViewWorkflowRuns) {
|
||||
return <AdminOnlyNotice pageTitle="Workflow Runs" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeprecationBanner featureName="Workflows" />
|
||||
|
|
|
|||
|
|
@ -293,6 +293,96 @@ describe("Sidebar (leftnav)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Workflow Runs, Memory and Guardrails Monitor render a shell and then 401
|
||||
// for every non-proxy-admin role, because their page-load routes sit outside
|
||||
// internal_user_routes / self_managed_routes. Cost Optimization does not:
|
||||
// its primary call is /user/daily/activity, which every role may make, so
|
||||
// the entry stays and only its proxy-wide tabs are gated inside the page.
|
||||
describe("capability-gated pages whose data is proxy-admin-only", () => {
|
||||
const authFor = (userRole: string) => ({
|
||||
userId: "some-user-id",
|
||||
accessToken: "test-access-token",
|
||||
userRole,
|
||||
isViewOnly: false,
|
||||
token: "test-token",
|
||||
userEmail: "someone@example.com",
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: false,
|
||||
showSSOBanner: false,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockUseAuthorized.mockReset();
|
||||
});
|
||||
|
||||
it("hides Workflow Runs and Memory from an internal user under Agentic", async () => {
|
||||
mockUseAuthorized.mockReturnValue(authFor("internal"));
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Agentic"));
|
||||
});
|
||||
// Liveness gate: the sibling Agents child stays visible to this role, so
|
||||
// the absences below mean the gate fired, not that the group never opened.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Agents")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Workflow Runs")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Memory")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// An org admin's session role is "Org Admin", which no capability list
|
||||
// carries, and the proxy denies these routes to org admins too because
|
||||
// `_user_is_org_admin` needs an organization_id the page-load GET never sends.
|
||||
// Agents is already out of reach for this role, so gating the other two
|
||||
// empties the Agentic group entirely and the parent must go with it rather
|
||||
// than degrade into a leaf link to the non-route `?page=agentic`.
|
||||
it("drops the whole Agentic group for an org admin once its last child is gated", () => {
|
||||
mockUseAuthorized.mockReturnValue(authFor("org_admin"));
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
// Liveness gate: Logs carries no role list, so it proves the sidebar rendered.
|
||||
expect(screen.getByText("Logs")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Agentic")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Workflow Runs")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Memory")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the Agentic group for an internal user, who can still see Agents", () => {
|
||||
mockUseAuthorized.mockReturnValue(authFor("internal"));
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Agentic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Workflow Runs and Memory to admins", async () => {
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Agentic"));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Workflow Runs")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Memory")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Guardrails Monitor from an internal user while keeping Usage and Cost Optimization", () => {
|
||||
mockUseAuthorized.mockReturnValue(authFor("internal"));
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByText("Guardrails Monitor")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Usage")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cost Optimization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Guardrails Monitor to admins", () => {
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Guardrails Monitor")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show Organizations tab for organization admins", () => {
|
||||
mockUseAuthorized.mockReturnValueOnce({
|
||||
userId: "org-admin-user-id",
|
||||
|
|
|
|||
|
|
@ -146,8 +146,20 @@ const menuGroups: MenuGroup[] = [
|
|||
icon: <Bot {...ICON} />,
|
||||
roles: rolesAllowedToViewWriteScopedPages,
|
||||
},
|
||||
{ key: "workflows", page: "workflows", label: "Workflow Runs", icon: <Workflow {...ICON} /> },
|
||||
{ key: "memory", page: "memory", label: "Memory", icon: <Database {...ICON} /> },
|
||||
{
|
||||
key: "workflows",
|
||||
page: "workflows",
|
||||
label: "Workflow Runs",
|
||||
icon: <Workflow {...ICON} />,
|
||||
roles: rolesWithCapability("viewWorkflowRuns"),
|
||||
},
|
||||
{
|
||||
key: "memory",
|
||||
page: "memory",
|
||||
label: "Memory",
|
||||
icon: <Database {...ICON} />,
|
||||
roles: rolesWithCapability("viewMemory"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: <Server {...ICON} /> },
|
||||
|
|
@ -206,7 +218,7 @@ const menuGroups: MenuGroup[] = [
|
|||
page: "guardrails-monitor",
|
||||
label: "Guardrails Monitor",
|
||||
icon: <HeartPulse {...ICON} />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
roles: rolesWithCapability("viewGuardrailUsage"),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -461,6 +473,9 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
return items
|
||||
.map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined }))
|
||||
.filter((item) => {
|
||||
// A parent whose children were all filtered out renders as a leaf link
|
||||
// to its own page id, which is not a real route. Drop it instead.
|
||||
if (item.children && item.children.length === 0) return false;
|
||||
if (item.key === "llm-playground" && isViewOnly) return false;
|
||||
if (item.key === "organizations" || item.key === "users") {
|
||||
const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface AdminOnlyNoticeProps {
|
||||
pageTitle: string;
|
||||
}
|
||||
|
||||
export const AdminOnlyNotice: React.FC<AdminOnlyNoticeProps> = ({ pageTitle }) => (
|
||||
<div className="p-6 w-full min-w-0 flex-1">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-2">{pageTitle}</h1>
|
||||
<p className="text-sm text-gray-500">{pageTitle} is only available to admin users.</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -26,6 +26,13 @@ const ADMIN_ONLY_CAPABILITIES: Capability[] = [
|
|||
"viewAgentUsage",
|
||||
];
|
||||
|
||||
const PROXY_ADMIN_ONLY_PAGE_CAPABILITIES: Capability[] = [
|
||||
"viewWorkflowRuns",
|
||||
"viewMemory",
|
||||
"viewGuardrailUsage",
|
||||
"viewProxyWideCostData",
|
||||
];
|
||||
|
||||
describe("hasCapability", () => {
|
||||
describe.each(ADMIN_ONLY_CAPABILITIES)("%s", (capability) => {
|
||||
it.each(ADMIN_ROLES)("should grant it to %s", (role) => {
|
||||
|
|
@ -66,6 +73,30 @@ describe("hasCapability - viewGlobalSpend", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// The four sidebar pages behind these capabilities call routes the proxy serves
|
||||
// to proxy_admin and proxy_admin_viewer only. An org admin is denied there too,
|
||||
// because `_user_is_org_admin` needs an organization_id that a page-load GET
|
||||
// never carries, so `org_admin` must not grant them either.
|
||||
describe.each(PROXY_ADMIN_ONLY_PAGE_CAPABILITIES)("hasCapability - %s", (capability) => {
|
||||
it.each(ADMIN_ROLES)("should grant it to %s", (role) => {
|
||||
expect(hasCapability(role, capability)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([...NON_ADMIN_ROLES, "internal_user_viewer", "org_admin"])("should deny it to %s", (role) => {
|
||||
expect(hasCapability(role, capability)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["proxy_admin", true],
|
||||
["proxy_admin_viewer", true],
|
||||
["org_admin", false],
|
||||
["internal_user", false],
|
||||
["internal_user_viewer", false],
|
||||
] as const)("should match the backend for a %s session", (rawRole, expected) => {
|
||||
expect(hasCapability(effectiveSessionRole(rawRole), capability)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rolesWithCapability", () => {
|
||||
it("should return a copy so callers cannot mutate the capability map", () => {
|
||||
const roles = rolesWithCapability("viewToolPolicies");
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ const CAPABILITY_ROLES = {
|
|||
viewOrganizationUsage: all_admin_roles,
|
||||
viewAgentUsage: all_admin_roles,
|
||||
viewGlobalSpend: proxyAdminOnlyRoles,
|
||||
viewWorkflowRuns: proxyAdminOnlyRoles,
|
||||
viewMemory: proxyAdminOnlyRoles,
|
||||
viewGuardrailUsage: proxyAdminOnlyRoles,
|
||||
viewProxyWideCostData: proxyAdminOnlyRoles,
|
||||
} as const satisfies Record<string, readonly string[]>;
|
||||
|
||||
export type Capability = keyof typeof CAPABILITY_ROLES;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue