134 lines
9.8 KiB
TypeScript
134 lines
9.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act, useState } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { RiskAcceptanceDialog } from '../../src/features/finding-reader/RiskAcceptanceDialog';
|
|
import { FindingReader } from '../../src/features/finding-reader/FindingReader';
|
|
import { appendRiskAcceptanceReview, type RiskAcceptanceCommand } from '../../src/domain/risk-acceptance';
|
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
|
import { riskCommand } from '../domain/risk-acceptance-fixture';
|
|
|
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); });
|
|
function control(label: string) {
|
|
return [...document.querySelectorAll('label')].find(item => item.querySelector('span')?.textContent === label)!.querySelector<HTMLInputElement | HTMLTextAreaElement>('input, textarea')!;
|
|
}
|
|
async function fill(label: string, value: string) {
|
|
const input = control(label);
|
|
await act(async () => {
|
|
const prototype = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
Object.getOwnPropertyDescriptor(prototype, 'value')!.set!.call(input, value);
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
}
|
|
async function click(label: string) {
|
|
const button = [...document.querySelectorAll('button')].find(button => button.textContent === label)!;
|
|
await act(async () => button.click());
|
|
}
|
|
describe('Accepted Risk dialog', () => {
|
|
it('offers whole-finding and exact Defender device scopes without mixing system scope into the narrative', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const container = document.createElement('div'); document.body.append(container);
|
|
const root = createRoot(container);
|
|
const observations = [
|
|
{ name: 'Device A', ip: '192.0.2.10', site: 'Synthetic site', sensor: 'sensor-a', cve: 'CVE-2026-1001', severity: 'High', description: 'Synthetic', additionalSourceFields: { 'Device ID': 'DEV-A' }, source: { tableIndex: 0, rowIndex: 1 } },
|
|
{ name: 'Device B', ip: '192.0.2.20', site: 'Synthetic site', sensor: 'sensor-a', cve: 'CVE-2026-1001', severity: 'High', description: 'Synthetic', additionalSourceFields: { 'Device ID': 'DEV-B' }, source: { tableIndex: 0, rowIndex: 2 } }
|
|
];
|
|
const record = makeSheet('Synthetic', [['CVE', 'Source Observations'], ['CVE-2026-1001', JSON.stringify(observations)]], '', 'defender.csv', { toolId: 'defender-for-iot-vulnerabilities' }).analysis.records[0];
|
|
Object.assign(record, { findingOccurrenceId: 'occurrence-1', findingDefinitionId: 'definition-1', scanRunId: 'run-1', importFileId: 'file-1' });
|
|
try {
|
|
await act(async () => root.render(<FindingReader record={record} title={record.title} toolId="defender-for-iot-vulnerabilities" linkedSheets={[]} resultReferences={[]} search="" position={0} recordCount={1} onPrevious={() => {}} onNext={() => {}} onReview={() => {}} onRiskAcceptance={async () => {}} onScopedRiskAcceptance={async () => {}}/>));
|
|
await act(async () => container.querySelector<HTMLButtonElement>('.accepted-risk-trigger')!.click());
|
|
const select = document.querySelector<HTMLSelectElement>('.risk-acceptance-scope-select select')!;
|
|
expect([...select.options].map(option => option.textContent)).toEqual(['Entire finding', 'Device A', 'Device B']);
|
|
expect(document.querySelector('.risk-acceptance-system-scope')).toBeNull();
|
|
await act(async () => {
|
|
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(select, 'device-id:dev-a');
|
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
});
|
|
expect(document.querySelector('.risk-acceptance-system-scope')?.textContent).toContain('CVE-2026-1001 on Device A');
|
|
expect(document.querySelector('.risk-acceptance-system-scope')?.textContent).toContain('1 covered CVE');
|
|
expect(control('Affected assets, systems and exclusions').value).toBe('');
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
|
|
it('opens from the finding header and blocks incomplete acceptance without changing the review', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const container = document.createElement('div'); document.body.append(container);
|
|
const root = createRoot(container);
|
|
const record = makeSheet('Synthetic', [['Title', 'Status'], ['Synthetic risk', 'Failed']], '', 'synthetic.csv').analysis.records[0];
|
|
const save = vi.fn(); const review = vi.fn();
|
|
try {
|
|
await act(async () => root.render(<FindingReader record={record} title={record.title} linkedSheets={[]} resultReferences={[]} search="" position={0} recordCount={1} onPrevious={() => {}} onNext={() => {}} onReview={review} onRiskAcceptance={save}/>));
|
|
const trigger = container.querySelector<HTMLButtonElement>('.accepted-risk-trigger')!;
|
|
expect(trigger.getAttribute('aria-haspopup')).toBe('dialog');
|
|
expect(trigger.textContent).toBe('Accepted Risk ?');
|
|
trigger.focus();
|
|
await act(async () => trigger.click());
|
|
expect(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')).toBe('Accepted Risk');
|
|
await click('Record acceptance');
|
|
expect(document.querySelector('[role="alert"]')).not.toBeNull();
|
|
expect(save).not.toHaveBeenCalled(); expect(review).not.toHaveBeenCalled();
|
|
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
|
|
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
|
expect(document.activeElement).toBe(trigger);
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
it('marks an existing draft risk acceptance with the dedicated button state', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const container = document.createElement('div'); document.body.append(container);
|
|
const root = createRoot(container);
|
|
const record = makeSheet('Synthetic', [['Title', 'Status'], ['Synthetic draft risk', 'Failed']], '', 'synthetic.csv').analysis.records[0];
|
|
const review = appendRiskAcceptanceReview(undefined, record.findingOccurrenceId ?? record.id, riskCommand('draft'), new Date().toISOString());
|
|
try {
|
|
await act(async () => root.render(<FindingReader record={record} title={record.title} review={{ ...review, include: false, status: 'Open', priority: 'Medium', owner: '', due: '', prerequisites: '', access: '', nextStep: '', evidence: '', comment: '', markerQuestion: false, markerBlocked: false, markerQuickwin: false, markerExternal: false, markerNotRelevant: false }} linkedSheets={[]} resultReferences={[]} search="" position={0} recordCount={1} onPrevious={() => {}} onNext={() => {}} onReview={() => {}} onRiskAcceptance={async () => {}}/>));
|
|
const trigger = container.querySelector<HTMLButtonElement>('.accepted-risk-trigger')!;
|
|
expect(trigger.textContent).toBe('Accepted RiskDraft');
|
|
expect(trigger.dataset.riskAcceptanceState).toBe('draft');
|
|
expect(trigger.classList.contains('is-draft')).toBe(true);
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
it('keeps entries after a failed save, confirms persistence only after success and renders revision history', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const container = document.createElement('div'); document.body.append(container);
|
|
const root = createRoot(container);
|
|
let fail = true;
|
|
const savedCommands: RiskAcceptanceCommand[] = [];
|
|
function Harness() {
|
|
const [review, setReview] = useState(() => appendRiskAcceptanceReview(undefined, 'occurrence', riskCommand('draft'), new Date().toISOString()));
|
|
return <RiskAcceptanceDialog title="Synthetic risk" occurrenceId="occurrence" value={review.riskAcceptance} onClose={() => {}} onSave={async command => {
|
|
if (fail) throw new Error('Synthetic database unavailable');
|
|
savedCommands.push(command);
|
|
setReview(current => appendRiskAcceptanceReview(current, 'occurrence', command, new Date().toISOString()));
|
|
}}/>;
|
|
}
|
|
try {
|
|
await act(async () => root.render(<Harness/>));
|
|
await fill('Recorded by', 'Synthetic recorder');
|
|
await fill('Change / decision reason', 'Synthetic external approval verified');
|
|
await act(async () => document.querySelector<HTMLInputElement>('.risk-acceptance-confirm input')!.click());
|
|
await click('Record acceptance');
|
|
expect(savedCommands).toHaveLength(0);
|
|
expect(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')).toBe('Review entry');
|
|
expect(document.querySelector('.risk-acceptance-review')?.textContent).toContain('Synthetic external approval verified');
|
|
await click('Change');
|
|
expect(control('Change / decision reason').value).toBe('Synthetic external approval verified');
|
|
await click('Record acceptance');
|
|
await click('OK');
|
|
expect(document.querySelector('[role="alert"]')?.textContent).toContain('Synthetic database unavailable');
|
|
expect(control('Change / decision reason').value).toBe('Synthetic external approval verified');
|
|
expect(document.querySelector('[role="status"]')).toBeNull();
|
|
fail = false;
|
|
await click('Record acceptance');
|
|
await click('OK');
|
|
expect(savedCommands).toHaveLength(1);
|
|
expect(document.querySelector('[role="status"]')?.textContent).toContain('saved successfully');
|
|
expect(document.querySelectorAll('.risk-acceptance-history details')).toHaveLength(2);
|
|
await fill('Change / decision reason', 'Synthetic control failure');
|
|
await click('Revoke acceptance');
|
|
await click('OK');
|
|
expect(document.querySelectorAll('.risk-acceptance-history details')).toHaveLength(3);
|
|
expect(document.querySelector('.risk-acceptance-form')?.textContent).toContain('Revoked');
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
});
|