/** * Coverage for SecurityHistoryView. * * Locks the scan history's selection and comparison-launch behavior: scans * fetched on mount, selection capped at two, oldest-first baseline ordering, * and selection reset on active-node change. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { VulnerabilityScan } from '@/types/security'; vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), })); vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), }, })); const licenseState = { isPaid: true }; vi.mock('@/context/LicenseContext', () => ({ useLicense: () => licenseState, })); vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }), })); const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } }; vi.mock('@/context/NodeContext', () => ({ useNodes: () => nodesState, })); const compareProps: { baselineScanId: number | null; currentScanId: number | null }[] = []; vi.mock('../ScanComparisonSheet', () => ({ ScanComparisonSheet: (props: { baselineScanId: number | null; currentScanId: number | null }) => { compareProps.push({ baselineScanId: props.baselineScanId, currentScanId: props.currentScanId }); return null; }, })); vi.mock('../VulnerabilityScanSheet', () => ({ SeverityChip: ({ severity }: { severity: string }) => {severity}, VulnerabilityScanSheet: () => null, })); import { apiFetch } from '@/lib/api'; import { SecurityHistoryView } from '../SecurityHistoryView'; const mockedFetch = apiFetch as unknown as ReturnType; function scan(overrides: Partial = {}): VulnerabilityScan { return { id: 1, node_id: 1, image_ref: 'alpine:3.19', image_digest: null, scanned_at: 1_700_000_000_000, total_vulnerabilities: 0, critical_count: 0, high_count: 0, medium_count: 0, low_count: 0, unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln', highest_severity: null, os_info: null, trivy_version: null, scan_duration_ms: null, triggered_by: 'manual', status: 'completed', error: null, stack_context: null, ...overrides, }; } function listResponse(items: VulnerabilityScan[], total?: number): Response { return { ok: true, status: 200, json: async () => ({ items, total: total ?? items.length }), } as unknown as Response; } beforeEach(() => { mockedFetch.mockReset(); compareProps.length = 0; licenseState.isPaid = true; nodesState.activeNode = { id: 1 }; }); afterEach(() => vi.clearAllMocks()); describe('SecurityHistoryView', () => { it('fetches completed scans on mount with server-driven pagination params', async () => { mockedFetch.mockResolvedValue(listResponse([scan()])); render(); await waitFor(() => expect(mockedFetch).toHaveBeenCalled()); const url = mockedFetch.mock.calls[0][0] as string; expect(url).toMatch(/^\/security\/scans\?/); expect(url).toContain('status=completed'); expect(url).toContain('offset=0'); expect(url).toMatch(/limit=\d+/); }); it('advances offset when the user pages forward', async () => { mockedFetch.mockResolvedValue(listResponse([scan()], 250)); const user = userEvent.setup(); render(); await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1)); const nextBtn = screen.getAllByRole('button').find( (b) => b.querySelector('.lucide-chevron-right'), ); expect(nextBtn).toBeDefined(); await user.click(nextBtn!); await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2)); const secondUrl = mockedFetch.mock.calls[1][0] as string; expect(secondUrl).toContain('offset=100'); }); it('re-fetches when activeNode.id changes', async () => { mockedFetch.mockResolvedValue(listResponse([scan()])); const { rerender } = render(); await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1)); nodesState.activeNode = { id: 2 }; rerender(); await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2)); }); it('caps selection at two scans, evicting the oldest', async () => { mockedFetch.mockResolvedValue( listResponse([ scan({ id: 1, scanned_at: 1000 }), scan({ id: 2, scanned_at: 2000 }), scan({ id: 3, scanned_at: 3000 }), ]), ); const user = userEvent.setup(); render(); const checkboxes = await screen.findAllByRole('checkbox'); expect(checkboxes).toHaveLength(3); await user.click(checkboxes[0]); await user.click(checkboxes[1]); await user.click(checkboxes[2]); expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeEnabled(); expect(checkboxes[0].getAttribute('aria-checked')).toBe('false'); expect(checkboxes[1].getAttribute('aria-checked')).toBe('true'); expect(checkboxes[2].getAttribute('aria-checked')).toBe('true'); }); it('passes older scan as baseline and newer as current on compare', async () => { mockedFetch.mockResolvedValue( listResponse([ scan({ id: 10, scanned_at: 3000 }), scan({ id: 20, scanned_at: 1000 }), ]), ); const user = userEvent.setup(); render(); const checkboxes = await screen.findAllByRole('checkbox'); await user.click(checkboxes[0]); await user.click(checkboxes[1]); await user.click(screen.getByRole('button', { name: /Compare \(2\/2\)/ })); const last = compareProps.at(-1); expect(last?.baselineScanId).toBe(20); expect(last?.currentScanId).toBe(10); }); it('does not fetch when closed', async () => { mockedFetch.mockResolvedValue(listResponse([scan()])); render(); // Flush any microtasks; the fetch guard returns synchronously so no // timer delay is required. await Promise.resolve(); expect(mockedFetch).not.toHaveBeenCalled(); }); it('fires onClose when Escape is pressed and does not fetch again', async () => { mockedFetch.mockResolvedValue(listResponse([scan()])); const onClose = vi.fn(); const user = userEvent.setup(); render(); await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1)); await user.keyboard('{Escape}'); await waitFor(() => expect(onClose).toHaveBeenCalled()); expect(mockedFetch).toHaveBeenCalledTimes(1); }); it('disables Compare button for community tier', async () => { licenseState.isPaid = false; mockedFetch.mockResolvedValue( listResponse([ scan({ id: 1, scanned_at: 1000 }), scan({ id: 2, scanned_at: 2000 }), ]), ); const user = userEvent.setup(); render(); const checkboxes = await screen.findAllByRole('checkbox'); await user.click(checkboxes[0]); await user.click(checkboxes[1]); const compareBtn = screen.getByRole('button', { name: /Compare \(2\/2\)/ }); expect(compareBtn).toBeDisabled(); }); });