99 lines
7.5 KiB
TypeScript
99 lines
7.5 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { act } from 'react';
|
||
import { createRoot } from 'react-dom/client';
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { useApp } from '../../src/app/AppProvider';
|
||
import { useFilteredRecords } from '../../src/features/search-filter/use-filtered-records';
|
||
import { DatasetViewer } from '../../src/features/dataset-viewer/DatasetViewer';
|
||
import { FloatingResultsWindow } from '../../src/features/linked-results/FloatingResultsWindow';
|
||
import { makeSheet } from '../../src/importers/sheet-factory';
|
||
import { resultSheetRowToneFor } from '../../src/features/linked-results/result-message';
|
||
|
||
vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; });
|
||
vi.mock('../../src/features/search-filter/use-filtered-records', () => ({ useFilteredRecords: vi.fn() }));
|
||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView'); });
|
||
|
||
function smbFixture() {
|
||
const findings = makeSheet('Synthetic Work', [
|
||
['Title', 'Status', 'Source Rule ID', 'Verwandte Daten', 'Source: Scan'],
|
||
['SMB v1 activated on 1 DC', 'Matched', 'S-SMB-v1', 'synthetic-file::rule-details-S-SMB-v1,synthetic-file::domaincontrollersdetail', 'PingCastle · synthetic']
|
||
], '', 'synthetic.html', { toolId: 'pingcastle', isProjectSheet: true });
|
||
const finding = findings.analysis.records[0]; finding.importFileId = 'synthetic-file';
|
||
const detail = makeSheet('PingCastle · Details', [['Domain controller'], ['DUMMY-DC']], '', 'synthetic.html',
|
||
{ id: 'pingcastle-section-synthetic-file::rule-details-S-SMB-v1' });
|
||
const inventory = makeSheet('PingCastle · Domain controllers', [['Domain controller', 'SMB v1'], ['DUMMY-OTHER', 'NO']], '', 'synthetic.html',
|
||
{ id: 'pingcastle-section-synthetic-file::domaincontrollersdetail' });
|
||
return { findings, finding, detail, inventory };
|
||
}
|
||
|
||
describe.each(['dark', 'light'])('Results affected evidence (%s)', theme => {
|
||
it('opens SMB Results through Work, marks only the exact affected list and retains proof when Work filters hide the finding', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { value: vi.fn(), configurable: true });
|
||
document.documentElement.dataset.theme = theme;
|
||
const { findings, finding, detail, inventory } = smbFixture();
|
||
const before = JSON.stringify([finding, detail, inventory]);
|
||
vi.mocked(useApp).mockReturnValue({ dataset: { sheets: [findings, detail, inventory], rawText: '', profileId: 'pingcastle' },
|
||
activeSheet: findings, view: 'records', search: '', titleColumn: 'Title', selectedRecordId: finding.id,
|
||
reviews: {}, facetFilters: {}, statusFilter: 'all', actionOnly: false,
|
||
setView: vi.fn(), setSelectedRecord: vi.fn(), updateReview: vi.fn(), resetFilters: vi.fn()
|
||
} as unknown as ReturnType<typeof useApp>);
|
||
vi.mocked(useFilteredRecords).mockReturnValue([finding]);
|
||
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
||
try {
|
||
await act(async () => root.render(<DatasetViewer/>));
|
||
await act(async () => [...host.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Results')!.click());
|
||
const red = host.querySelectorAll('.rich-text-table-row--violation');
|
||
expect(red).toHaveLength(1); expect(red[0].textContent).toBe('DUMMY-DC');
|
||
const other = [...host.querySelectorAll('tbody tr')].find(row => row.textContent?.includes('DUMMY-OTHER'))!;
|
||
expect(other.classList.contains('rich-text-table-row--violation')).toBe(false);
|
||
vi.mocked(useFilteredRecords).mockReturnValue([]);
|
||
await act(async () => root.render(<DatasetViewer/>));
|
||
expect(host.querySelector('.rich-text-table-row--violation')!.textContent).toBe('DUMMY-DC');
|
||
expect(JSON.stringify([finding, detail, inventory])).toBe(before);
|
||
} finally { await act(async () => root.unmount()); host.remove(); }
|
||
});
|
||
|
||
it('colors source-marked Maester inline rows and leaves missing/conflicting status or misleading names neutral', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
||
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
||
const value = '| Object | Status |\n| --- | --- |\n| DUMMY-BAD | ❌ Failed |\n| DUMMY-GOOD | ✅ Passed |\n| Fail | Unknown |\n| DUMMY-UNKNOWN | Investigate |\n| ✅ DUMMY-CONFLICT | ❌ Failed |';
|
||
try {
|
||
await act(async () => root.render(<FloatingResultsWindow id="synthetic-inline" title="Results – Synthetic" sheets={[]}
|
||
toolId="maester" outcome="fail" sections={[{ title: 'Test result', value }]} color="" zIndex={1} offset={0} search=""
|
||
onActivate={vi.fn()} onClose={vi.fn()}/>));
|
||
expect(host.querySelectorAll('.rich-text-table-row--violation')).toHaveLength(1);
|
||
expect(host.querySelectorAll('.rich-text-table-row--match')).toHaveLength(1);
|
||
expect(host.querySelectorAll('tbody tr:not([class])')).toHaveLength(3);
|
||
} finally { await act(async () => root.unmount()); host.remove(); }
|
||
});
|
||
});
|
||
|
||
it('fails closed on unknown rules, foreign imports, changed layouts, missing scope and non-fail outcomes', () => {
|
||
const { finding, detail, inventory } = smbFixture(); const row = detail.analysis.records[0];
|
||
const tone = (f = finding, s = detail, outcome: 'fail' | 'pass' | 'warning' = 'fail') => resultSheetRowToneFor('pingcastle', row, outcome, { finding: f, sheet: s });
|
||
expect(tone()).toBe('violation'); expect(tone(finding, detail, 'pass')).toBeUndefined(); expect(tone(finding, detail, 'warning')).toBeUndefined();
|
||
expect(tone({ ...finding, importFileId: 'foreign' })).toBeUndefined();
|
||
expect(tone({ ...finding, importFileId: undefined })).toBeUndefined();
|
||
expect(tone({ ...finding, fields: finding.fields.map(field => field.label === 'Source Rule ID' ? { ...field, value: 'S-Unknown' } : field) })).toBeUndefined();
|
||
expect(tone(finding, { ...detail, analysis: { ...detail.analysis, headers: ['Unknown'] } })).toBeUndefined();
|
||
expect(tone(finding, inventory)).toBeUndefined();
|
||
});
|
||
|
||
it('marks only native-linked, non-ignored Purple Knight affected rows and retains neutral ambiguous/foreign evidence', () => {
|
||
const findings = makeSheet('Synthetic', [['Name', 'Status', 'Source Rule ID', 'Result', 'Source: File'],
|
||
['Synthetic spooler', 'IOE Found', 'SI000077', 'SI000077', 'synthetic-pk.html']], '', 'synthetic-pk.html');
|
||
const finding = findings.analysis.records[0];
|
||
const evidence = makeSheet('Purple Knight · SI000077', [['HostName', 'Ignored'], ['DUMMY-DC', 'False'], ['DUMMY-IGNORED', 'True']], '', 'synthetic-pk.html', { sourceTableIndex: 3 });
|
||
const tone = (sheet = evidence, row = evidence.analysis.records[0], outcome: 'pass' | 'fail' = 'fail') =>
|
||
resultSheetRowToneFor('purple-knight', row, outcome, { finding, sheet });
|
||
expect(tone()).toBe('violation');
|
||
expect(tone(evidence, evidence.analysis.records[1])).toBeUndefined();
|
||
expect(tone(evidence, evidence.analysis.records[0], 'pass')).toBeUndefined();
|
||
expect(tone({ ...evidence, sourceFile: 'foreign.html' })).toBeUndefined();
|
||
expect(tone({ ...evidence, name: 'Similar spooler title', sourceSheet: 'Other' })).toBeUndefined();
|
||
expect(tone({ ...evidence, analysis: { ...evidence.analysis, headers: ['HostName', 'Unknown'] } })).toBeUndefined();
|
||
});
|