/** * HistoryTab is the inline scan-history table that replaced the history sheet. * Locks: completed-scan fetch on mount with pagination params, Open -> inspect * on the vulns tab, two-scan compare capped at two with oldest-first baseline * ordering, search-by-image, and the load-failure error state. */ 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 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}, })); import { apiFetch } from '@/lib/api'; import { HistoryTab } from '../HistoryTab'; 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; } function installMatchMedia(matches: boolean) { window.matchMedia = vi.fn().mockReturnValue({ matches, media: '', onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), }) as unknown as typeof window.matchMedia; } beforeEach(() => { mockedFetch.mockReset(); compareProps.length = 0; nodesState.activeNode = { id: 1 }; }); afterEach(() => vi.clearAllMocks()); describe('HistoryTab', () => { it('fetches completed scans on mount with pagination params', async () => { mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })])); render(); await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument()); const url = mockedFetch.mock.calls[0][0] as string; expect(url).toContain('/security/scans?'); expect(url).toContain('status=completed'); expect(url).toContain('limit=100'); expect(url).toContain('offset=0'); }); it('opens the scan sheet on the vulns tab from Open', async () => { const onInspect = vi.fn(); mockedFetch.mockResolvedValue(listResponse([scan({ id: 42, image_ref: 'nginx:1' })])); render(); await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument()); await userEvent.click(screen.getByRole('button', { name: 'Open' })); expect(onInspect).toHaveBeenCalledWith(42, 'vulns'); }); it('compares two scans with the older as baseline and newer as current', async () => { const older = scan({ id: 10, image_ref: 'a:1', scanned_at: 1000 }); const newer = scan({ id: 20, image_ref: 'b:1', scanned_at: 2000 }); mockedFetch.mockResolvedValue(listResponse([newer, older])); render(); await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument()); const checks = screen.getAllByLabelText('Select scan to compare'); await userEvent.click(checks[0]); await userEvent.click(checks[1]); await userEvent.click(screen.getByRole('button', { name: /Compare/ })); const last = compareProps[compareProps.length - 1]; expect(last.baselineScanId).toBe(10); expect(last.currentScanId).toBe(20); }); it('caps the compare selection at two', async () => { mockedFetch.mockResolvedValue(listResponse([ scan({ id: 1, image_ref: 'a:1', scanned_at: 3000 }), scan({ id: 2, image_ref: 'b:1', scanned_at: 2000 }), scan({ id: 3, image_ref: 'c:1', scanned_at: 1000 }), ])); render(); await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument()); const checks = screen.getAllByLabelText('Select scan to compare'); await userEvent.click(checks[0]); await userEvent.click(checks[1]); await userEvent.click(checks[2]); expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeInTheDocument(); }); it('searches by imageIdentityLike as you type (not legacy imageRefLike)', async () => { mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })])); render(); await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument()); await userEvent.type(screen.getByPlaceholderText('Search by image or digest...'), 'redis'); await waitFor(() => { const calls = mockedFetch.mock.calls.map((c) => c[0] as string); expect(calls.some((u) => u.includes('imageIdentityLike=redis'))).toBe(true); expect(calls.every((u) => !u.includes('imageRefLike='))).toBe(true); }); }); it('shows short digest as primary when image_digest is present', async () => { mockedFetch.mockResolvedValue(listResponse([ scan({ image_ref: 'nginx:1.25', image_digest: 'sha256:abcdef0123456789ffff', }), ])); render(); await waitFor(() => expect(screen.getByText('abcdef012345')).toBeInTheDocument()); expect(screen.getByText('nginx:1.25')).toBeInTheDocument(); }); it('renders the error state when the load fails', async () => { mockedFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as unknown as Response); render(); await waitFor(() => expect(screen.getByText(/Couldn't load scan history/)).toBeInTheDocument()); }); it('treats a malformed 200 response (no items array) as an error, not an empty list', async () => { mockedFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ oops: true }) } as unknown as Response); render(); await waitFor(() => expect(screen.getByText(/Couldn't load scan history/)).toBeInTheDocument()); expect(screen.queryByText(/No completed scans yet/)).not.toBeInTheDocument(); }); }); describe('HistoryTab (mobile)', () => { const original = window.matchMedia; afterEach(() => { window.matchMedia = original; vi.clearAllMocks(); }); it('renders mobile rows instead of a table', async () => { installMatchMedia(true); mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })])); render(); await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument()); // No table element, no column headers. expect(document.querySelector('table')).toBeNull(); expect(screen.queryByText('Trigger')).toBeNull(); }); it('opens the scan sheet from a mobile row tap', async () => { installMatchMedia(true); const onInspect = vi.fn(); mockedFetch.mockResolvedValue(listResponse([scan({ id: 42, image_ref: 'nginx:1' })])); render(); await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument()); // Click the row's content button (the inspect target), not the checkbox. const rowBtn = screen.getByRole('button', { name: /nginx:1/ }); await userEvent.click(rowBtn); expect(onInspect).toHaveBeenCalledWith(42, 'vulns'); }); it('selects two scans via mobile checkboxes and opens compare', async () => { installMatchMedia(true); const older = scan({ id: 10, image_ref: 'a:1', scanned_at: 1000 }); const newer = scan({ id: 20, image_ref: 'b:1', scanned_at: 2000 }); mockedFetch.mockResolvedValue(listResponse([newer, older])); render(); await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument()); const checks = screen.getAllByLabelText('Select scan to compare'); await userEvent.click(checks[0]); await userEvent.click(checks[1]); await userEvent.click(screen.getByRole('button', { name: /Compare/ })); const last = compareProps[compareProps.length - 1]; expect(last.baselineScanId).toBe(10); expect(last.currentScanId).toBe(20); }); it('does not show clean for a secret-only scan', async () => { installMatchMedia(true); mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'secret-img:1', total_vulnerabilities: 0, secret_count: 2, misconfig_count: 0, highest_severity: null, })])); render(); await waitFor(() => expect(screen.getByText('secret-img:1')).toBeInTheDocument()); expect(screen.queryByText('clean')).not.toBeInTheDocument(); }); it('shows clean tag for a truly clean scan', async () => { installMatchMedia(true); mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'clean-img:1', total_vulnerabilities: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, highest_severity: null, })])); render(); await waitFor(() => expect(screen.getByText('clean-img:1')).toBeInTheDocument()); expect(screen.getByText('clean')).toBeInTheDocument(); }); it('renders checkbox with 44px touch target', async () => { installMatchMedia(true); mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'nginx:1' })])); render(); await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument()); const checkbox = screen.getByLabelText('Select scan to compare'); expect(checkbox.className).toContain('h-11'); expect(checkbox.className).toContain('w-11'); }); });