diff --git a/src/components/dashboard/McpHealthCard.tsx b/src/components/dashboard/McpHealthCard.tsx index 7e25196..43aacf2 100644 --- a/src/components/dashboard/McpHealthCard.tsx +++ b/src/components/dashboard/McpHealthCard.tsx @@ -42,7 +42,7 @@ import { type RosterHeaderKind, type SummarySegment, } from "./mcpServerRoster"; -import { PermissionDenied } from "./PermissionDenied"; +import { isPermissionDenied, PermissionDenied } from "./PermissionDenied"; import { ServerRosterRow, ServerRosterRowStacked } from "./ServerRosterRow"; import { StatusDot } from "./StatusDot"; @@ -151,7 +151,7 @@ export function McpHealthCard({ health: sharedHealth }: { health?: SystemHealthR // No gateways.read -> 403 -> precise permission gate (authoritative even with // a previously-loaded roster: a lost permission should not keep showing data). - if (error?.status === 403) { + if (isPermissionDenied(error)) { return ; } diff --git a/src/components/dashboard/PermissionDenied.test.ts b/src/components/dashboard/PermissionDenied.test.ts new file mode 100644 index 0000000..f35ea15 --- /dev/null +++ b/src/components/dashboard/PermissionDenied.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { ApiError } from "@/api/client"; + +import { isPermissionDenied } from "./PermissionDenied"; + +describe("isPermissionDenied", () => { + it("matches an ApiError with status 403", () => { + expect(isPermissionDenied(new ApiError(403, null, "HTTP 403"))).toBe(true); + }); + + it("matches the sanitized query-error shape useQuery stores", () => { + expect(isPermissionDenied({ message: "HTTP 403", status: 403 })).toBe(true); + }); + + it("rejects other statuses and non-errors", () => { + expect(isPermissionDenied(new ApiError(500, null, "HTTP 500"))).toBe(false); + expect(isPermissionDenied({ status: 500 })).toBe(false); + expect(isPermissionDenied(null)).toBe(false); + expect(isPermissionDenied(undefined)).toBe(false); + expect(isPermissionDenied("403")).toBe(false); + }); +}); diff --git a/src/components/dashboard/PermissionDenied.tsx b/src/components/dashboard/PermissionDenied.tsx index 2c8d5d2..194608d 100644 --- a/src/components/dashboard/PermissionDenied.tsx +++ b/src/components/dashboard/PermissionDenied.tsx @@ -8,12 +8,16 @@ import { Lock } from "lucide-react"; import { useIntl } from "react-intl"; -import { ApiError } from "@/api/client"; import { cn } from "@/lib/utils"; -/** True when an error is a 403 from the API (insufficient permissions). */ +/** + * True when an error is a 403 from the API. Structural on purpose: useQuery + * sanitizes thrown ApiErrors into plain `{ message, status, ... }` objects + * (sanitizeError in src/hooks/useQuery.ts), so an instanceof test would never + * match a query error. Matches both shapes. + */ export function isPermissionDenied(err: unknown): boolean { - return err instanceof ApiError && err.status === 403; + return typeof err === "object" && err !== null && "status" in err && err.status === 403; } interface PermissionDeniedProps {