From 1554540431bc67546c489b404385cd3a569b0e89 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 6 Sep 2026 01:01:41 +0200 Subject: [PATCH] web: one API call layer (fetchJson/fetchOk/fetchBlob), notifyError, ratchets with the full baseline (audit package 5, stage 1) lib/scopedFetch.ts becomes lib/apiClient.ts: ApiError carries retryAfterSeconds (body first, Retry-After header second), fetchJson refuses empty and malformed 2xx bodies instead of handing back undefined, sets Content-Type for string bodies, fetchOk covers action calls and fetchBlob downloads. describeApiError renders 429/503 with the server's retry advice; apiErrorText is the one-string form for inline error slots; apiErrorNotification folds the "Session Expired" dialect into one place and NotificationContext.notifyError uses it. useAuthenticatedFetch throws ApiError (correlation id preserved), drops the dead 204 branch and onTokenExpired. dedupedFetchJson for the three feature-flag readers. lib/__tests__/apiClient.guard.test.ts ratchets raw authenticatedFetch(/dedupedAuthFetch(/ .json()/instanceof TokenExpiredError per file against a baseline that may only shrink (a count above fails as a new site, below as a stale entry); fetchJson without a type argument is a violation. Baseline today: 228 raw fetches, 221 parses, 140 token checks. Co-Authored-By: Claude Fable 5.1 --- .../app/analyze-rules/page.tsx | 2 - .../app/apps/components/InstallsTab.tsx | 33 +-- .../hooks/useTenantSecurityConfig.ts | 29 +- .../app/dashboard/page.tsx | 4 +- .../app/fleet-health/page.tsx | 2 +- .../app/gather-rules/page.tsx | 2 - .../app/ime-log-patterns/page.tsx | 2 - .../sessions/hooks/useSessionTenantConfig.ts | 7 +- .../contexts/NotificationContext.tsx | 14 +- .../hooks/useAuthenticatedFetch.ts | 60 +--- .../hooks/useEditionInfo.ts | 6 +- .../lib/__tests__/apiClient.guard.baseline.ts | 273 ++++++++++++++++++ .../lib/__tests__/apiClient.guard.test.ts | 160 ++++++++++ .../lib/__tests__/apiClient.test.ts | 169 +++++++++++ .../lib/__tests__/scopedFetch.test.ts | 71 ----- .../autopilot-monitor-web/lib/apiClient.ts | 164 +++++++++++ .../lib/dedupedAuthFetch.ts | 10 +- .../autopilot-monitor-web/lib/scopedFetch.ts | 74 ----- 18 files changed, 830 insertions(+), 252 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/apiClient.test.ts delete mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/scopedFetch.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/apiClient.ts delete mode 100644 src/Web/autopilot-monitor-web/lib/scopedFetch.ts diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx index 150ca481..8d010215 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -42,12 +42,10 @@ export default function AnalyzeRulesPage() { const { data: rules, loading, execute: fetchRulesExec, setData: setRules } = useAuthenticatedFetch({ onError: (err) => showError(err.message), - onTokenExpired: (err) => showError(err.message), }); const { execute: mutate } = useAuthenticatedFetch({ onError: (err) => showError(err.message), - onTokenExpired: (err) => showError(err.message), }); // Filter state diff --git a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx index 13fc25a4..9ef6cabf 100644 --- a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx @@ -7,8 +7,7 @@ import { useTenant } from "../../../contexts/TenantContext"; import { useAuth } from "../../../contexts/AuthContext"; import { useNotifications } from "../../../contexts/NotificationContext"; import { scopedApi } from "@/lib/scopedApi"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; -import { apiErrorFromResponse, describeApiError } from "@/lib/scopedFetch"; +import { fetchJson } from "@/lib/apiClient"; import { formatBytes, formatDuration } from "@/lib/formatting"; import DoBreakdownBar from "@/components/DoBreakdownBar"; import { CalculatingInline } from "@/components/CalculatingCard"; @@ -82,7 +81,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { const router = useRouter(); const { tenantId } = useTenant(); const { getAccessToken } = useAuth(); - const { addNotification } = useNotifications(); + const { notifyError } = useNotifications(); const { isGlobalAdmin, scopeInitialized, scopeKey } = scope; const [data, setData] = useState(null); @@ -110,32 +109,26 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { const listUrl = scopedApi.appsList(scope, days); const metricsUrl = scopedApi.appMetrics(scope, days); - const [listRes, metricsRes] = await Promise.all([ - authenticatedFetch(listUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }), - authenticatedFetch(metricsUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }), + // The list is the page; the DO rollup is decoration — its failure stays silent. + const [listRes, metricsRes] = await Promise.allSettled([ + fetchJson(listUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }), + fetchJson(metricsUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }), ]); if (cancelled) return; - if (listRes.ok) { - setData((await listRes.json()) as AppsListResponse); + if (listRes.status === "fulfilled") { + setData(listRes.value); succeeded = true; } else { - // Error envelope: the backend's message plus the correlation id as a quotable reference. - const { message, reference } = describeApiError(await apiErrorFromResponse(listRes)); - addNotification("error", "Backend Error", `Failed to load apps: ${message}`, "apps-list-error", undefined, reference ?? undefined); + notifyError("Backend Error", listRes.reason, "apps-list-error", "Failed to load apps."); } - if (metricsRes.ok) { - const m = (await metricsRes.json()) as AppMetricsResponse; - setDoRollup(m.deliveryOptimization ?? null); + if (metricsRes.status === "fulfilled") { + setDoRollup(metricsRes.value.deliveryOptimization ?? null); } } catch (err) { if (cancelled) return; - if (err instanceof TokenExpiredError) { - addNotification("error", "Session Expired", err.message, "session-expired-error"); - } else { - console.error("Failed to fetch app installs", err); - addNotification("error", "Backend Not Reachable", "Unable to load app dashboard data.", "apps-list-error"); - } + console.error("Failed to fetch app installs", err); + notifyError("Backend Not Reachable", err, "apps-list-error", "Unable to load app dashboard data."); } finally { progressFinish(succeeded); if (!cancelled) setLoading(false); diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts index 78397b16..f21ef1c9 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts @@ -3,17 +3,11 @@ import { useEffect, useState } from "react"; import { api } from "@/lib/api"; import { TokenExpiredError } from "@/lib/authenticatedFetch"; -import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch"; -import type { NotificationType } from "@/contexts/NotificationContext"; +import { dedupedFetchJson } from "@/lib/dedupedAuthFetch"; import { missingContactProfileParts } from "@/lib/edition"; -type AddNotification = ( - type: NotificationType, - title: string, - message: string, - key?: string, - href?: string, -) => void; +/** NotificationContext.notifyError, injected so the hook stays free of the provider. */ +type NotifyError = (title: string, err: unknown, key?: string, fallback?: string) => void; /** The subset of the feature-flags response the dashboard banners read. */ export interface TenantConfigurationSummary { @@ -79,7 +73,7 @@ export function useTenantSecurityConfig( tenantId: string | null | undefined, user: User | null | undefined, getAccessToken: (forceRefresh?: boolean) => Promise, - addNotification: AddNotification, + notifyError: NotifyError, ): TenantSecuritySummary { const [summary, setSummary] = useState(EMPTY_SUMMARY); @@ -95,19 +89,12 @@ export function useTenantSecurityConfig( if (!user.isTenantAdmin && !user.isGlobalAdmin && user.role == null) return; try { - const response = await dedupedAuthFetch(api.config.featureFlags(tenantId), getAccessToken); - - if (!response.ok) { - setSummary(EMPTY_SUMMARY); - return; - } - - const data: TenantConfigurationSummary = await response.json(); + const data = await dedupedFetchJson(api.config.featureFlags(tenantId), getAccessToken); setSummary(summarizeTenantSecurityConfig(data)); } catch (error) { - if (error instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', error.message, 'session-expired-error'); - } + // Fail-soft on a backend refusal (the banners just stay quiet); a token expiry is the + // one failure the user must hear about. + if (error instanceof TokenExpiredError) notifyError('Session Expired', error); setSummary(EMPTY_SUMMARY); } }; diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx index 62bfccbb..5829daf6 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx @@ -99,7 +99,7 @@ function HomeContent() { ? "w-full px-4 sm:px-6 lg:px-8 py-4" : "max-w-7xl mx-auto py-4 sm:px-6 lg:px-8"; const { user, getAccessToken, isActivationPending, hasGlobalScope } = useAuth(); - const { addNotification } = useNotifications(); + const { addNotification, notifyError } = useNotifications(); // `?tenant=` deep-links a cross-tenant view onto one tenant — used by the /fleet card grid to drill // a managed tenant into this dashboard. Ignored for non-cross-tenant users (the filter is unused there). const initialTenantFilter = searchParams?.get("tenant") ?? ""; @@ -262,7 +262,7 @@ function HomeContent() { } }, [user, router]); - const { serialValidationEnabled, proContactMissing, proContactMissingParts, appHomingFunnelActive } = useTenantSecurityConfig(tenantId, user, getAccessToken, addNotification); + const { serialValidationEnabled, proContactMissing, proContactMissingParts, appHomingFunnelActive } = useTenantSecurityConfig(tenantId, user, getAccessToken, notifyError); // Disable global-scope mode for users without platform scope. A read-only Global Reader keeps it // (their cross-tenant view is read-only-safe; writes are gated separately + backend-enforced). diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx index d47dfcd7..adc4fad0 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx @@ -10,7 +10,7 @@ import { useTenant } from "../../contexts/TenantContext"; import { useAuth } from "../../contexts/AuthContext"; import { useNotifications } from "../../contexts/NotificationContext"; import { scopedApi } from "@/lib/scopedApi"; -import { fetchJson } from "@/lib/scopedFetch"; +import { fetchJson } from "@/lib/apiClient"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import FleetStatCard from "./components/FleetStatCard"; import TimeAttributionSection, { TimeAttributionResponseDto } from "./components/TimeAttributionSection"; diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx index 9960a068..d0109f45 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx @@ -28,12 +28,10 @@ export default function GatherRulesPage() { const { data: rules, loading, execute: fetchRulesExec, setData: setRules } = useAuthenticatedFetch({ onError: (err) => showError(err.message), - onTokenExpired: (err) => showError(err.message), }); const { execute: mutate } = useAuthenticatedFetch({ onError: (err) => showError(err.message), - onTokenExpired: (err) => showError(err.message), }); // Filter state diff --git a/src/Web/autopilot-monitor-web/app/ime-log-patterns/page.tsx b/src/Web/autopilot-monitor-web/app/ime-log-patterns/page.tsx index 7ae52845..e5fa410f 100644 --- a/src/Web/autopilot-monitor-web/app/ime-log-patterns/page.tsx +++ b/src/Web/autopilot-monitor-web/app/ime-log-patterns/page.tsx @@ -28,12 +28,10 @@ export default function ImeLogPatternsPage() { const { data: patterns, loading, execute: fetchPatternsExec, setData: setPatterns } = useAuthenticatedFetch({ onError: (err) => showError(err.message), - onTokenExpired: (err) => showError(err.message), }); const { execute: mutate } = useAuthenticatedFetch({ onError: (err) => showError(err.message), - onTokenExpired: (err) => showError(err.message), }); // Filters diff --git a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionTenantConfig.ts b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionTenantConfig.ts index f455f29a..c6624b59 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionTenantConfig.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionTenantConfig.ts @@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch"; +import { dedupedFetchJson } from "@/lib/dedupedAuthFetch"; +import type { TenantFeatureFlagsResponse } from "@/utils/wire-types.generated"; interface UseSessionTenantConfigReturn { showScriptOutput: boolean; @@ -36,9 +37,7 @@ export function useSessionTenantConfig( // These flags are exposed via the member-readable feature-flags endpoint so that // Operators and Viewers can load session details without 403'ing on the admin-only // full /api/config/{tenantId} response. - const res = await dedupedAuthFetch(api.config.featureFlags(sessionTenantId), getAccessToken); - if (!res.ok || cancelled) return; - const cfg = await res.json(); + const cfg = await dedupedFetchJson(api.config.featureFlags(sessionTenantId), getAccessToken); if (cancelled) return; setShowScriptOutput(cfg.showScriptOutput ?? true); setEnableSoftwareInventoryAnalyzer(cfg.enableSoftwareInventoryAnalyzer ?? false); diff --git a/src/Web/autopilot-monitor-web/contexts/NotificationContext.tsx b/src/Web/autopilot-monitor-web/contexts/NotificationContext.tsx index f0c0315e..c367be18 100644 --- a/src/Web/autopilot-monitor-web/contexts/NotificationContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/NotificationContext.tsx @@ -1,6 +1,7 @@ "use client"; import React, { createContext, useContext, useState, useCallback, useMemo } from 'react'; +import { apiErrorNotification } from '@/lib/apiClient'; export type NotificationType = 'error' | 'warning' | 'info' | 'success'; @@ -20,6 +21,11 @@ interface NotificationContextType { notifications: Notification[]; unreadCount: number; addNotification: (type: NotificationType, title: string, message: string, key?: string, href?: string, reference?: string) => void; + /** + * The error toast for a failed API call: message and reference from the error envelope + * (describeApiError); a token expiry renders as "Session Expired" whatever the title. + */ + notifyError: (title: string, err: unknown, key?: string, fallback?: string) => void; markAsRead: (id: string) => void; markAllAsRead: () => void; removeNotification: (id: string) => void; @@ -101,6 +107,11 @@ export function NotificationProvider({ children }: { children: React.ReactNode } } }, []); + const notifyError = useCallback((title: string, err: unknown, key?: string, fallback?: string) => { + const n = apiErrorNotification(title, err, key, fallback); + addNotification(n.type, n.title, n.message, n.key, undefined, n.reference); + }, [addNotification]); + const markAsRead = useCallback((id: string) => { setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n) @@ -130,11 +141,12 @@ export function NotificationProvider({ children }: { children: React.ReactNode } notifications, unreadCount, addNotification, + notifyError, markAsRead, markAllAsRead, removeNotification, clearAll, - }), [notifications, unreadCount, addNotification, markAsRead, markAllAsRead, removeNotification, clearAll]); + }), [notifications, unreadCount, addNotification, notifyError, markAsRead, markAllAsRead, removeNotification, clearAll]); return ( diff --git a/src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts b/src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts index 32d1fa4d..7212e4be 100644 --- a/src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts +++ b/src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts @@ -4,16 +4,15 @@ import { useState, useCallback, useRef } from "react"; import { useLatest } from "@/hooks/useLatest"; import { useAuth } from "@/contexts/AuthContext"; import { useNotifications } from "@/contexts/NotificationContext"; -import { - authenticatedFetch, - TokenExpiredError, -} from "@/lib/authenticatedFetch"; +import { TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; export interface UseAuthenticatedFetchOptions { - /** Called on non-TokenExpiredError failures. If not set, only the error state is updated. */ + /** + * Called on every failure (ApiError, TokenExpiredError, network). If not set, only the error + * state is updated — except a token expiry, which the notification bell reports. + */ onError?: (error: Error) => void; - /** Called on TokenExpiredError. Default: addNotification via NotificationContext. */ - onTokenExpired?: (error: TokenExpiredError) => void; } export interface ExecuteOptions { @@ -41,7 +40,7 @@ export function useAuthenticatedFetch( options?: UseAuthenticatedFetchOptions, ): UseAuthenticatedFetchReturn { const { getAccessToken } = useAuth(); - const { addNotification } = useNotifications(); + const { notifyError } = useNotifications(); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); @@ -78,25 +77,7 @@ export function useAuthenticatedFetch( } setError(null); - const response = await authenticatedFetch(url, getAccessToken, init); - - if (!response.ok) { - // Try to extract error message from response body (API returns { message: "..." }) - let message = `Failed: ${response.statusText}`; - try { - const errorData = await response.json(); - if (errorData.message) message = errorData.message; - } catch { - /* response body wasn't JSON */ - } - throw new Error(message); - } - - if (response.status === 204) { - return null; - } - - const json = await response.json(); + const json = await fetchJson(url, getAccessToken, init); const result = executeOptions?.transform ? executeOptions.transform(json) : (json as T); @@ -111,26 +92,13 @@ export function useAuthenticatedFetch( return null; } const opts = optionsRef.current; - if (err instanceof TokenExpiredError) { - if (opts?.onTokenExpired) { - opts.onTokenExpired(err); - } else { - addNotification( - "error", - "Session Expired", - err.message, - "session-expired", - ); - } - } else if (opts?.onError) { - opts.onError( - err instanceof Error ? err : new Error(String(err)), - ); + if (opts?.onError) { + opts.onError(err instanceof Error ? err : new Error(String(err))); + } else if (err instanceof TokenExpiredError) { + notifyError("Session Expired", err); } - const message = - err instanceof Error ? err.message : "An unknown error occurred"; - setError(message); + setError(apiErrorText(err, "An unknown error occurred")); return null; } finally { // Only the spinner's owner may clear it — a superseded request finishing early @@ -140,7 +108,7 @@ export function useAuthenticatedFetch( } } }, - [getAccessToken, addNotification, optionsRef], + [getAccessToken, notifyError, optionsRef], ); return { data, loading, error, execute, clearError, setData }; diff --git a/src/Web/autopilot-monitor-web/hooks/useEditionInfo.ts b/src/Web/autopilot-monitor-web/hooks/useEditionInfo.ts index 155000c5..1f8c254b 100644 --- a/src/Web/autopilot-monitor-web/hooks/useEditionInfo.ts +++ b/src/Web/autopilot-monitor-web/hooks/useEditionInfo.ts @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; import { api } from "@/lib/api"; -import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch"; +import { dedupedFetchJson } from "@/lib/dedupedAuthFetch"; import { parseEditionInfo, type EditionInfo } from "@/lib/edition"; /** @@ -30,9 +30,7 @@ export function useEditionInfo(): EditionInfo | null { let cancelled = false; const run = async () => { try { - const res = await dedupedAuthFetch(api.config.featureFlags(tenantId), getAccessToken); - if (!res.ok) return; - const parsed = parseEditionInfo(await res.json()); + const parsed = parseEditionInfo(await dedupedFetchJson(api.config.featureFlags(tenantId), getAccessToken)); if (!cancelled) setInfo(parsed); } catch { // Leave null — chrome renders nothing rather than a guessed edition. diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts new file mode 100644 index 00000000..2296c381 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts @@ -0,0 +1,273 @@ +// Ratchet baseline for lib/__tests__/apiClient.guard.test.ts — per-file counts of the legacy +// call-site patterns on 2026-09-06. Only ever shrinks: a migrated file lowers or drops its entry, +// a new raw site anywhere fails the guard. Regenerate a shrunken map by hand, never by script over +// the whole tree (the guard's stale check tells you which entries to lower). + +/** Files that keep a raw site for a stated reason; they never leave the baseline. */ +export const PERMANENT: Record = { + "contexts/AuthContext.tsx": "auth/me bootstrap runs before the auth context exists — raw fetch by design", + "components/landing/StatsBand.tsx": "unauthenticated public blob manifest, not the API", + "app/health-check/page.tsx": "/version.json of the SWA itself, not the API", + "app/settings/TenantConfigContext.tsx": "consent return path routes on TokenExpiredError (router.replace) — the one non-message handling", +}; + +export const AUTHENTICATEDFETCH_BASELINE: Record = { + "app/admin/AdminConfigContext.tsx": 8, + "app/admin/backups/components/RestoreRowDiffModal.tsx": 1, + "app/admin/backups/detail/page.tsx": 2, + "app/admin/backups/page.tsx": 3, + "app/admin/components/ConfigReseedSection.tsx": 4, + "app/admin/components/DeviceBlockSection.tsx": 6, + "app/admin/components/DistressReportsSection.tsx": 1, + "app/admin/components/FeedbackSection.tsx": 1, + "app/admin/components/IdentityBindingsSection.tsx": 3, + "app/admin/components/IgnoredSoftwareTab.tsx": 4, + "app/admin/components/MaintenanceTriggerSection.tsx": 2, + "app/admin/components/MappedSoftwareTab.tsx": 3, + "app/admin/components/OpsEventsSection.tsx": 1, + "app/admin/components/SessionExportSection.tsx": 1, + "app/admin/components/SessionReportsSection.tsx": 3, + "app/admin/components/TenantAdminSection.tsx": 5, + "app/admin/components/TenantManagementSection.tsx": 10, + "app/admin/components/UnmappedSoftwareTab.tsx": 5, + "app/admin/components/VersionBlockSection.tsx": 3, + "app/admin/components/VulnerabilityDataSection.tsx": 11, + "app/admin/customs-archive/detail/page.tsx": 3, + "app/admin/customs-archive/page.tsx": 2, + "app/admin/metrics/sections/SectionAgentMetrics.tsx": 1, + "app/admin/metrics/sections/SectionImePatternHealth.tsx": 1, + "app/admin/metrics/sections/SectionMcpUsage.tsx": 2, + "app/admin/metrics/sections/SectionPlatformUsage.tsx": 1, + "app/admin/metrics/sections/SectionVerdictCalibration.tsx": 1, + "app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx": 2, + "app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 2, + "app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx": 2, + "app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx": 1, + "app/admin/ops/session-cleanup/page.tsx": 3, + "app/admin/presence/page.tsx": 1, + "app/admin/settings/sections/SectionDelegatedAdmins.tsx": 4, + "app/admin/settings/sections/SectionEmailTemplates.tsx": 4, + "app/admin/settings/sections/SectionTenantGroups.tsx": 2, + "app/admin/settings/sections/SectionUsagePlans.tsx": 2, + "app/admin/tenants/sections/SectionTenantConfigReport.tsx": 2, + "app/analyze-rules/page.tsx": 2, + "app/annotations/page.tsx": 1, + "app/apps/components/InventoryTab.tsx": 1, + "app/apps/components/VulnerabilitiesTab.tsx": 1, + "app/apps/detail/page.tsx": 2, + "app/audit/page.tsx": 1, + "app/dashboard/hooks/useBlockDevice.ts": 1, + "app/dashboard/hooks/useDashboardSessions.ts": 3, + "app/dashboard/hooks/useDashboardStats.ts": 1, + "app/dashboard/hooks/useDeleteSession.ts": 3, + "app/dashboard/hooks/useTenantList.ts": 1, + "app/dashboard/page.tsx": 1, + "app/delegations/accept/page.tsx": 2, + "app/diagnosis/page.tsx": 3, + "app/fleet-health/hooks/useFleetHealth.ts": 1, + "app/fleet-health/page.tsx": 1, + "app/fleet/hooks/useFleetSummaries.ts": 1, + "app/gather-rules/page.tsx": 1, + "app/geographic-performance/page.tsx": 1, + "app/geographic-performance/sessions/page.tsx": 1, + "app/health-check/page.tsx": 2, + "app/progress/hooks/useProgressEvents.ts": 3, + "app/progress/hooks/useProgressSearch.ts": 1, + "app/sessions/components/AnalysisResultsSection.tsx": 1, + "app/sessions/components/CollectLogsButton.tsx": 3, + "app/sessions/components/DeviceHistoryBanner.tsx": 1, + "app/sessions/components/SessionAnnotationsCard.tsx": 2, + "app/sessions/hooks/useSessionAnalysis.ts": 2, + "app/sessions/hooks/useSessionDetail.ts": 1, + "app/sessions/hooks/useSessionEvents.ts": 2, + "app/sessions/network-timeline/page.tsx": 2, + "app/sessions/page.tsx": 5, + "app/settings/TenantConfigContext.tsx": 20, + "app/settings/components/HardwareRejectionInsights.tsx": 1, + "app/settings/components/McpUsersSection.tsx": 8, + "app/settings/components/NotRegisteredDevicesInsights.tsx": 1, + "app/settings/components/OffboardingSection.tsx": 1, + "app/settings/components/TpmIncompatibleDevicesInsights.tsx": 1, + "app/settings/reporting/sections/SectionMcpUsage.tsx": 2, + "app/settings/tenant/sections/SectionDelegatedAccess.tsx": 6, + "app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx": 2, + "app/settings/tenant/sections/SectionSubmitLogs.tsx": 1, + "app/sla/page.tsx": 1, + "app/usage-metrics/page.tsx": 1, + "components/DelegatedSlotPrompt.tsx": 1, + "components/FeedbackBubble.tsx": 3, + "components/GlobalSearch.tsx": 1, + "contexts/GlobalNotificationContext.tsx": 3, + "contexts/SignalRContext.tsx": 3, + "contexts/TenantNotificationContext.tsx": 3, + "hooks/useDiagnosticsPathsCatalog.ts": 1, + "hooks/useTenantList.ts": 1, +}; + +export const DEDUPEDAUTHFETCH_BASELINE: Record = { +}; + +export const JSONPARSE_BASELINE: Record = { + "app/activation/page.tsx": 1, + "app/admin/AdminConfigContext.tsx": 8, + "app/admin/backups/components/RestoreRowDiffModal.tsx": 1, + "app/admin/backups/detail/page.tsx": 2, + "app/admin/backups/page.tsx": 3, + "app/admin/components/ConfigReseedSection.tsx": 8, + "app/admin/components/DeviceBlockSection.tsx": 6, + "app/admin/components/DistressReportsSection.tsx": 1, + "app/admin/components/FeedbackSection.tsx": 1, + "app/admin/components/IdentityBindingsSection.tsx": 3, + "app/admin/components/IgnoredSoftwareTab.tsx": 2, + "app/admin/components/MaintenanceTriggerSection.tsx": 1, + "app/admin/components/MappedSoftwareTab.tsx": 3, + "app/admin/components/OpsEventsSection.tsx": 1, + "app/admin/components/SessionExportSection.tsx": 1, + "app/admin/components/SessionReportsSection.tsx": 2, + "app/admin/components/TenantAdminSection.tsx": 5, + "app/admin/components/TenantManagementSection.tsx": 12, + "app/admin/components/UnmappedSoftwareTab.tsx": 3, + "app/admin/components/VersionBlockSection.tsx": 3, + "app/admin/components/VulnerabilityDataSection.tsx": 11, + "app/admin/customs-archive/detail/page.tsx": 2, + "app/admin/customs-archive/page.tsx": 2, + "app/admin/metrics/sections/SectionAgentMetrics.tsx": 1, + "app/admin/metrics/sections/SectionImePatternHealth.tsx": 1, + "app/admin/metrics/sections/SectionMcpUsage.tsx": 2, + "app/admin/metrics/sections/SectionPlatformUsage.tsx": 1, + "app/admin/metrics/sections/SectionVerdictCalibration.tsx": 1, + "app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx": 1, + "app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 1, + "app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx": 2, + "app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx": 1, + "app/admin/ops/session-cleanup/page.tsx": 3, + "app/admin/presence/page.tsx": 1, + "app/admin/settings/sections/SectionDelegatedAdmins.tsx": 4, + "app/admin/settings/sections/SectionEmailTemplates.tsx": 3, + "app/admin/settings/sections/SectionTenantGroups.tsx": 2, + "app/admin/settings/sections/SectionUsagePlans.tsx": 3, + "app/admin/tenants/sections/SectionTenantConfigReport.tsx": 2, + "app/analyze-rules/page.tsx": 2, + "app/annotations/page.tsx": 1, + "app/apps/components/InventoryTab.tsx": 1, + "app/apps/components/VulnerabilitiesTab.tsx": 1, + "app/apps/detail/page.tsx": 2, + "app/audit/page.tsx": 1, + "app/dashboard/hooks/deleteSessionResponse.ts": 1, + "app/dashboard/hooks/useBlockDevice.ts": 1, + "app/dashboard/hooks/useDashboardSessions.ts": 4, + "app/dashboard/hooks/useDashboardStats.ts": 2, + "app/dashboard/hooks/useTenantList.ts": 1, + "app/dashboard/page.tsx": 1, + "app/delegations/accept/page.tsx": 3, + "app/diagnosis/page.tsx": 3, + "app/fleet-health/hooks/useFleetHealth.ts": 2, + "app/fleet-health/page.tsx": 1, + "app/fleet/hooks/useFleetSummaries.ts": 1, + "app/gather-rules/page.tsx": 1, + "app/geographic-performance/page.tsx": 1, + "app/geographic-performance/sessions/page.tsx": 1, + "app/health-check/page.tsx": 3, + "app/progress/hooks/useProgressEvents.ts": 3, + "app/progress/hooks/useProgressSearch.ts": 1, + "app/sessions/components/AnalysisResultsSection.tsx": 1, + "app/sessions/components/CollectLogsButton.tsx": 1, + "app/sessions/components/DeviceHistoryBanner.tsx": 1, + "app/sessions/components/SessionAnnotationsCard.tsx": 2, + "app/sessions/hooks/useSessionAnalysis.ts": 2, + "app/sessions/hooks/useSessionDetail.ts": 1, + "app/sessions/hooks/useSessionEvents.ts": 2, + "app/sessions/network-timeline/page.tsx": 2, + "app/sessions/page.tsx": 2, + "app/settings/TenantConfigContext.tsx": 23, + "app/settings/components/HardwareRejectionInsights.tsx": 1, + "app/settings/components/McpUsersSection.tsx": 8, + "app/settings/components/NotRegisteredDevicesInsights.tsx": 1, + "app/settings/components/TpmIncompatibleDevicesInsights.tsx": 1, + "app/settings/reporting/sections/SectionMcpUsage.tsx": 2, + "app/settings/tenant/sections/SectionDelegatedAccess.tsx": 6, + "app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx": 1, + "app/settings/tenant/sections/SectionSubmitLogs.tsx": 1, + "app/sla/page.tsx": 1, + "app/usage-metrics/page.tsx": 1, + "components/DelegatedSlotPrompt.tsx": 1, + "components/FeedbackBubble.tsx": 1, + "components/GlobalSearch.tsx": 1, + "components/landing/StatsBand.tsx": 2, + "contexts/AuthContext.tsx": 3, + "contexts/GlobalNotificationContext.tsx": 1, + "contexts/TenantNotificationContext.tsx": 1, + "hooks/useDiagnosticsPathsCatalog.ts": 1, + "hooks/useTenantList.ts": 1, +}; + +export const TOKENEXPIRED_BASELINE: Record = { + "app/admin/AdminConfigContext.tsx": 6, + "app/admin/backups/components/RestoreRowDiffModal.tsx": 1, + "app/admin/backups/detail/page.tsx": 2, + "app/admin/backups/page.tsx": 3, + "app/admin/components/ConfigReseedSection.tsx": 4, + "app/admin/components/DeviceBlockSection.tsx": 6, + "app/admin/components/DistressReportsSection.tsx": 1, + "app/admin/components/FeedbackSection.tsx": 1, + "app/admin/components/IdentityBindingsSection.tsx": 1, + "app/admin/components/MaintenanceTriggerSection.tsx": 2, + "app/admin/components/MappedSoftwareTab.tsx": 3, + "app/admin/components/OpsEventsSection.tsx": 1, + "app/admin/components/SessionExportSection.tsx": 1, + "app/admin/components/SessionReportsSection.tsx": 3, + "app/admin/components/TenantAdminSection.tsx": 5, + "app/admin/components/TenantManagementSection.tsx": 6, + "app/admin/components/UnmappedSoftwareTab.tsx": 2, + "app/admin/components/VersionBlockSection.tsx": 3, + "app/admin/components/VulnerabilityDataSection.tsx": 5, + "app/admin/customs-archive/detail/page.tsx": 1, + "app/admin/customs-archive/page.tsx": 2, + "app/admin/metrics/sections/SectionAgentMetrics.tsx": 1, + "app/admin/metrics/sections/SectionImePatternHealth.tsx": 1, + "app/admin/metrics/sections/SectionMcpUsage.tsx": 1, + "app/admin/metrics/sections/SectionPlatformUsage.tsx": 1, + "app/admin/metrics/sections/SectionVerdictCalibration.tsx": 1, + "app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx": 2, + "app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 2, + "app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx": 2, + "app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx": 1, + "app/admin/ops/session-cleanup/page.tsx": 3, + "app/admin/presence/page.tsx": 1, + "app/admin/settings/sections/SectionDelegatedAdmins.tsx": 1, + "app/admin/settings/sections/SectionEmailTemplates.tsx": 1, + "app/admin/settings/sections/SectionTenantGroups.tsx": 1, + "app/admin/settings/sections/SectionUsagePlans.tsx": 2, + "app/admin/tenants/sections/SectionTenantConfigReport.tsx": 2, + "app/apps/components/InventoryTab.tsx": 1, + "app/apps/components/VulnerabilitiesTab.tsx": 1, + "app/apps/detail/page.tsx": 2, + "app/audit/page.tsx": 1, + "app/dashboard/hooks/useBlockDevice.ts": 2, + "app/dashboard/hooks/useDashboardSessions.ts": 3, + "app/dashboard/hooks/useDashboardStats.ts": 1, + "app/dashboard/hooks/useDeleteSession.ts": 2, + "app/dashboard/hooks/useTenantSecurityConfig.ts": 1, + "app/delegations/accept/page.tsx": 1, + "app/diagnosis/page.tsx": 3, + "app/fleet-health/hooks/useFleetHealth.ts": 1, + "app/fleet-health/page.tsx": 1, + "app/geographic-performance/page.tsx": 1, + "app/geographic-performance/sessions/page.tsx": 1, + "app/health-check/page.tsx": 1, + "app/progress/hooks/useProgressEvents.ts": 1, + "app/progress/hooks/useProgressSearch.ts": 1, + "app/sessions/components/CollectLogsButton.tsx": 1, + "app/sessions/components/SessionAnnotationsCard.tsx": 1, + "app/sessions/hooks/useSessionDetail.ts": 1, + "app/sessions/hooks/useSessionEvents.ts": 1, + "app/sessions/page.tsx": 1, + "app/settings/TenantConfigContext.tsx": 16, + "app/settings/components/McpUsersSection.tsx": 6, + "app/settings/components/OffboardingSection.tsx": 1, + "app/settings/reporting/sections/SectionMcpUsage.tsx": 1, + "app/settings/tenant/sections/SectionDelegatedAccess.tsx": 1, + "app/settings/tenant/sections/SectionSubmitLogs.tsx": 1, + "app/sla/page.tsx": 1, + "app/usage-metrics/page.tsx": 1, +}; diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.test.ts new file mode 100644 index 00000000..dd070417 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { + AUTHENTICATEDFETCH_BASELINE, + DEDUPEDAUTHFETCH_BASELINE, + JSONPARSE_BASELINE, + PERMANENT, + TOKENEXPIRED_BASELINE, +} from "./apiClient.guard.baseline"; + +/** + * Ratchet for the one API call layer (lib/apiClient.ts, D-203): pages, components, hooks and + * contexts call the backend through fetchJson/fetchOk/fetchBlob and render failures through + * describeApiError/apiErrorText/notifyError. Raw `authenticatedFetch(`, `.json()` parses and + * `instanceof TokenExpiredError` branches are legacy; their per-file counts live in the baseline + * module and may only go down. A count above its baseline (or a file not in the baseline) is a new + * raw site; a count below it is a stale baseline entry that must be lowered — so the baseline + * always states the true remaining debt. + * + * Comment lines are ignored; test files are excluded (they stub fetch deliberately). + */ + +const WEB_ROOT = join(__dirname, "..", ".."); +const SCAN_DIRS = ["app", "components", "hooks", "contexts"]; +/** The lib-level hook is itself a fetchJson consumer and the one place allowed to spell TokenExpiredError. */ +const EXEMPT_FILES = new Set(["hooks/useAuthenticatedFetch.ts"]); + +interface Pattern { + name: string; + regex: RegExp; + baseline: Record; + remedy: string; +} + +const PATTERNS: Pattern[] = [ + { + name: "authenticatedFetch(", + regex: /\bauthenticatedFetch\(/g, + baseline: AUTHENTICATEDFETCH_BASELINE, + remedy: "call fetchJson/fetchOk/fetchBlob from @/lib/apiClient instead", + }, + { + name: "dedupedAuthFetch(", + regex: /\bdedupedAuthFetch\(/g, + baseline: DEDUPEDAUTHFETCH_BASELINE, + remedy: "call dedupedFetchJson from @/lib/dedupedAuthFetch instead", + }, + { + name: ".json()", + regex: /\.json\(\)/g, + baseline: JSONPARSE_BASELINE, + remedy: "fetchJson parses the body; a hand parse hides the error envelope", + }, + { + name: "instanceof TokenExpiredError", + regex: /instanceof TokenExpiredError/g, + baseline: TOKENEXPIRED_BASELINE, + remedy: "notifyError/apiErrorText render a token expiry centrally; keep a branch only where the UI must act (route, reset)", + }, +]; + +function collectFiles(dir: string, acc: string[]): void { + for (const entry of readdirSync(dir)) { + if (entry === "node_modules" || entry === "__tests__" || entry.startsWith(".")) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + collectFiles(full, acc); + } else if (/\.(ts|tsx)$/.test(entry) && !/\.test\.(ts|tsx)$/.test(entry)) { + acc.push(full); + } + } +} + +function isCommentLine(line: string): boolean { + const trimmed = line.trimStart(); + return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*"); +} + +/** file (repo-relative, forward slashes) → code lines. */ +function scannedSources(): Map { + const files: string[] = []; + for (const dir of SCAN_DIRS) collectFiles(join(WEB_ROOT, dir), files); + const out = new Map(); + for (const file of files) { + const rel = relative(WEB_ROOT, file).replace(/\\/g, "/"); + if (EXEMPT_FILES.has(rel)) continue; + out.set( + rel, + readFileSync(file, "utf-8") + .split("\n") + .filter((l) => !isCommentLine(l)), + ); + } + return out; +} + +function countPerFile(sources: Map, regex: RegExp): Map { + const counts = new Map(); + for (const [rel, lines] of sources) { + let n = 0; + for (const line of lines) n += line.match(regex)?.length ?? 0; + if (n > 0) counts.set(rel, n); + } + return counts; +} + +const sources = scannedSources(); + +describe("apiClient ratchet", () => { + it("scans the app (plausibility floor)", () => { + expect(sources.size).toBeGreaterThan(50); + }); + + for (const pattern of PATTERNS) { + describe(pattern.name, () => { + const counts = countPerFile(sources, pattern.regex); + + it("has no raw site above its baseline", () => { + const violations: string[] = []; + for (const [rel, n] of counts) { + const allowed = pattern.baseline[rel] ?? 0; + if (n > allowed) violations.push(`${rel}: ${n} (baseline ${allowed})`); + } + expect( + violations, + `New raw \`${pattern.name}\` site(s) — ${pattern.remedy}:\n ${violations.join("\n ")}`, + ).toEqual([]); + }); + + it("has no stale baseline entry (the baseline states the true remaining debt)", () => { + const stale: string[] = []; + for (const [rel, allowed] of Object.entries(pattern.baseline)) { + const n = counts.get(rel) ?? 0; + if (n < allowed) stale.push(`${rel}: now ${n} (baseline ${allowed})`); + } + expect( + stale, + `Lower these entries in lib/__tests__/apiClient.guard.baseline.ts (${pattern.name}):\n ${stale.join("\n ")}`, + ).toEqual([]); + }); + }); + } + + it("never calls fetchJson without an explicit type argument", () => { + const violations: string[] = []; + for (const [rel, lines] of sources) { + lines.forEach((line, i) => { + if (/\b(?:fetchJson|dedupedFetchJson)\(/.test(line)) violations.push(`${rel}:${i + 1}`); + }); + } + expect(violations, "fetchJson(…) — name the wire type (utils/wire-types.generated):\n " + violations.join("\n ")).toEqual([]); + }); + + it("documents every permanent exception against a file that is still in a baseline", () => { + const inSomeBaseline = new Set(PATTERNS.flatMap((p) => Object.keys(p.baseline))); + const rotten = Object.keys(PERMANENT).filter((rel) => !inSomeBaseline.has(rel)); + expect(rotten, "PERMANENT names a file with no baseline entry left — drop the reason").toEqual([]); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.test.ts new file mode 100644 index 00000000..8510f87d --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ApiError, + apiErrorFromResponse, + apiErrorNotification, + apiErrorText, + describeApiError, + fetchBlob, + fetchJson, + fetchOk, +} from "../apiClient"; +import { TokenExpiredError } from "../authenticatedFetch"; + +vi.mock("../appInsights", () => ({ trackEvent: vi.fn() })); + +function jsonResponse(status: number, body: unknown, statusText = "Error", headers: Record = {}): Response { + return new Response(JSON.stringify(body), { status, statusText, headers: { "Content-Type": "application/json", ...headers } }); +} + +describe("apiErrorFromResponse", () => { + it("reads the error envelope: error, code, correlationId, hint, retryAfterSeconds", async () => { + const err = await apiErrorFromResponse( + jsonResponse(429, { + error: "Rate limit exceeded.", + code: "RateLimitExceeded", + correlationId: "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f", + hint: "Slow down.", + retryAfterSeconds: 12, + }), + ); + + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(429); + expect(err.message).toBe("Rate limit exceeded."); + expect(err.code).toBe("RateLimitExceeded"); + expect(err.correlationId).toBe("3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f"); + expect(err.hint).toBe("Slow down."); + expect(err.retryAfterSeconds).toBe(12); + }); + + it("takes Retry-After from the header when the body has no retryAfterSeconds", async () => { + const err = await apiErrorFromResponse(jsonResponse(503, { error: "Busy.", code: "ServiceUnavailable", correlationId: "c" }, "x", { "Retry-After": "5" })); + expect(err.retryAfterSeconds).toBe(5); + + const none = await apiErrorFromResponse(jsonResponse(503, { error: "Busy.", code: "ServiceUnavailable", correlationId: "c" })); + expect(none.retryAfterSeconds).toBeNull(); + }); + + it("falls back to the pre-envelope message, then to statusText; an empty body is not an exception", async () => { + const legacy = await apiErrorFromResponse(jsonResponse(400, { success: false, message: "tenantId is required" }, "Bad Request")); + expect(legacy.message).toBe("tenantId is required"); + expect(legacy.code).toBe(""); + expect(legacy.correlationId).toBe(""); + + const bare = await apiErrorFromResponse(new Response("not json", { status: 502, statusText: "Bad Gateway" })); + expect(bare.message).toBe("Bad Gateway"); + expect(bare.hint).toBeNull(); + + const empty = await apiErrorFromResponse(new Response("", { status: 500, statusText: "Internal Server Error" })); + expect(empty.message).toBe("Internal Server Error"); + }); +}); + +describe("fetchJson / fetchOk / fetchBlob", () => { + beforeEach(() => vi.stubGlobal("fetch", vi.fn())); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + const fetchMock = () => globalThis.fetch as ReturnType; + const getToken = () => vi.fn().mockResolvedValue("tok"); + + it("returns the parsed body on success and throws the envelope ApiError otherwise", async () => { + fetchMock().mockResolvedValueOnce(jsonResponse(200, { items: [1] })); + await expect(fetchJson<{ items: number[] }>("https://api/x", getToken())).resolves.toEqual({ items: [1] }); + + fetchMock().mockResolvedValueOnce(jsonResponse(403, { error: "Access denied.", code: "Forbidden", correlationId: "cid-1" })); + const err = await fetchJson("https://api/x", getToken()).catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).code).toBe("Forbidden"); + expect((err as ApiError).correlationId).toBe("cid-1"); + }); + + it("refuses an empty or malformed 2xx body instead of handing back undefined", async () => { + fetchMock().mockResolvedValueOnce(new Response("", { status: 200 })); + const empty = await fetchJson("https://api/x", getToken()).catch((e) => e); + expect(empty).toBeInstanceOf(ApiError); + expect((empty as ApiError).message).toBe("Empty response body"); + + fetchMock().mockResolvedValueOnce(new Response("", { status: 200 })); + const html = await fetchJson("https://api/x", getToken()).catch((e) => e); + expect((html as ApiError).message).toBe("Malformed response body"); + }); + + it("sets Content-Type: application/json for a string body unless the caller set one", async () => { + fetchMock().mockResolvedValue(jsonResponse(200, {})); + await fetchJson("https://api/x", getToken(), { method: "POST", body: JSON.stringify({ a: 1 }) }); + const headers1 = fetchMock().mock.calls[0][1].headers as Headers; + expect(headers1.get("Content-Type")).toBe("application/json"); + + await fetchOk("https://api/x", getToken(), { method: "PUT", body: "raw", headers: { "Content-Type": "text/plain" } }); + const headers2 = fetchMock().mock.calls[1][1].headers as Headers; + expect(headers2.get("Content-Type")).toBe("text/plain"); + + await fetchOk("https://api/x", getToken(), { method: "DELETE" }); + const headers3 = fetchMock().mock.calls[2][1].headers as Headers; + expect(headers3.has("Content-Type")).toBe(false); + }); + + it("fetchOk returns the response for status checks and throws on non-ok; fetchBlob returns the blob", async () => { + fetchMock().mockResolvedValueOnce(new Response("", { status: 202 })); + const res = await fetchOk("https://api/x", getToken(), { method: "POST" }); + expect(res.status).toBe(202); + + fetchMock().mockResolvedValueOnce(jsonResponse(409, { error: "Locked.", code: "Conflict", correlationId: "c" })); + await expect(fetchOk("https://api/x", getToken())).rejects.toBeInstanceOf(ApiError); + + fetchMock().mockResolvedValueOnce(new Response(new Blob(["zip"]), { status: 200 })); + const blob = await fetchBlob("https://api/x", getToken()); + expect(await blob.text()).toBe("zip"); + }); + + it("lets TokenExpiredError pass through untouched", async () => { + const noToken = vi.fn().mockResolvedValue(null); + await expect(fetchJson("https://api/x", noToken)).rejects.toBeInstanceOf(TokenExpiredError); + }); +}); + +describe("describeApiError / apiErrorText / apiErrorNotification", () => { + it("renders an ApiError with the short correlation id as reference", () => { + const err = new ApiError(500, "GetSessions failed.", "InternalError", "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f"); + expect(describeApiError(err)).toEqual({ message: "GetSessions failed.", reference: "3f2a9c1e" }); + expect(apiErrorText(err)).toBe("GetSessions failed. (Ref 3f2a9c1e)"); + }); + + it("adds the retry advice on 429 and 503", () => { + const limited = new ApiError(429, "Rate limit exceeded.", "RateLimitExceeded", "cid", null, 12); + expect(describeApiError(limited).message).toBe("Rate limit exceeded. Try again in 12 s."); + const busy = new ApiError(503, "", "ServiceUnavailable", "cid"); + expect(describeApiError(busy).message).toBe("Service temporarily unavailable. Try again shortly."); + }); + + it("has no reference for a token expiry or a plain Error, and uses the fallback for unknown values", () => { + expect(describeApiError(new TokenExpiredError()).reference).toBeNull(); + expect(describeApiError(new Error("boom"))).toEqual({ message: "boom", reference: null }); + expect(describeApiError(undefined, "Could not load.")).toEqual({ message: "Could not load.", reference: null }); + expect(describeApiError(new ApiError(502, "", "", ""), "Upstream failed.").message).toBe("Upstream failed."); + expect(apiErrorText(new Error("boom"))).toBe("boom"); + }); + + it("builds the notification: caller title and key, reference from the envelope; token expiry overrides both", () => { + const err = new ApiError(404, "Session not found.", "NotFound", "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f"); + expect(apiErrorNotification("Backend Error", err, "apps-list-error")).toEqual({ + type: "error", + title: "Backend Error", + message: "Session not found.", + key: "apps-list-error", + reference: "3f2a9c1e", + }); + expect(apiErrorNotification("Backend Error", new TokenExpiredError(), "apps-list-error")).toEqual({ + type: "error", + title: "Session Expired", + message: new TokenExpiredError().message, + key: "session-expired", + reference: undefined, + }); + expect(apiErrorNotification("Backend Error", "?", undefined, "Unable to load.").message).toBe("Unable to load."); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/scopedFetch.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/scopedFetch.test.ts deleted file mode 100644 index de60aeb3..00000000 --- a/src/Web/autopilot-monitor-web/lib/__tests__/scopedFetch.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ApiError, apiErrorFromResponse, describeApiError, fetchJson } from "../scopedFetch"; -import { TokenExpiredError } from "../authenticatedFetch"; - -vi.mock("../appInsights", () => ({ trackEvent: vi.fn() })); - -function jsonResponse(status: number, body: unknown, statusText = "Error"): Response { - return new Response(JSON.stringify(body), { status, statusText, headers: { "Content-Type": "application/json" } }); -} - -describe("apiErrorFromResponse", () => { - it("reads the error envelope: error, code, correlationId, hint", async () => { - const err = await apiErrorFromResponse( - jsonResponse(404, { error: "Session not found", code: "NotFound", correlationId: "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f", hint: "Check the id." }), - ); - - expect(err).toBeInstanceOf(ApiError); - expect(err.status).toBe(404); - expect(err.message).toBe("Session not found"); - expect(err.code).toBe("NotFound"); - expect(err.correlationId).toBe("3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f"); - expect(err.hint).toBe("Check the id."); - }); - - it("falls back to the pre-envelope message and then to statusText", async () => { - const legacy = await apiErrorFromResponse(jsonResponse(400, { success: false, message: "tenantId is required" }, "Bad Request")); - expect(legacy.message).toBe("tenantId is required"); - expect(legacy.code).toBe(""); - expect(legacy.correlationId).toBe(""); - - const bare = await apiErrorFromResponse(new Response("not json", { status: 502, statusText: "Bad Gateway" })); - expect(bare.message).toBe("Bad Gateway"); - expect(bare.hint).toBeNull(); - }); -}); - -describe("fetchJson", () => { - beforeEach(() => vi.stubGlobal("fetch", vi.fn())); - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it("returns the parsed body on success and throws the envelope ApiError otherwise", async () => { - const fetchMock = globalThis.fetch as ReturnType; - fetchMock.mockResolvedValueOnce(jsonResponse(200, { items: [1] })); - const getToken = vi.fn().mockResolvedValue("tok"); - - await expect(fetchJson<{ items: number[] }>("https://api/x", getToken)).resolves.toEqual({ items: [1] }); - - fetchMock.mockResolvedValueOnce(jsonResponse(403, { error: "Access denied.", code: "Forbidden", correlationId: "cid-1" })); - const err = await fetchJson("https://api/x", getToken).catch((e) => e); - expect(err).toBeInstanceOf(ApiError); - expect((err as ApiError).code).toBe("Forbidden"); - expect((err as ApiError).correlationId).toBe("cid-1"); - }); -}); - -describe("describeApiError", () => { - it("renders an ApiError with the short correlation id as reference", () => { - const err = new ApiError(500, "GetSessions failed.", "InternalError", "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f"); - expect(describeApiError(err)).toEqual({ message: "GetSessions failed.", reference: "3f2a9c1e" }); - }); - - it("has no reference for a token expiry or a plain Error, and uses the fallback for unknown values", () => { - expect(describeApiError(new TokenExpiredError()).reference).toBeNull(); - expect(describeApiError(new Error("boom"))).toEqual({ message: "boom", reference: null }); - expect(describeApiError(undefined, "Could not load.")).toEqual({ message: "Could not load.", reference: null }); - expect(describeApiError(new ApiError(502, "", "", ""), "Upstream failed.").message).toBe("Upstream failed."); - }); -}); diff --git a/src/Web/autopilot-monitor-web/lib/apiClient.ts b/src/Web/autopilot-monitor-web/lib/apiClient.ts new file mode 100644 index 00000000..c9ba8e36 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/apiClient.ts @@ -0,0 +1,164 @@ +import { authenticatedFetch, TokenExpiredError } from "./authenticatedFetch"; +import { shortCorrelationId } from "./correlationId"; +import type { ApiErrorResponse } from "@/utils/wire-types.generated"; + +/** + * The one API call layer above authenticatedFetch: ok-check, body parse and the backend's + * error envelope in one place, so no call site hand-rolls `if (!res.ok)`, `.json()` or an + * `instanceof TokenExpiredError` branch (guarded by lib/__tests__/apiClient.guard.test.ts). + * + * fetchJson — JSON body expected; throws ApiError on non-2xx, empty or malformed body + * fetchOk — action calls whose body nobody reads; returns the ok Response (status only) + * fetchBlob — downloads + * + * Every non-2xx body is the typed envelope `{ error, code, correlationId, hint?, + * retryAfterSeconds? }` (D-192); TokenExpiredError from authenticatedFetch passes through + * untouched and is rendered by describeApiError like any other failure. + */ + +export type GetAccessToken = (forceRefresh?: boolean) => Promise; + +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + /** Machine-readable code (Constants.ApiErrorCodes or a domain code class); "" when the body had none. */ + public readonly code: string = "", + /** The request's correlation id — the handle for the backend log; "" when the body had none. */ + public readonly correlationId: string = "", + public readonly hint: string | null = null, + /** Seconds the server asked us to wait (429/503); null when it sent none. */ + public readonly retryAfterSeconds: number | null = null, + ) { + super(message); + this.name = "ApiError"; + } +} + +/** + * What a non-2xx body may carry: the generated envelope plus `message`, which pre-envelope + * backends wrote. The `message` fallback is a deploy-window remnant (backend 1.5.1171 shipped the + * envelope on 2026-09-05); drop it once no older backend can answer this web build. + */ +type ErrorEnvelope = Partial & { message?: unknown }; + +const str = (v: unknown): string | null => (typeof v === "string" && v.length > 0 ? v : null); +const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); + +function retryAfterFromHeader(response: Response): number | null { + // Platform CORS may not expose the header to browser script; the body's retryAfterSeconds + // is the primary source, this is the best-effort second one. + const raw = response.headers?.get?.("Retry-After"); + if (!raw) return null; + const seconds = Number.parseInt(raw, 10); + return Number.isFinite(seconds) && seconds >= 0 ? seconds : null; +} + +/** Build an ApiError from a non-ok response: envelope fields when present, statusText otherwise. */ +export async function apiErrorFromResponse(response: Response): Promise { + let body: ErrorEnvelope | null = null; + try { + const text = await response.text(); + if (text.length > 0) body = JSON.parse(text) as ErrorEnvelope; + } catch { + /* not JSON */ + } + const message = str(body?.error) ?? str(body?.message) ?? response.statusText; + return new ApiError( + response.status, + message, + str(body?.code) ?? "", + str(body?.correlationId) ?? "", + str(body?.hint), + num(body?.retryAfterSeconds) ?? retryAfterFromHeader(response), + ); +} + +/** A string body without an explicit Content-Type is JSON — every backend route deserialises JSON. */ +function withJsonContentType(init: RequestInit | undefined): RequestInit | undefined { + if (typeof init?.body !== "string") return init; + const headers = new Headers(init.headers); + if (headers.has("Content-Type")) return init; + headers.set("Content-Type", "application/json"); + return { ...init, headers }; +} + +/** Parse the JSON body of an ok response; an empty or malformed body is an ApiError, never `undefined`. */ +export async function parseJsonBody(response: Response): Promise { + const text = await response.text(); + if (text.length === 0) throw new ApiError(response.status, "Empty response body"); + try { + return JSON.parse(text) as T; + } catch { + throw new ApiError(response.status, "Malformed response body"); + } +} + +export async function fetchJson(url: string, getAccessToken: GetAccessToken, init?: RequestInit): Promise { + const response = await authenticatedFetch(url, getAccessToken, withJsonContentType(init)); + if (!response.ok) throw await apiErrorFromResponse(response); + return parseJsonBody(response); +} + +/** For calls whose body nobody reads: resolves with the ok Response (for status checks), throws ApiError otherwise. */ +export async function fetchOk(url: string, getAccessToken: GetAccessToken, init?: RequestInit): Promise { + const response = await authenticatedFetch(url, getAccessToken, withJsonContentType(init)); + if (!response.ok) throw await apiErrorFromResponse(response); + return response; +} + +export async function fetchBlob(url: string, getAccessToken: GetAccessToken, init?: RequestInit): Promise { + const response = await authenticatedFetch(url, getAccessToken, init); + if (!response.ok) throw await apiErrorFromResponse(response); + return response.blob(); +} + +function retryAdvice(err: ApiError): string { + return err.retryAfterSeconds !== null && err.retryAfterSeconds > 0 + ? `Try again in ${err.retryAfterSeconds} s.` + : "Try again shortly."; +} + +/** + * One rendering for any thrown error: the user-facing message plus, for backend failures, + * the short correlation id as a reference the user can quote to support. 429 and 503 add the + * server's retry advice. Pass `reference` to addNotification so the bell shows it. + */ +export function describeApiError(err: unknown, fallback = "Request failed."): { message: string; reference: string | null } { + if (err instanceof TokenExpiredError) return { message: err.message, reference: null }; + if (err instanceof ApiError) { + let message = err.message || fallback; + if (err.status === 429) message = `${err.message || "Too many requests."} ${retryAdvice(err)}`; + else if (err.status === 503) message = `${err.message || "Service temporarily unavailable."} ${retryAdvice(err)}`; + return { message, reference: err.correlationId ? shortCorrelationId(err.correlationId) : null }; + } + if (err instanceof Error && err.message) return { message: err.message, reference: null }; + return { message: fallback, reference: null }; +} + +/** describeApiError as one string for inline error slots (`setError`): "message (Ref abcd1234)". */ +export function apiErrorText(err: unknown, fallback = "Request failed."): string { + const { message, reference } = describeApiError(err, fallback); + return reference ? `${message} (Ref ${reference})` : message; +} + +export interface ApiErrorNotification { + type: "error"; + title: string; + message: string; + key: string | undefined; + reference: string | undefined; +} + +/** + * The notification for a failed call. A token expiry always renders as "Session Expired" under + * one dedup key, whatever the caller's title — that is the one dialect every catch block used to + * hand-write. + */ +export function apiErrorNotification(title: string, err: unknown, key?: string, fallback?: string): ApiErrorNotification { + const { message, reference } = describeApiError(err, fallback); + if (err instanceof TokenExpiredError) { + return { type: "error", title: "Session Expired", message, key: "session-expired", reference: undefined }; + } + return { type: "error", title, message, key, reference: reference ?? undefined }; +} diff --git a/src/Web/autopilot-monitor-web/lib/dedupedAuthFetch.ts b/src/Web/autopilot-monitor-web/lib/dedupedAuthFetch.ts index 91ff91f9..d847a808 100644 --- a/src/Web/autopilot-monitor-web/lib/dedupedAuthFetch.ts +++ b/src/Web/autopilot-monitor-web/lib/dedupedAuthFetch.ts @@ -16,8 +16,7 @@ * body stream is independently readable. */ import { authenticatedFetch } from './authenticatedFetch'; - -type GetAccessToken = (forceRefresh?: boolean) => Promise; +import { apiErrorFromResponse, parseJsonBody, type GetAccessToken } from './apiClient'; const inFlight = new Map>(); @@ -47,6 +46,13 @@ export async function dedupedAuthFetch( return (await pending).clone(); } +/** dedupedAuthFetch with the apiClient contract: ApiError on non-ok, parsed JSON body otherwise. */ +export async function dedupedFetchJson(url: string, getAccessToken: GetAccessToken, init?: RequestInit): Promise { + const response = await dedupedAuthFetch(url, getAccessToken, init); + if (!response.ok) throw await apiErrorFromResponse(response); + return parseJsonBody(response); +} + /** * Test-only helper to clear the in-flight Map between tests so they don't * leak state into one another. diff --git a/src/Web/autopilot-monitor-web/lib/scopedFetch.ts b/src/Web/autopilot-monitor-web/lib/scopedFetch.ts deleted file mode 100644 index 6f001aaa..00000000 --- a/src/Web/autopilot-monitor-web/lib/scopedFetch.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { authenticatedFetch, TokenExpiredError } from "./authenticatedFetch"; -import { shortCorrelationId } from "./correlationId"; - -/** - * JSON fetch layer for the scope-migrated pages: authenticatedFetch + ok-check + body - * parse in one call, so call sites stop hand-rolling the response boilerplate. Throws - * ApiError on a non-ok response carrying the backend's error envelope - * (`{ error, code, correlationId, hint? }` — every non-2xx body since the 2026-09 envelope - * pass; TokenExpiredError from authenticatedFetch passes through untouched). - */ -export class ApiError extends Error { - constructor( - public readonly status: number, - message: string, - /** Machine-readable code (Constants.ApiErrorCodes or a domain code class); "" when the body had none. */ - public readonly code: string = "", - /** The request's correlation id — the handle for the backend log; "" when the body had none. */ - public readonly correlationId: string = "", - public readonly hint: string | null = null, - ) { - super(message); - this.name = "ApiError"; - } -} - -/** Shape of the backend error envelope (ApiErrorResponse and its specialised siblings). */ -interface ErrorEnvelope { - error?: unknown; - code?: unknown; - correlationId?: unknown; - hint?: unknown; - /** Pre-envelope bodies used `message`; still read for the deploy window between backend and web. */ - message?: unknown; -} - -const str = (v: unknown): string | null => (typeof v === "string" && v.length > 0 ? v : null); - -/** Build an ApiError from a non-ok response: envelope fields when present, statusText otherwise. */ -export async function apiErrorFromResponse(response: Response): Promise { - let body: ErrorEnvelope | null = null; - try { - body = (await response.json()) as ErrorEnvelope; - } catch { - /* not JSON */ - } - const message = str(body?.error) ?? str(body?.message) ?? response.statusText; - return new ApiError(response.status, message, str(body?.code) ?? "", str(body?.correlationId) ?? "", str(body?.hint)); -} - -export async function fetchJson( - url: string, - getAccessToken: (forceRefresh?: boolean) => Promise, - init?: RequestInit, -): Promise { - const response = await authenticatedFetch(url, getAccessToken, init); - if (!response.ok) { - throw await apiErrorFromResponse(response); - } - return (await response.json()) as T; -} - -/** - * One rendering for any thrown error: the user-facing message plus, for backend failures, - * the short correlation id as a reference the user can quote to support. Pass `reference` - * to addNotification so the bell shows it. - */ -export function describeApiError(err: unknown, fallback = "Request failed."): { message: string; reference: string | null } { - if (err instanceof TokenExpiredError) return { message: err.message, reference: null }; - if (err instanceof ApiError) { - return { message: err.message || fallback, reference: err.correlationId ? shortCorrelationId(err.correlationId) : null }; - } - if (err instanceof Error && err.message) return { message: err.message, reference: null }; - return { message: fallback, reference: null }; -}