diff --git a/web/src/components/Layout.test.tsx b/web/src/components/Layout.test.tsx new file mode 100644 index 0000000..acf737b --- /dev/null +++ b/web/src/components/Layout.test.tsx @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +// ----------------------------------------------------------------------------- +// UX-001 Phase 4 — Layout "Setup guide" re-entry button +// +// Phase 2 added a persistent "Setup guide" button to the sidebar so operators +// who dismissed the onboarding wizard (or closed it mid-flow) can always walk +// themselves back in. The button must: +// +// 1. Render with the accessible name "Setup guide". +// 2. On click, clear the `certctl:onboarding-dismissed` localStorage key so +// DashboardPage's first-run detection re-engages. +// 3. On click, navigate to `/?onboarding=1` — the query-param re-entry +// signal DashboardPage reads via useSearchParams. The query param is the +// contract between Layout and DashboardPage; without it, a user who +// already has certs + issuers would not see the wizard again. +// ----------------------------------------------------------------------------- + +// Intercept useNavigate so we can assert the destination path without having +// to configure every route segment the wizard might push to. +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +// Layout pulls auth state from AuthProvider to decide whether to render the +// logout button. Tests don't care about auth — stub the hook with an anonymous +// session so Layout renders without needing a real AuthProvider wrapper. +vi.mock('./AuthProvider', () => ({ + useAuth: () => ({ + loading: false, + authRequired: false, + authenticated: true, + authType: 'none', + user: '', + admin: false, + login: vi.fn(), + logout: vi.fn(), + error: null, + }), +})); + +// Imported after vi.mock so the mocks are in effect when Layout's module graph +// resolves. +import Layout from './Layout'; + +function renderLayout() { + return render( + + + }> + root} /> + + + , + ); +} + +describe('Layout — UX-001 Setup guide sidebar button', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('renders a "Setup guide" button in the sidebar', () => { + renderLayout(); + + // Red-to-green guard: if the button is removed or renamed, this catches + // it. We match by accessible name so the assertion survives className / + // icon churn. + expect(screen.getByRole('button', { name: /Setup guide/i })).toBeInTheDocument(); + }); + + it('clears the onboarding-dismissed localStorage key on click', () => { + localStorage.setItem('certctl:onboarding-dismissed', 'true'); + expect(localStorage.getItem('certctl:onboarding-dismissed')).toBe('true'); + + renderLayout(); + fireEvent.click(screen.getByRole('button', { name: /Setup guide/i })); + + // DashboardPage reads this key synchronously to decide whether the first- + // run wizard can auto-open. Leaving it set would suppress the wizard even + // after navigation, defeating the re-entry contract. + expect(localStorage.getItem('certctl:onboarding-dismissed')).toBeNull(); + }); + + it('navigates to /?onboarding=1 on click', () => { + renderLayout(); + fireEvent.click(screen.getByRole('button', { name: /Setup guide/i })); + + // The `?onboarding=1` query param is the explicit signal DashboardPage + // checks via useSearchParams. Asserting the exact path pins the contract + // both ends rely on. + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith('/?onboarding=1'); + }); + + it('tolerates localStorage access failure without throwing', () => { + // Some browsers / privacy modes throw on localStorage access. Layout + // wraps the removal in try/catch so the navigation still fires. Simulate + // the failure and verify the navigation path is unaffected. + const original = Storage.prototype.removeItem; + Storage.prototype.removeItem = vi.fn(() => { + throw new Error('localStorage unavailable'); + }); + + try { + renderLayout(); + fireEvent.click(screen.getByRole('button', { name: /Setup guide/i })); + + expect(mockNavigate).toHaveBeenCalledWith('/?onboarding=1'); + } finally { + Storage.prototype.removeItem = original; + } + }); +}); diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 1923697..b789e1f 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { NavLink, Outlet } from 'react-router-dom'; +import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { useAuth } from './AuthProvider'; import logo from '../assets/certctl-logo.png'; @@ -35,6 +35,12 @@ function Icon({ d }: { d: string }) { export default function Layout() { const { authRequired, logout } = useAuth(); + const navigate = useNavigate(); + + const openSetupGuide = () => { + try { localStorage.removeItem('certctl:onboarding-dismissed'); } catch { /* noop */ } + navigate('/?onboarding=1'); + }; return (
@@ -71,6 +77,18 @@ export default function Layout() { ))} +
+ +
+
certctl {authRequired && ( diff --git a/web/src/pages/DashboardPage.test.tsx b/web/src/pages/DashboardPage.test.tsx new file mode 100644 index 0000000..a6baf70 --- /dev/null +++ b/web/src/pages/DashboardPage.test.tsx @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// ----------------------------------------------------------------------------- +// UX-001 Phase 4 — DashboardPage onboarding wizard entry/exit contract +// +// DashboardPage is the arbiter of when the OnboardingWizard is visible. Two +// triggers must both work: +// +// 1. First-run detection: no certificates + no user-configured issuers + +// no dismissal flag → auto-open the wizard. +// +// 2. Re-entry via URL: `?onboarding=1` query param forces the wizard open +// even for users who already have certs or have previously dismissed it. +// This is the other end of the Layout "Setup guide" contract tested in +// Layout.test.tsx. +// +// And the close path must clean up after itself: +// +// 3. On dismiss, DashboardPage must (a) set +// `certctl:onboarding-dismissed=true` in localStorage so it doesn't +// auto-reopen on refresh, and (b) strip the `?onboarding=1` query param +// via setSearchParams(..., { replace: true }) so a subsequent refresh +// doesn't relaunch the wizard either. +// ----------------------------------------------------------------------------- + +// Mock the entire API client. vi.mock factories are hoisted above the imports +// that follow, so these stubs are in effect when DashboardPage's module graph +// resolves. +vi.mock('../api/client', () => ({ + getCertificates: vi.fn(), + getAgents: vi.fn(), + getJobs: vi.fn(), + getNotifications: vi.fn(), + getHealth: vi.fn(), + getDashboardSummary: vi.fn(), + getCertificatesByStatus: vi.fn(), + getExpirationTimeline: vi.fn(), + getJobTrends: vi.fn(), + getIssuanceRate: vi.fn(), + previewDigest: vi.fn(), + sendDigest: vi.fn(), + getIssuers: vi.fn(), +})); + +// Replace OnboardingWizard with a paper-thin stub so these tests exercise the +// entry/exit contract without pulling OnboardingWizard's (heavy) query tree +// and step machinery into the fixture. OnboardingWizard's own behaviour is +// covered in OnboardingWizard.test.tsx. +vi.mock('./OnboardingWizard', () => ({ + default: ({ onDismiss }: { onDismiss: () => void }) => ( +
+ +
+ ), +})); + +import DashboardPage from './DashboardPage'; +import * as client from '../api/client'; + +// Location probe: renders current pathname+search into the DOM so we can +// assert that setSearchParams(next, { replace: true }) actually stripped the +// `?onboarding=1` query param without relying on router internals. +function LocationProbe() { + const loc = useLocation(); + return ( +
+ {loc.pathname} + {loc.search} +
+ ); +} + +function renderDashboard(initialEntry = '/') { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + }, + }); + return render( + + + + + } /> + + + , + ); +} + +// Canonical "empty but well-formed" stubs for every query DashboardPage calls +// unconditionally. Returning data (rather than leaving queries pending) lets +// the component pass its `summary !== undefined && issuersData !== undefined` +// gate and compute isFirstRun in a single render pass. +function stubAllQueriesEmpty() { + vi.mocked(client.getHealth).mockResolvedValue({ status: 'healthy' } as never); + vi.mocked(client.getDashboardSummary).mockResolvedValue({ + total_certificates: 0, + expiring_certificates: 0, + expired_certificates: 0, + revoked_certificates: 0, + active_agents: 0, + pending_jobs: 0, + completed_jobs: 0, + failed_jobs: 0, + notifications_dead: 0, + } as never); + vi.mocked(client.getIssuers).mockResolvedValue({ + data: [], + total: 0, + page: 1, + per_page: 100, + } as never); + vi.mocked(client.getCertificatesByStatus).mockResolvedValue([] as never); + vi.mocked(client.getExpirationTimeline).mockResolvedValue([] as never); + vi.mocked(client.getJobTrends).mockResolvedValue([] as never); + vi.mocked(client.getIssuanceRate).mockResolvedValue([] as never); + vi.mocked(client.getCertificates).mockResolvedValue({ + data: [], + total: 0, + page: 1, + per_page: 100, + } as never); + vi.mocked(client.getJobs).mockResolvedValue({ + data: [], + total: 0, + page: 1, + per_page: 100, + } as never); +} + +describe('DashboardPage — UX-001 onboarding wizard entry/exit', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + localStorage.clear(); + stubAllQueriesEmpty(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('auto-opens the wizard on first run (no certs, no user-configured issuers, no dismissal)', async () => { + renderDashboard('/'); + + // First-run detection runs after the summary + issuers queries resolve. + // The wizard is gated behind a setTimeout(..., 0) in DashboardPage to + // avoid setState-during-render, so we waitFor the stub to appear. + await waitFor(() => { + expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument(); + }); + }); + + it('opens the wizard when URL has ?onboarding=1, even with dismissal flag set', async () => { + // Simulate a user who dismissed the wizard previously and is now clicking + // the sidebar "Setup guide" button, which navigates to /?onboarding=1. + localStorage.setItem('certctl:onboarding-dismissed', 'true'); + + // Give the fixture non-zero counts so first-run detection would *not* + // fire on its own. Only the query-param override should open the wizard. + vi.mocked(client.getDashboardSummary).mockResolvedValue({ + total_certificates: 42, + expiring_certificates: 0, + expired_certificates: 0, + revoked_certificates: 0, + active_agents: 3, + pending_jobs: 0, + completed_jobs: 0, + failed_jobs: 0, + notifications_dead: 0, + } as never); + vi.mocked(client.getIssuers).mockResolvedValue({ + data: [{ id: 'iss-prod', name: 'Prod ACME', type: 'ACME', source: 'database' }], + total: 1, + page: 1, + per_page: 100, + } as never); + + renderDashboard('/?onboarding=1'); + + await waitFor(() => { + expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument(); + }); + }); + + it('onDismiss sets localStorage flag and strips ?onboarding=1 from the URL', async () => { + renderDashboard('/?onboarding=1'); + + // Wait for the wizard to open. + await waitFor(() => { + expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument(); + }); + + // Sanity: URL still carries the re-entry signal before dismiss. + expect(screen.getByTestId('location-probe').textContent).toContain('onboarding=1'); + + fireEvent.click(screen.getByTestId('wizard-dismiss-btn')); + + // After dismiss: localStorage is set, so a future refresh won't re-open. + await waitFor(() => { + expect(localStorage.getItem('certctl:onboarding-dismissed')).toBe('true'); + }); + + // The wizard is torn down. + await waitFor(() => { + expect(screen.queryByTestId('onboarding-wizard')).not.toBeInTheDocument(); + }); + + // And the `?onboarding=1` query param is stripped via replace: true so + // refreshing the page won't reopen the wizard via the URL path either. + await waitFor(() => { + const probe = screen.getByTestId('location-probe').textContent ?? ''; + expect(probe).not.toContain('onboarding=1'); + }); + }); + + it('does not open the wizard when dismissed and URL has no onboarding param', async () => { + localStorage.setItem('certctl:onboarding-dismissed', 'true'); + renderDashboard('/'); + + // Give queries a tick to settle. + await waitFor(() => { + expect(screen.getByText(/System healthy|Checking system status/i)).toBeInTheDocument(); + }); + + // The wizard should NOT have auto-opened — dismissal flag is respected + // when the URL doesn't carry the override signal. + expect(screen.queryByTestId('onboarding-wizard')).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 22bddc1..97dbc2b 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useQuery, useMutation } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, @@ -162,6 +162,7 @@ function DigestCard() { export default function DashboardPage() { const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); // Onboarding wizard state: once shown, stays shown until explicitly dismissed. // Uses a ref to "latch" the first-run detection so query refetches don't yank the wizard away. @@ -170,6 +171,10 @@ export default function DashboardPage() { }); const [showWizard, setShowWizard] = useState(false); + // Re-entry signal: sidebar "Setup guide" button navigates to /?onboarding=1 to reopen the wizard + // even after dismissal. Takes precedence over localStorage dismissal; stripped on close. + const forceOnboarding = searchParams.get('onboarding') === '1'; + // All hooks must be called unconditionally (React rules of hooks — no hooks after early returns) const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 }); const { data: summary } = useQuery({ queryKey: ['dashboard-summary'], queryFn: getDashboardSummary, refetchInterval: 30000 }); @@ -190,17 +195,23 @@ export default function DashboardPage() { summary.total_certificates === 0 && userConfiguredIssuers.length === 0; - if (isFirstRun && !showWizard) { + if ((isFirstRun || forceOnboarding) && !showWizard) { // Can't call setState during render — use a microtask setTimeout(() => setShowWizard(true), 0); } - if (showWizard && !onboardingDismissed) { + if ((showWizard && !onboardingDismissed) || forceOnboarding) { return ( { try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ } setOnboardingDismissed(true); setShowWizard(false); + // Strip ?onboarding=1 so page refresh doesn't relaunch the wizard + if (searchParams.has('onboarding')) { + const next = new URLSearchParams(searchParams); + next.delete('onboarding'); + setSearchParams(next, { replace: true }); + } }} /> ); } diff --git a/web/src/pages/OnboardingWizard.test.tsx b/web/src/pages/OnboardingWizard.test.tsx new file mode 100644 index 0000000..c8807a8 --- /dev/null +++ b/web/src/pages/OnboardingWizard.test.tsx @@ -0,0 +1,311 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; + +// ----------------------------------------------------------------------------- +// UX-001 Phase 3 — CertificateStep inline team + owner creation contract +// +// The wizard has to satisfy C-001's six required certificate fields (name, +// common_name, issuer_id, owner_id, team_id, renewal_policy_id). Before Phase +// 3, a fresh install had no teams + no owners, so the two required `` auto- +// selects the new team's id. +// 3. "+ New owner" does the same for owners. +// 4. Cancel closes the modal without firing the mutation — pins the +// "nothing leaks on abort" guarantee. +// +// DashboardPage's outer wizard entry/exit contract is covered in +// DashboardPage.test.tsx. Layout's sidebar re-entry button is covered in +// Layout.test.tsx. +// ----------------------------------------------------------------------------- + +// Mock the entire API client. vi.mock factories are hoisted above the imports +// that follow, so these stubs are in effect when OnboardingWizard's module +// graph resolves. +vi.mock('../api/client', () => ({ + getApiKey: vi.fn(() => 'test-api-key'), + getIssuers: vi.fn(), + getAgents: vi.fn(), + getProfiles: vi.fn(), + getOwners: vi.fn(), + getTeams: vi.fn(), + getPolicies: vi.fn(), + createIssuer: vi.fn(), + testIssuerConnection: vi.fn(), + createCertificate: vi.fn(), + triggerRenewal: vi.fn(), + createTeam: vi.fn(), + createOwner: vi.fn(), +})); + +import OnboardingWizard from './OnboardingWizard'; +import * as client from '../api/client'; + +function renderWizard() { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + }, + }); + return render( + + + + + , + ); +} + +// Canonical "empty but well-formed" stubs for every query the wizard calls. +// Returning data (rather than leaving queries pending) lets CertificateStep's +// dropdowns render their placeholder options immediately. +function stubAllQueriesEmpty() { + vi.mocked(client.getIssuers).mockResolvedValue({ + data: [], total: 0, page: 1, per_page: 100, + } as never); + vi.mocked(client.getAgents).mockResolvedValue({ + data: [], total: 0, page: 1, per_page: 100, + } as never); + vi.mocked(client.getProfiles).mockResolvedValue({ + data: [], total: 0, page: 1, per_page: 100, + } as never); + vi.mocked(client.getOwners).mockResolvedValue({ + data: [], total: 0, page: 1, per_page: 500, + } as never); + vi.mocked(client.getTeams).mockResolvedValue({ + data: [], total: 0, page: 1, per_page: 500, + } as never); + vi.mocked(client.getPolicies).mockResolvedValue({ + data: [], total: 0, page: 1, per_page: 500, + } as never); +} + +// Drive through Skip to reach CertificateStep. Each step renders its own +// "Skip this step" button in the WizardFooter; clicking it advances via the +// parent goTo() state machine. "Skip setup" in the header (top-right) is a +// different button tied to onDismiss and is intentionally not clicked here. +async function advanceToCertificateStep() { + // IssuerStep renders first. Wait for its heading before clicking skip so + // we don't race ahead of the initial render. + await waitFor(() => { + expect( + screen.getByRole('heading', { name: /Connect a Certificate Authority/i }), + ).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: /Skip this step/i })); + + // AgentStep — wait for its heading, then skip. + await waitFor(() => { + expect( + screen.getByRole('heading', { name: /Deploy a certctl Agent/i }), + ).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: /Skip this step/i })); + + // CertificateStep — wait for its heading. Caller can now exercise the + // inline modals. + await waitFor(() => { + expect( + screen.getByRole('heading', { name: /Add a Certificate/i }), + ).toBeInTheDocument(); + }); +} + +describe('OnboardingWizard — UX-001 inline team + owner creation in CertificateStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + stubAllQueriesEmpty(); + }); + + it('skip-skip reaches CertificateStep with "+ New team" and "+ New owner" buttons', async () => { + renderWizard(); + await advanceToCertificateStep(); + + // The contract buttons are the whole point of UX-001 Phase 3 — if they + // disappear, the dead-end is back. + expect(screen.getByRole('button', { name: /\+ New team/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /\+ New owner/i })).toBeInTheDocument(); + }); + + it('+ New team opens the inline modal, calls createTeam, invalidates the cache, and auto-selects the new team', async () => { + // Drive getTeams from a closure variable so React Query's post-mutation + // refetch observes the newly-created team. Without this, the parent + // auto-selected the new team. Locate the select by + // finding the new team's
+
+ +