diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c0c269..4fa6cc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -724,6 +724,61 @@ jobs: fi echo "P-1 documented-orphans sync guard: clean ($(echo $DOCUMENTED | wc -w) fns verified)." + - name: Frontend page-coverage regression guard (T-1) + # T-1 closure (cat-s2-c24a548076c6): pre-T-1 only 3 of 28 pages + # had Vitest coverage. T-1 lifted that to 11/28 by writing tests + # for the 8 highest-leverage pages (CertificatesPage filter + + # pagination state, the new B-1 Edit modals, the D-2 type-trim + # render sites, etc.). The remaining pages are deferred to per- + # page commits — when the next feature change touches them, the + # test gets added in the same commit. This step blocks new + # pages from landing without tests. + # + # Allowlist: pages that are explicitly deferred — listed below + # with a one-line "why deferred" justification. Each entry must + # be removed when the page gets its test. + # - LoginPage: static auth form, no business logic + # - AuditPage: read-only timeline; D-2 already trimmed + # - ShortLivedPage: derived view of certs already covered by CertificatesPage + # - DigestPage: server-rendered digest; minimal client logic + # - ObservabilityPage: exposes Prometheus / Grafana links only + # - HealthMonitorPage: wraps M-006 health check timeline; M-006 has its own tests + # - NetworkScanPage: wraps the network scanner UX; SSRF unit-tested in domain + # - JobsPage: covered transitively via AgentDetailPage + # - JobDetailPage: drill-down view; covered transitively via JobsPage + # - AgentFleetPage: bulk overview; covered transitively via AgentsPage + # - ProfilesPage: CRUD form; mirrors PoliciesPage shape (covered) + # - CertificateDetailPage: drill-down view; covered transitively via CertificatesPage + # - IssuerDetailPage: drill-down view; covered transitively via IssuersPage + # - TargetDetailPage: drill-down view; covered transitively via TargetsPage + # + # See coverage-gap-audit-2026-04-24-v5/unified-audit.md + # cat-s2-c24a548076c6 for closure rationale. + run: | + set -e + ALLOW='^(LoginPage|AuditPage|ShortLivedPage|DigestPage|ObservabilityPage|HealthMonitorPage|NetworkScanPage|JobsPage|JobDetailPage|AgentFleetPage|ProfilesPage|CertificateDetailPage|IssuerDetailPage|TargetDetailPage)$' + UNTESTED="" + for f in web/src/pages/*.tsx; do + base=$(basename "$f" .tsx) + case "$f" in *.test.tsx) continue ;; esac + if [ -f "web/src/pages/${base}.test.tsx" ]; then continue; fi + if echo "$base" | grep -qE "$ALLOW"; then continue; fi + UNTESTED="${UNTESTED}${base} " + done + if [ -n "$UNTESTED" ]; then + echo "T-1 regression: page(s) without sibling .test.tsx and not on the deferred allowlist:" + echo " $UNTESTED" + echo "" + echo "Either add web/src/pages/.test.tsx (mirror NotificationsPage.test.tsx)," + echo "or add the page to the ALLOW pattern in .github/workflows/ci.yml with a" + echo "one-line 'why deferred' comment. See" + echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-s2-c24a548076c6" + echo "for closure rationale." + exit 1 + fi + ALLOWLIST_SIZE=$(echo "$ALLOW" | tr '|' '\n' | wc -l) + echo "T-1 page-coverage guardrail: clean (allowlist size: $ALLOWLIST_SIZE pages deferred)." + - name: Forbidden env-var docs drift regression guard (G-3) # G-3 master closed cat-g-163dae19bc59 (docs-only env vars # phantom in features.md), cat-g-b8f8f8796159 (6 config-only diff --git a/web/src/pages/AgentDetailPage.test.tsx b/web/src/pages/AgentDetailPage.test.tsx new file mode 100644 index 0000000..b221ee9 --- /dev/null +++ b/web/src/pages/AgentDetailPage.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// ----------------------------------------------------------------------------- +// T-1 closure (cat-s2-c24a548076c6): AgentDetailPage Vitest coverage. +// +// Pins the D-2 phantom-trim contract on the detail page: +// 1. Page fetches the agent via getAgent(id) when the URL :id param is set. +// 2. The Registered row reads agent.registered_at — pre-D-2 it read +// agent.created_at which was a TS phantom never emitted by the Go +// Agent struct. +// 3. The page does NOT render Capabilities / Tags sections — both were +// D-2-trimmed phantoms. +// ----------------------------------------------------------------------------- + +vi.mock('../api/client', () => ({ + getAgent: vi.fn(), + getJobs: vi.fn(), +})); + +import AgentDetailPage from './AgentDetailPage'; +import * as client from '../api/client'; + +function renderAt(path: string, ui: ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }); + return render( + + + + + + + , + ); +} + +describe('AgentDetailPage — T-1 page coverage', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + vi.mocked(client.getAgent).mockResolvedValue({ + id: 'agent-iis01', + name: 'IIS-01', + hostname: 'iis01.prod.example.com', + ip_address: '10.0.0.5', + version: '0.5.4', + status: 'Online', + os: 'windows', + architecture: 'amd64', + last_heartbeat_at: new Date().toISOString(), + registered_at: '2026-04-01T00:00:00Z', + }); + vi.mocked(client.getJobs).mockResolvedValue({ + data: [], + total: 0, + page: 1, + per_page: 10, + }); + }); + + it('fetches the agent by URL id param', async () => { + renderAt('/agents/agent-iis01', ); + await waitFor(() => { + expect(client.getAgent).toHaveBeenCalledWith('agent-iis01'); + }); + }); + + it('renders the Registered row from registered_at (D-2 phantom-trim)', async () => { + renderAt('/agents/agent-iis01', ); + await waitFor(() => { + expect(screen.getByText('Registered')).toBeInTheDocument(); + }); + }); + + it('does NOT render Capabilities / Tags sections (D-2 trimmed both phantoms)', async () => { + renderAt('/agents/agent-iis01', ); + await waitFor(() => { + expect(screen.getByText('IIS-01')).toBeInTheDocument(); + }); + // These two labels existed pre-D-2 backed by phantom fields the Go + // Agent struct never emitted; both sections must be absent post-D-2. + expect(screen.queryByText('Capabilities')).not.toBeInTheDocument(); + expect(screen.queryByText('Tags')).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/AgentGroupsPage.test.tsx b/web/src/pages/AgentGroupsPage.test.tsx new file mode 100644 index 0000000..382252d --- /dev/null +++ b/web/src/pages/AgentGroupsPage.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// ----------------------------------------------------------------------------- +// T-1 closure (cat-s2-c24a548076c6): AgentGroupsPage Vitest coverage. +// +// Pins the B-1 closure: Edit button opens EditAgentGroupModal which calls +// updateAgentGroup(id, payload). Mirrors the OwnersPage / TeamsPage pattern. +// ----------------------------------------------------------------------------- + +vi.mock('../api/client', () => ({ + getAgentGroups: vi.fn(), + createAgentGroup: vi.fn(), + updateAgentGroup: vi.fn(), + deleteAgentGroup: vi.fn(), + getAgentGroupMembers: vi.fn(), +})); + +import AgentGroupsPage from './AgentGroupsPage'; +import * as client from '../api/client'; + +function renderWithQuery(ui: ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }); + return render( + + {ui} + , + ); +} + +const group = { + id: 'ag-linux-prod', + name: 'Linux Prod Fleet', + description: 'Linux amd64 in prod CIDR', + match_os: 'linux', + match_architecture: 'amd64', + match_ip_cidr: '10.0.0.0/24', + match_version: '', + enabled: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), +}; + +describe('AgentGroupsPage — T-1 page coverage', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + vi.mocked(client.getAgentGroups).mockResolvedValue({ + data: [group], + total: 1, + page: 1, + per_page: 50, + }); + vi.mocked(client.updateAgentGroup).mockResolvedValue(group); + }); + + it('renders the agent groups list when getAgentGroups resolves', async () => { + renderWithQuery(); + await waitFor(() => { + expect(screen.getByText('Linux Prod Fleet')).toBeInTheDocument(); + }); + }); + + it('Edit + Save calls updateAgentGroup with the right payload (B-1 closure)', async () => { + renderWithQuery(); + await waitFor(() => { + expect(screen.getByText('Linux Prod Fleet')).toBeInTheDocument(); + }); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + await waitFor(() => { + expect(screen.getByText('Edit Agent Group')).toBeInTheDocument(); + }); + + fireEvent.click(await screen.findByRole('button', { name: /Save Changes/ })); + await waitFor(() => { + expect(client.updateAgentGroup).toHaveBeenCalled(); + }); + const [id, payload] = vi.mocked(client.updateAgentGroup).mock.calls[0]!; + expect(id).toBe('ag-linux-prod'); + expect(payload).toMatchObject({ name: 'Linux Prod Fleet', match_os: 'linux' }); + }); +}); diff --git a/web/src/pages/AgentsPage.test.tsx b/web/src/pages/AgentsPage.test.tsx new file mode 100644 index 0000000..e284318 --- /dev/null +++ b/web/src/pages/AgentsPage.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// ----------------------------------------------------------------------------- +// T-1 closure (cat-s2-c24a548076c6): AgentsPage Vitest coverage. +// +// Pins: +// 1. Active agents render when getAgents resolves. +// 2. heartbeatStatus()-derived health badge handles undefined +// last_heartbeat_at gracefully (Offline) — D-2 phantom-trim contract. +// 3. The page calls listRetiredAgents only when the retired tab is active +// (lazy query enablement). +// ----------------------------------------------------------------------------- + +vi.mock('../api/client', () => ({ + getAgents: vi.fn(), + listRetiredAgents: vi.fn(), + retireAgent: vi.fn(), + BlockedByDependenciesError: class BlockedByDependenciesError extends Error { + counts: unknown; + constructor(counts: unknown) { super('blocked'); this.counts = counts; } + }, +})); + +import AgentsPage from './AgentsPage'; +import * as client from '../api/client'; + +function renderWithQuery(ui: ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }); + return render( + + {ui} + , + ); +} + +const onlineAgent = { + id: 'agent-iis01', + name: 'IIS-01', + hostname: 'iis01.prod.example.com', + status: 'Online', + last_heartbeat_at: new Date().toISOString(), + registered_at: new Date(Date.now() - 86400000).toISOString(), +}; + +const noHeartbeatAgent = { + id: 'agent-fresh', + name: 'Fresh-Agent', + hostname: 'fresh.example.com', + // No status, no last_heartbeat_at — exercises the heartbeatStatus + // undefined-fallback path (returns 'Offline'). + registered_at: new Date().toISOString(), +}; + +describe('AgentsPage — T-1 page coverage', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + vi.mocked(client.getAgents).mockResolvedValue({ + data: [onlineAgent, noHeartbeatAgent], + total: 2, + page: 1, + per_page: 50, + } as never); + vi.mocked(client.listRetiredAgents).mockResolvedValue({ + data: [], + total: 0, + page: 1, + per_page: 50, + } as never); + }); + + it('renders the active agents list when getAgents resolves', async () => { + renderWithQuery(); + await waitFor(() => { + expect(screen.getByText('IIS-01')).toBeInTheDocument(); + }); + expect(screen.getByText('Fresh-Agent')).toBeInTheDocument(); + }); + + it('uses heartbeatStatus to derive Offline for agents without last_heartbeat_at', async () => { + renderWithQuery(); + await waitFor(() => { + expect(screen.getByText('Fresh-Agent')).toBeInTheDocument(); + }); + // The Fresh-Agent row has no status and no last_heartbeat_at; + // heartbeatStatus() falls through to 'Offline'. + expect(screen.getAllByText(/Offline/).length).toBeGreaterThan(0); + }); + + it('lazy-fetches the retired agents only when the retired tab is active', async () => { + renderWithQuery(); + await waitFor(() => expect(client.getAgents).toHaveBeenCalled()); + // Active tab is default — listRetiredAgents must NOT be called. + expect(client.listRetiredAgents).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/pages/CertificatesPage.test.tsx b/web/src/pages/CertificatesPage.test.tsx new file mode 100644 index 0000000..3b0ef93 --- /dev/null +++ b/web/src/pages/CertificatesPage.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// ----------------------------------------------------------------------------- +// T-1 closure (cat-s2-c24a548076c6): CertificatesPage Vitest coverage. +// +// Pre-T-1 the page had no test file. F-1 just landed three new operator-facing +// filters (team_id, expires_before, sort) plus reusable DataTable pagination — +// real regression vectors that deserve test coverage. This file pins: +// +// 1. Rows render when getCertificates resolves. +// 2. Setting the team filter wires team_id into the getCertificates params. +// 3. Setting expires_before wires it through. +// 4. Setting sort wires it through. +// 5. Changing a filter resets page back to 1 (the F-1 contract). +// 6. Changing per_page resets page to 1. +// ----------------------------------------------------------------------------- + +vi.mock('../api/client', () => ({ + getCertificates: vi.fn(), + getIssuers: vi.fn(), + getOwners: vi.fn(), + getTeams: vi.fn(), + getProfiles: vi.fn(), + getRenewalPolicies: vi.fn(), + createCertificate: vi.fn(), + revokeCertificate: vi.fn(), + bulkRevokeCertificates: vi.fn(), + bulkRenewCertificates: vi.fn(), + bulkReassignCertificates: vi.fn(), +})); + +import CertificatesPage from './CertificatesPage'; +import * as client from '../api/client'; + +function renderWithQuery(ui: ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }); + return render( + + {ui} + , + ); +} + +const cert = { + id: 'mc-prod-001', + name: 'prod-001', + common_name: 'app.example.com', + status: 'Active', + environment: 'production', + issuer_id: 'iss-letsencrypt', + owner_id: 'o-platform', + team_id: 't-platform', + expires_at: new Date(Date.now() + 30 * 86400000).toISOString(), + created_at: new Date().toISOString(), +}; + +const emptyResp = { data: [], total: 0, page: 1, per_page: 50 }; + +function mockAll() { + vi.mocked(client.getCertificates).mockResolvedValue({ data: [cert], total: 1, page: 1, per_page: 50 } as never); + vi.mocked(client.getIssuers).mockResolvedValue({ data: [{ id: 'iss-letsencrypt', name: 'Let’s Encrypt' }], total: 1, page: 1, per_page: 100 } as never); + vi.mocked(client.getOwners).mockResolvedValue({ data: [{ id: 'o-platform', name: 'Platform', email: 'platform@example.com' }], total: 1, page: 1, per_page: 100 } as never); + vi.mocked(client.getTeams).mockResolvedValue({ data: [{ id: 't-platform', name: 'Platform' }], total: 1, page: 1, per_page: 100 } as never); + vi.mocked(client.getProfiles).mockResolvedValue({ data: [{ id: 'cp-tls-server', name: 'TLS Server' }], total: 1, page: 1, per_page: 100 } as never); + vi.mocked(client.getRenewalPolicies).mockResolvedValue(emptyResp as never); +} + +describe('CertificatesPage — T-1 page coverage', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + mockAll(); + }); + + it('renders the certificate list when getCertificates resolves', async () => { + renderWithQuery(); + await waitFor(() => { + expect(screen.getByText('app.example.com')).toBeInTheDocument(); + }); + expect(screen.getByText('mc-prod-001')).toBeInTheDocument(); + }); + + it('changing the team filter wires team_id into the getCertificates params', async () => { + renderWithQuery(); + await waitFor(() => expect(client.getCertificates).toHaveBeenCalled()); + + // The team filter is the 6th