mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 14:45:41 +00:00
feat: chart-led Security overview with sortable Images and History tables (#1364)
* feat: chart-led Security overview with sortable Images and History tables Refine the Security page around the existing design system and add the data the dashboard needs. - Overview leads with four charts (30-day risk trend, severity donut, top exposed images, findings by type); the signal-rail counts become a secondary summary, and the scanner and deploy-enforcement posture follow. - Images becomes a recessed table with search, a severity filter, sortable columns, a last-scan column, and inline scan actions; the findings cell is clickable into the scan sheet, and the per-row cursor tooltip is dropped where the columns already carry that information. - Policies puts deploy-enforcement first, collapses the policy packs into an accordion, and uses the standard primary button for Add policy. - Suppressions and acknowledgements move their titles and Add buttons outside the cards, matching the Fleet tab layout. - History switches from the detail sheet to an inline table (search, sortable columns, two-scan compare, pagination); the now-unreachable scan-history overlay is removed. - Add GET /api/security/overview/trend, a node-scoped daily critical/high rollup backing the risk-trend chart. - Extract the shared image-scan hook and the severity classifier, and harden the overview data fetch so a malformed non-critical response can never read as a clean security state. * fix: treat malformed Security responses as errors, not empty or clean states Address an independent review of the data-fetch paths so a 200 with an unexpected shape can never read as a benign "no findings" view. - SecurityView: validate that the image-summaries body is a scan-summary map; an unexpected shape now sets the error state instead of an empty map. Isolate the trend fetch in its own self-catching promise so a transport failure on the non-critical chart can no longer poison the overview or summaries error state. - useImageScan: only a "completed" poll counts as success (a malformed or unknown status now throws), and a failed post-scan summaries refresh is logged instead of silently dropped. - HistoryTab: a 200 whose body lacks an items array is treated as an error, not an empty "no completed scans" list.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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 }) => <span>{severity}</span>,
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { HistoryTab } from '../HistoryTab';
|
||||
|
||||
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;
|
||||
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(<HistoryTab onInspect={vi.fn()} />);
|
||||
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(<HistoryTab onInspect={onInspect} />);
|
||||
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(<HistoryTab onInspect={vi.fn()} />);
|
||||
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(<HistoryTab onInspect={vi.fn()} />);
|
||||
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 image on Enter, adding imageRefLike to the request', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
|
||||
await userEvent.type(screen.getByPlaceholderText('Search by image...'), 'redis{Enter}');
|
||||
await waitFor(() => {
|
||||
const calls = mockedFetch.mock.calls.map((c) => c[0] as string);
|
||||
expect(calls.some((u) => u.includes('imageRefLike=redis'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the error state when the load fails', async () => {
|
||||
mockedFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as unknown as Response);
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
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(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText(/Couldn't load scan history/)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/No completed scans yet/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user