mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
12c2b37510
* feat(security): polish scan sheets, fix CVE links, surface policy violations Adds cveUrl helper that rewrites Trivy's 404-ing avd.aquasec.com links to cve.org for CVE-prefixed IDs (GHSA and misconfig URLs pass through unchanged). Redesigns both scan sheets with shadow-card-bevel chips, tracked-mono kickers, severity row tinting with a left accent rail, and tabular-nums timestamps. Surfaces a destructive policy-violation banner on scans whose policy_evaluation row flags a block, and fixes the compare sheet's delta ribbon so CRITICAL net-positive deltas render in destructive (not warning) tone. Backend parses the JSON policy_evaluation column at the API boundary so the UI receives a structured object. * chore(security): suppress CVE-2026-32281 and CVE-2026-32283 in Trivy scan Both CVEs affect Go stdlib crypto/x509 and TLS in Docker CLI 29.4.0 (Go 1.26.1) and Compose v5.1.2 (Go 1.25.8). No upstream static binary has been released with the patched Go 1.26.2 or 1.25.9 runtimes yet. Exposure analysis: the Docker CLI and compose plugin connect to the local Docker socket (Unix socket, no TLS) and to public registries with well-known CAs. Neither CVE is exploitable in this configuration. Added alongside sibling entries already in .trivyignore for the same binary versions. Revisit on next Docker CLI and Compose upstream release.
271 lines
9.2 KiB
TypeScript
271 lines
9.2 KiB
TypeScript
/**
|
|
* 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('renders the truncation banner when the response flags truncated', async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, result({ truncated: true, row_limit: 1000 })),
|
|
);
|
|
|
|
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/first 1000 findings per scan/i)).toBeInTheDocument(),
|
|
);
|
|
});
|
|
|
|
it('hides the truncation banner when truncated is false', async () => {
|
|
mockedFetch.mockResolvedValueOnce(jsonResponse(200, result({ truncated: false })));
|
|
|
|
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
|
|
|
await waitFor(() => expect(screen.getByText(/Baseline/i)).toBeInTheDocument());
|
|
expect(screen.queryByText(/findings per scan/i)).toBeNull();
|
|
});
|
|
|
|
it('relabels the unchanged bucket as "Shared" for cross-image comparisons', async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, result({
|
|
scanB: { id: 2, image_ref: 'alpine:3.19', scanned_at: 1_700_000_010_000 },
|
|
unchanged: [vuln({ vulnerability_id: 'CVE-U', pkg_name: 'pu' })],
|
|
})),
|
|
);
|
|
|
|
const user = userEvent.setup();
|
|
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByRole('button', { name: /Shared \(1\)/ })).toBeInTheDocument(),
|
|
);
|
|
expect(screen.queryByRole('button', { name: /Unchanged/ })).toBeNull();
|
|
|
|
await user.click(screen.getByRole('button', { name: /Shared \(1\)/ }));
|
|
expect(screen.getByText('Shared')).toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps the "Unchanged" label when both scans use the same image', async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, result({
|
|
unchanged: [vuln({ vulnerability_id: 'CVE-U', pkg_name: 'pu' })],
|
|
})),
|
|
);
|
|
|
|
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByRole('button', { name: /Unchanged \(1\)/ })).toBeInTheDocument(),
|
|
);
|
|
expect(screen.queryByRole('button', { name: /Shared/ })).toBeNull();
|
|
});
|
|
|
|
it('rewrites CVE primary_url to cve.org for CVE IDs', async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, result({
|
|
added: [vuln({
|
|
vulnerability_id: 'CVE-2024-1234',
|
|
primary_url: 'https://avd.aquasec.com/nvd/CVE-2024-1234',
|
|
})],
|
|
})),
|
|
);
|
|
|
|
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
|
|
|
const link = await screen.findByRole('link', { name: 'CVE-2024-1234' });
|
|
expect(link).toHaveAttribute(
|
|
'href',
|
|
'https://www.cve.org/CVERecord?id=CVE-2024-1234',
|
|
);
|
|
});
|
|
|
|
it('tags CRITICAL net-positive delta chip with destructive tone', async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, result({
|
|
added: [vuln({ vulnerability_id: 'CVE-C', severity: 'CRITICAL' })],
|
|
})),
|
|
);
|
|
|
|
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
|
|
|
const chip = await screen.findByLabelText('CRITICAL delta +1');
|
|
expect(chip).toHaveAttribute('data-tone', 'destructive');
|
|
});
|
|
|
|
it('tags HIGH net-positive delta chip with warning tone (not destructive)', async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, result({
|
|
added: [vuln({ vulnerability_id: 'CVE-H', severity: 'HIGH' })],
|
|
})),
|
|
);
|
|
|
|
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
|
|
|
const chip = await screen.findByLabelText('HIGH delta +1');
|
|
expect(chip).toHaveAttribute('data-tone', 'warning');
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|