mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +00:00
e4fdb1cd6c
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.
233 lines
7.5 KiB
TypeScript
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();
|
|
});
|
|
});
|