From 386337acf2fcf91de02ae121bf09e71c9f97f22b Mon Sep 17 00:00:00 2001 From: Ahmad Al Tamimi Date: Fri, 21 Aug 2026 17:47:13 +0400 Subject: [PATCH] fix: match sanitized query errors in isPermissionDenied and adopt it in McpHealthCard useQuery sanitizes thrown ApiErrors into plain objects, so the helper's instanceof check never matched a query error. Make it structural and use it for the McpHealthCard 403 gate. Signed-off-by: Ahmad Al Tamimi --- src/components/dashboard/McpHealthCard.tsx | 4 ++-- .../dashboard/PermissionDenied.test.ts | 23 +++++++++++++++++++ src/components/dashboard/PermissionDenied.tsx | 10 +++++--- 3 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 src/components/dashboard/PermissionDenied.test.ts 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 {