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 }; -}