diff --git a/frontend/src/__tests__/lib/api-hooks.test.ts b/frontend/src/__tests__/lib/api-hooks.test.ts index 26017d541..3fd63c52c 100644 --- a/frontend/src/__tests__/lib/api-hooks.test.ts +++ b/frontend/src/__tests__/lib/api-hooks.test.ts @@ -75,6 +75,25 @@ describe('internal api hooks', () => { expect(new Headers(scoreInit.headers).get('X-RegEngine-API-Key')).toBeNull(); }); + it('rejects a 401 on workbench readiness once, with a machine-readable auth status (SEL-616)', async () => { + fetchWithCsrfMock.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers(), + }); + + const { fetchWorkbenchReadinessSummary } = await import('@/lib/api-hooks'); + const { getErrorStatus, isAuthError } = await import('@/lib/api-error'); + const error = await fetchWorkbenchReadinessSummary('tenant-123').catch((err: unknown) => err); + + expect(error).toBeInstanceOf(Error); + expect(getErrorStatus(error)).toBe(401); + expect(isAuthError(error)).toBe(true); + // 401 is final: no retry/backoff on the readiness path. + expect(fetchWithCsrfMock).toHaveBeenCalledTimes(1); + }); + it('throws client errors immediately without retrying them', async () => { fetchWithCsrfMock.mockResolvedValue({ ok: false, diff --git a/frontend/src/__tests__/pages/dashboard-load-errors.test.tsx b/frontend/src/__tests__/pages/dashboard-load-errors.test.tsx index e7e3d297a..f8dc86ae3 100644 --- a/frontend/src/__tests__/pages/dashboard-load-errors.test.tsx +++ b/frontend/src/__tests__/pages/dashboard-load-errors.test.tsx @@ -53,7 +53,11 @@ vi.mock('@/hooks/use-agent-reviews', () => ({ vi.mock('@/hooks/use-dashboard-refresh', () => ({ useDashboardRefresh: vi.fn() })); vi.mock('next/link', () => ({ default: ({ children, href }: { children: React.ReactNode; href: string }) => {children} })); vi.mock('framer-motion', () => ({ - motion: { div: ({ children, ...props }: React.PropsWithChildren>) =>
{children}
}, + motion: { + div: ({ children }: React.PropsWithChildren>) =>
{children}
, + li: ({ children }: React.PropsWithChildren>) =>
  • {children}
  • , + circle: () => , + }, AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}, })); @@ -223,11 +227,47 @@ describe('shared dashboard load errors', () => { const user = userEvent.setup(); renderWithQuery(); - expect(await screen.findByRole('alert')).toHaveTextContent('We could not load this page.'); + expect(await screen.findByRole('alert')).toHaveTextContent('We could not load the compliance score.'); + // SEL-616: score failure must not blank Import readiness / setup paths + expect(await screen.findByText('Import readiness')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Retry' })); await waitFor(() => expect(fetchComplianceScoreMock).toHaveBeenCalledTimes(2)); }); + it('keeps Import readiness visible when score and readiness both fail (SEL-616)', async () => { + fetchComplianceScoreMock.mockRejectedValue(apiError(401, 'Unauthorized', crypto.randomUUID())); + fetchWorkbenchSummaryMock.mockRejectedValue(apiError(503, 'Service Unavailable', crypto.randomUUID())); + renderWithQuery(); + + expect(await screen.findByRole('alert')).toHaveTextContent('We could not load the compliance score.'); + expect(await screen.findByText('Import readiness')).toBeInTheDocument(); + expect(screen.getByText(/Import readiness is unavailable right now/i)).toBeInTheDocument(); + expect(screen.queryByText('We could not load this page.')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry import readiness' })).toBeInTheDocument(); + }); + + it('rejects a 401 on readiness without showing placeholder readiness data (SEL-616)', async () => { + fetchComplianceScoreMock.mockResolvedValue({ + tenant_id: 't', + overall_score: 82, + grade: 'B', + breakdown: {}, + next_actions: [], + events_analyzed: 10, + last_chain_hash: null, + }); + fetchWorkbenchSummaryMock.mockRejectedValue(apiError(401, 'Unauthorized', crypto.randomUUID())); + renderWithQuery(); + + expect(await screen.findByText(/Import readiness is unavailable right now/i)).toBeInTheDocument(); + // An unauthorized readiness request must not render as an empty-but-healthy workspace. + expect(screen.queryByText('No saved preflight run yet')).not.toBeInTheDocument(); + expect(screen.queryByText(/open fix/)).not.toBeInTheDocument(); + expect(screen.queryByText('We could not load this page.')).not.toBeInTheDocument(); + expect(screen.queryByText('We could not load the compliance score.')).not.toBeInTheDocument(); + expect(fetchWorkbenchSummaryMock).toHaveBeenCalledTimes(1); + }); + it('lets recall report retry a failed request in place', async () => { fetchRecallReportMock.mockRejectedValue(new Error('server error')); const user = userEvent.setup(); diff --git a/frontend/src/app/dashboard/compliance/page.tsx b/frontend/src/app/dashboard/compliance/page.tsx index d439be603..2cd1f241a 100644 --- a/frontend/src/app/dashboard/compliance/page.tsx +++ b/frontend/src/app/dashboard/compliance/page.tsx @@ -321,6 +321,7 @@ export default function ComplianceDashboardPage() { const { data: workbenchSummary = null, + isError: readinessFailed, refetch: fetchWorkbenchSummary, } = useQuery({ queryKey: ['inflow-workbench-readiness', tenantId], @@ -330,7 +331,8 @@ export default function ComplianceDashboardPage() { retry: false, }); - const error = scoreError; + // Score and workbench failures stay sectional so one outage cannot blank the page + // (SEL-616: readiness/summary 503 must not look like a full Compliance crash). const lastFetched = dataUpdatedAt ? new Date(dataUpdatedAt) : null; // Ordered breakdown keys by weight (highest first) @@ -381,12 +383,12 @@ export default function ComplianceDashboardPage() {

    - {lastFetched && !error && ( + {lastFetched && !scoreError && ( Updated {lastFetched.toLocaleTimeString()} )} - {score && !error && ( + {score && !scoreError && ( + + ) : ( + <> +
    +
    +
    + {workbenchSummary?.score ?? NO_DATA_YET} +
    +

    + {workbenchSummary?.label ?? 'No saved preflight run yet'} +

    +
    + 0 + ? 'bg-re-warning-muted0/10 text-re-warning' + : 'bg-re-brand-muted text-re-brand' + }`} + > + {workbenchSummary?.unresolved_fix_count ?? 0} open fix{(workbenchSummary?.unresolved_fix_count ?? 0) === 1 ? '' : 'es'} + +
    +
    +
    + Export eligible + {workbenchSummary?.export_eligible ? 'Yes' : 'Not yet'} +
    +
    + + )} + + )} {/* Score Content */} - {score && !loading && !error && ( + {score && !loading && !scoreError && ( {/* Status Banner */} @@ -512,40 +575,59 @@ export default function ComplianceDashboardPage() { Import readiness -
    -
    -
    +

    Couldn't load

    +

    + Import readiness is unavailable right now. The rest of this dashboard can still be used. +

    + + + ) : ( + <> +
    +
    +
    + {workbenchSummary?.score ?? NO_DATA_YET} +
    +

    + {workbenchSummary?.label ?? 'No saved preflight run yet'} +

    +
    + 0 + ? 'bg-re-warning-muted0/10 text-re-warning' + : 'bg-re-brand-muted text-re-brand' + }`} + > + {workbenchSummary?.unresolved_fix_count ?? 0} open fix{(workbenchSummary?.unresolved_fix_count ?? 0) === 1 ? '' : 'es'} +
    -

    - {workbenchSummary?.label ?? 'No saved preflight run yet'} -

    -
    - 0 - ? 'bg-re-warning-muted0/10 text-re-warning' - : 'bg-re-brand-muted text-re-brand' - }`} - > - {workbenchSummary?.unresolved_fix_count ?? 0} open fix{(workbenchSummary?.unresolved_fix_count ?? 0) === 1 ? '' : 'es'} - -
    -
    -
    - Export eligible - {workbenchSummary?.export_eligible ? 'Yes' : 'Not yet'} -
    -
    - Latest run - - {workbenchSummary?.run_id ? workbenchSummary.run_id.slice(0, 16) : 'none'} - -
    -
    +
    +
    + Export eligible + {workbenchSummary?.export_eligible ? 'Yes' : 'Not yet'} +
    +
    + Latest run + + {workbenchSummary?.run_id ? workbenchSummary.run_id.slice(0, 16) : 'none'} + +
    +
    + + )} @@ -756,8 +838,8 @@ export default function ComplianceDashboardPage() { )} - {/* Empty state - no score and not loading */} - {!score && !loading && !error && isLoggedIn && ( + {/* Empty / unavailable score — still offer setup paths (SEL-616) */} + {!score && !loading && isLoggedIn && ( (