Files
sencho/frontend/src/components/__tests__/SecurityHistoryView.test.tsx
T
Anso e4fdb1cd6c fix(security): convert scan history from full page to sheet overlay (#720)
Scan history is now a right-side sheet that layers over the current view
(typically Resources Hub) instead of a full-page activeView branch. The
sheet opens via the existing navigation event, fetches only when open,
dismisses on Escape or overlay click, and preserves the nested scan-details
and scan-compare sheets intact via Radix portal stacking.

The fetch effect now resets selection and page state on active-node change
exactly once, avoiding a double-fetch on node switches.
2026-04-21 08:01:31 -04:00

233 lines
7.5 KiB
TypeScript

/**
* 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[], 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(<SecurityHistoryView open onClose={vi.fn()} />);
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(<SecurityHistoryView open onClose={vi.fn()} />);
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(<SecurityHistoryView open onClose={vi.fn()} />);
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
nodesState.activeNode = { id: 2 };
rerender(<SecurityHistoryView key="remount-signal" open onClose={vi.fn()} />);
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 open onClose={vi.fn()} />);
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 open onClose={vi.fn()} />);
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(<SecurityHistoryView open={false} onClose={vi.fn()} />);
// 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(<SecurityHistoryView open onClose={onClose} />);
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(<SecurityHistoryView open onClose={vi.fn()} />);
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();
});
});