test(security): add scan comparison coverage (#656)

Backend supertest suite for GET /api/security/compare covers tier gating,
input validation, cross-node isolation, diff partitioning by
vulnerability_id::pkg_name, suppression application, and cross-image
comparison. Frontend vitest + React Testing Library scaffolding with
component tests for ScanComparisonSheet (loading, error recovery,
cross-image warning, filter pills, reload on id change) and
SecurityHistoryView (mount fetch, selection cap, oldest-first baseline
ordering, tier gating).
This commit is contained in:
Anso
2026-04-17 13:44:40 -04:00
committed by GitHub
parent fe8abaac55
commit f4e3c267cd
7 changed files with 1833 additions and 3 deletions
+27
View File
@@ -0,0 +1,27 @@
import '@testing-library/jest-dom/vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
class MockResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = globalThis.ResizeObserver ?? MockResizeObserver;
if (typeof window !== 'undefined' && !window.matchMedia) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
afterEach(() => {
cleanup();
});
@@ -0,0 +1,169 @@
/**
* Coverage for ScanComparisonSheet.
*
* Locks the Sheet's data-path behavior: loading, error recovery, cross-image
* warning, filter pills, pagination clamp, and empty states. Complements the
* backend compare handler tests by guarding the frontend rendering contract.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(),
}));
const toastError = vi.fn();
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: (...args: unknown[]) => toastError(...args),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn(),
},
}));
vi.mock('../VulnerabilityScanSheet', () => ({
SeverityChip: ({ severity }: { severity: string }) => (
<span data-testid="severity-chip">{severity}</span>
),
VulnerabilityScanSheet: () => null,
}));
import { apiFetch } from '@/lib/api';
import { ScanComparisonSheet } from '../ScanComparisonSheet';
import type { ScanCompareResult, ScanCompareVulnerability } from '@/types/security';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
function vuln(overrides: Partial<ScanCompareVulnerability> = {}): ScanCompareVulnerability {
return {
vulnerability_id: 'CVE-2024-0001',
pkg_name: 'openssl',
severity: 'HIGH',
suppressed: false,
...overrides,
};
}
function result(overrides: Partial<ScanCompareResult> = {}): ScanCompareResult {
return {
scanA: { id: 1, image_ref: 'alpine:3.18', scanned_at: 1_700_000_000_000 },
scanB: { id: 2, image_ref: 'alpine:3.18', scanned_at: 1_700_000_010_000 },
added: [],
removed: [],
unchanged: [],
...overrides,
};
}
function jsonResponse(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as unknown as Response;
}
beforeEach(() => {
mockedFetch.mockReset();
toastError.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('ScanComparisonSheet', () => {
it('renders nothing when scan ids are null', () => {
const { container } = render(
<ScanComparisonSheet baselineScanId={null} currentScanId={null} onClose={() => {}} />,
);
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it('shows cross-image warning when scan image refs differ', async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, result({
scanB: { id: 2, image_ref: 'alpine:3.19', scanned_at: 1_700_000_010_000 },
})),
);
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
await waitFor(() =>
expect(screen.getByText(/different image references/i)).toBeInTheDocument(),
);
});
it('does not show cross-image warning for same image refs', async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(200, result()));
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
await waitFor(() => expect(screen.getByText(/Baseline/i)).toBeInTheDocument());
expect(screen.queryByText(/different image references/i)).toBeNull();
});
it('surfaces a toast and closes the sheet on fetch error', async () => {
const onClose = vi.fn();
mockedFetch.mockResolvedValueOnce(jsonResponse(500, { error: 'boom' }));
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={onClose} />);
await waitFor(() => expect(toastError).toHaveBeenCalledWith('boom'));
expect(onClose).toHaveBeenCalled();
});
it('switches the visible rows when a filter pill is clicked', async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, result({
added: [vuln({ vulnerability_id: 'CVE-A', pkg_name: 'pa' })],
removed: [vuln({ vulnerability_id: 'CVE-R', pkg_name: 'pr' })],
unchanged: [vuln({ vulnerability_id: 'CVE-U', pkg_name: 'pu' })],
})),
);
const user = userEvent.setup();
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
await waitFor(() => expect(screen.getByText('CVE-A')).toBeInTheDocument());
expect(screen.queryByText('CVE-R')).toBeNull();
await user.click(screen.getByRole('button', { name: /Removed \(1\)/ }));
expect(screen.getByText('CVE-R')).toBeInTheDocument();
expect(screen.queryByText('CVE-A')).toBeNull();
await user.click(screen.getByRole('button', { name: /Unchanged \(1\)/ }));
expect(screen.getByText('CVE-U')).toBeInTheDocument();
});
it('renders a bucket-specific empty state message', async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(200, result()));
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
await waitFor(() =>
expect(screen.getByText(/Nothing regressed between these scans/i)).toBeInTheDocument(),
);
});
it('reloads when the scan ids change', async () => {
mockedFetch.mockResolvedValue(jsonResponse(200, result()));
const { rerender } = render(
<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />,
);
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
await act(async () => {
rerender(<ScanComparisonSheet baselineScanId={3} currentScanId={4} onClose={() => {}} />);
});
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2));
expect(mockedFetch).toHaveBeenLastCalledWith('/security/compare?scanId1=3&scanId2=4');
});
});
@@ -0,0 +1,188 @@
/**
* 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 }) => <span>{severity}</span>,
VulnerabilityScanSheet: () => null,
}));
import { apiFetch } from '@/lib/api';
import { SecurityHistoryView } from '../SecurityHistoryView';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
function scan(overrides: Partial<VulnerabilityScan> = {}): 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[]): Response {
return {
ok: true,
status: 200,
json: async () => ({ items }),
} as unknown as Response;
}
beforeEach(() => {
mockedFetch.mockReset();
compareProps.length = 0;
licenseState.isPaid = true;
nodesState.activeNode = { id: 1 };
});
afterEach(() => vi.clearAllMocks());
describe('SecurityHistoryView', () => {
it('fetches scans on mount', async () => {
mockedFetch.mockResolvedValue(listResponse([scan()]));
render(<SecurityHistoryView />);
await waitFor(() =>
expect(mockedFetch).toHaveBeenCalledWith('/security/scans?limit=200'),
);
});
it('re-fetches when activeNode.id changes', async () => {
mockedFetch.mockResolvedValue(listResponse([scan()]));
const { rerender } = render(<SecurityHistoryView />);
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
nodesState.activeNode = { id: 2 };
rerender(<SecurityHistoryView key="remount-signal" />);
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(<SecurityHistoryView />);
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(<SecurityHistoryView />);
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('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(<SecurityHistoryView />);
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();
});
});