mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
feat(security): add triage status and OpenVEX justification parity (#1615)
* feat(security): add triage status and OpenVEX justification parity Share triage options across SuppressionsPanel and the scan-sheet suppress dialog, require justification for not_affected and false_positive, and document the fields. * fix(security): use design-system Select for triage dropdowns Replace native selects so OpenVEX justification options use themed popover content instead of unreadable OS option lists in dark mode. * fix(security): keep triage justification Select controlled Pass an empty string instead of undefined so Radix Select does not flip between uncontrolled and controlled when the placeholder is shown.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet';
|
||||
|
||||
const mockedFetch = vi.mocked(apiFetch);
|
||||
const ok = (body: unknown) => ({ ok: true, json: async () => body }) as Response;
|
||||
|
||||
const VULN = {
|
||||
id: 1, scan_id: 1, vulnerability_id: 'CVE-2026-1000', pkg_name: 'openssl', installed_version: '1.0',
|
||||
fixed_version: null, severity: 'HIGH', title: null, description: null, primary_url: null,
|
||||
};
|
||||
|
||||
const SCAN = {
|
||||
id: 1, node_id: 1, image_ref: 'img:1', image_digest: null, scanned_at: Date.now(),
|
||||
total_vulnerabilities: 1, critical_count: 0, high_count: 1, medium_count: 0, low_count: 0,
|
||||
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
|
||||
highest_severity: 'HIGH', os_info: null, trivy_version: '0.50.0', scan_duration_ms: 1, triggered_by: 'manual',
|
||||
status: 'completed', error: null, stack_context: null, policy_evaluation: null,
|
||||
};
|
||||
|
||||
function postSuppressionCall() {
|
||||
return mockedFetch.mock.calls.find(
|
||||
([url, opts]) => /\/security\/suppressions$/.test(String(url)) && (opts as { method?: string } | undefined)?.method === 'POST',
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
(toast.error as ReturnType<typeof vi.fn>).mockClear();
|
||||
mockedFetch.mockImplementation(((url: string, opts?: { method?: string }) => {
|
||||
if (opts?.method === 'POST' && /\/security\/suppressions$/.test(url)) {
|
||||
return Promise.resolve(ok({}));
|
||||
}
|
||||
if (/\/fleet\/role/.test(url)) return Promise.resolve(ok({ role: 'control' }));
|
||||
if (/\/vulnerabilities\?limit=1000&offset=0/.test(url)) return Promise.resolve(ok({ items: [VULN], total: 1 }));
|
||||
if (/\/secrets/.test(url) || /\/misconfigs/.test(url)) return Promise.resolve(ok({ items: [] }));
|
||||
if (/\/security\/scans\/1$/.test(url)) return Promise.resolve(ok(SCAN));
|
||||
return Promise.resolve(ok({ items: [] }));
|
||||
}) as unknown as typeof apiFetch);
|
||||
});
|
||||
|
||||
async function openSuppressDialog() {
|
||||
render(<VulnerabilityScanSheet scanId={1} onClose={() => {}} canManageSuppressions />);
|
||||
await waitFor(() => expect(screen.getByText('CVE-2026-1000')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByTitle('Suppress this CVE'));
|
||||
}
|
||||
|
||||
async function pickSelect(label: string, optionName: string) {
|
||||
await userEvent.click(screen.getByRole('combobox', { name: label }));
|
||||
await userEvent.click(await screen.findByRole('option', { name: optionName }));
|
||||
}
|
||||
|
||||
describe('VulnerabilityScanSheet suppress dialog', () => {
|
||||
it('requires an OpenVEX justification for a not-affected decision and clears it when switching away', async () => {
|
||||
await openSuppressDialog();
|
||||
|
||||
await pickSelect('Triage decision', 'Not affected');
|
||||
expect(screen.getByRole('combobox', { name: 'OpenVEX justification' })).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Reason'), 'Vendor confirmed unreachable code path.');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Suppress' }));
|
||||
expect(toast.error).toHaveBeenCalledWith('An OpenVEX justification is required for this triage decision.');
|
||||
expect(postSuppressionCall()).toBeUndefined();
|
||||
|
||||
await pickSelect('Triage decision', 'Accepted risk');
|
||||
expect(screen.queryByRole('combobox', { name: 'OpenVEX justification' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends the triage status and justification when suppressing a CVE', async () => {
|
||||
await openSuppressDialog();
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Reason'), 'False positive confirmed by vendor.');
|
||||
await pickSelect('Triage decision', 'False positive');
|
||||
await pickSelect('OpenVEX justification', 'Inline mitigations already exist');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Suppress' }));
|
||||
|
||||
await waitFor(() => expect(postSuppressionCall()).toBeTruthy());
|
||||
const body = JSON.parse((postSuppressionCall()![1] as { body: string }).body);
|
||||
expect(body.status).toBe('false_positive');
|
||||
expect(body.justification).toBe('inline_mitigations_already_exist');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user