217 lines
15 KiB
TypeScript
217 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { buildUnifiedResultCsv } from '../../src/exports/unified-result-csv';
|
||
import { act } from 'react';
|
||
import { createRoot } from 'react-dom/client';
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||
import { deriveUnifiedResultFacets, queryUnifiedResultItems, type UnifiedResultProjection } from '../../src/domain/unified-result';
|
||
import { ResultRow, ResultsBrowser } from '../../src/features/results-browser/ResultsBrowser';
|
||
import { discloseResultDbProjectionWithTrace } from '../../src/domain/result-db-disclosure';
|
||
import { ResultDbIdentityGuard } from '../../src/domain/result-db-identity-guard';
|
||
import * as browserDownloads from '../../src/platform/browser/browser-download-adapter';
|
||
import { read, utils } from 'xlsx';
|
||
import { buildUnifiedResultExcel } from '../../src/exports/unified-result-excel';
|
||
import { buildUnifiedResultJsonl } from '../../src/exports/unified-result-jsonl';
|
||
import { browserDownloadAdapter } from '../../src/platform/browser/browser-download-adapter';
|
||
|
||
function reviewedFinding(id: string): UnifiedResultProjection {
|
||
return {
|
||
resultProjectionId: `scanner-finding:${id}`, sourceResultId: id, sourceKind: 'scanner-finding',
|
||
sourceTool: 'purple-knight', sourceRuleId: 'SI000055', projectId: 'synthetic', assessmentId: 'slot',
|
||
collectionRunId: 'collection', scanRunId: 'scan', title: `Synthetic delegation finding ${id}`,
|
||
severityOriginal: 'high', severityNormalized: 'high', outcomeOriginal: 'fail', outcomeNormalized: 'fail',
|
||
technicalExecutionState: 'completed', resultType: 'exposure_finding', isIoe: true, isDeviation: true,
|
||
lifecycleState: 'unknown', mappingConfidence: 1,
|
||
reviewStatus: 'open', reviewDisposition: 'accepted', reviewPriority: 'critical',
|
||
reviewOwner: 'Synthetic Owner', reviewDueDate: '2026-10-01',
|
||
importedAt: '2026-09-28T00:00:00Z', createdAt: '2026-09-28T00:00:00Z', frameworkReferences: [], categories: []
|
||
};
|
||
}
|
||
|
||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||
|
||
describe('Result DB export disclosure', () => {
|
||
it('shows no review column or review filter and exports JSONL without review state or environment fingerprints', async () => {
|
||
const items = [reviewedFinding('a'), {
|
||
...reviewedFinding('b'), sourceTool: 'pingcastle', sourceRuleId: 'A-NotEnoughDC',
|
||
title: 'The number of DCs is too small to provide redundancy: 1 DC',
|
||
description: 'Last backup 55 days ago; 15 accounts never expire.'
|
||
}];
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResultFacets')
|
||
.mockImplementation(async query => deriveUnifiedResultFacets(items, query));
|
||
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResults')
|
||
.mockImplementation(async query => queryUnifiedResultItems(items, query));
|
||
const download = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => undefined);
|
||
const container = document.createElement('div');
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<ResultsBrowser backendStatus="connected" projectId="synthetic" assessmentIds={['slot']}/>));
|
||
expect(container.querySelectorAll('.results-browser-title')).toHaveLength(2);
|
||
const headings = [...container.querySelectorAll('.results-browser-head span')].map(item => item.textContent);
|
||
expect(headings).not.toContain('Review');
|
||
expect(container.querySelector('[data-label="Review"]')).toBeNull();
|
||
const filterHeadings = [...container.querySelectorAll('section.results-filter-group h3')].map(item => item.textContent);
|
||
expect(filterHeadings.some(heading => /review/i.test(heading ?? ''))).toBe(false);
|
||
|
||
const exportButton = [...container.querySelectorAll<HTMLButtonElement>('button')]
|
||
.find(button => button.textContent === 'Export JSONL')!;
|
||
await act(async () => exportButton.click());
|
||
|
||
expect(download).toHaveBeenCalledTimes(1);
|
||
const text = download.mock.calls[0][0].text;
|
||
const [manifest, ...results] = text.trimEnd().split('\n').map(line => JSON.parse(line));
|
||
expect(manifest.handling.classification).toBe('confidential-security-posture');
|
||
expect(results).toHaveLength(2);
|
||
expect(results.map(line => line.result.sourceRuleId).sort()).toEqual(['A-NotEnoughDC', 'SI000055']);
|
||
expect(text).not.toMatch(/reviewStatus|reviewDisposition|reviewPriority|reviewOwner|reviewDueDate|Synthetic Owner|accepted/);
|
||
const dc = results.find(line => line.result.sourceRuleId === 'A-NotEnoughDC')!.result;
|
||
expect(dc.title).toBe('The number of DCs is too small to provide redundancy: [count] DC');
|
||
expect(dc.description).toBe('Last backup [duration] ago; [count] accounts never expire.');
|
||
expect(manifest).not.toHaveProperty('generatedAt');
|
||
expect(download.mock.calls[0][0].fileName).toMatch(/^assessor-result-db-filtered-\d{4}-\d{2}-\d{2}\.jsonl$/);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it('never reveals that a stored row had client review state, neither in the row detail nor in the JSONL export', async () => {
|
||
// The backend serves materialized rows that carry their trace, so this is the real export input.
|
||
const reviewed = discloseResultDbProjectionWithTrace({ ...reviewedFinding('first'), score: 27 });
|
||
const unreviewed = discloseResultDbProjectionWithTrace({
|
||
...reviewedFinding('second'), reviewStatus: undefined, reviewDisposition: undefined,
|
||
reviewPriority: undefined, reviewOwner: undefined, reviewDueDate: undefined, score: 96
|
||
});
|
||
const items = [reviewed, unreviewed];
|
||
expect(reviewed.disclosureTrace?.withheldFields).toEqual(unreviewed.disclosureTrace?.withheldFields);
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResultFacets')
|
||
.mockImplementation(async query => deriveUnifiedResultFacets(items, query));
|
||
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResults')
|
||
.mockImplementation(async query => queryUnifiedResultItems(items, query));
|
||
const download = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => undefined);
|
||
const container = document.createElement('div');
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<ResultRow item={reviewed} expanded={true} onToggle={() => undefined}
|
||
workRecordIds={[]} onOpenInWork={() => undefined}/>));
|
||
expect(container.textContent).not.toMatch(/review|Synthetic Owner/i);
|
||
|
||
await act(async () => root.render(<ResultsBrowser backendStatus="connected" projectId="synthetic" assessmentIds={['slot']}/>));
|
||
const exportButton = [...container.querySelectorAll<HTMLButtonElement>('button')]
|
||
.find(button => button.textContent === 'Export JSONL')!;
|
||
await act(async () => exportButton.click());
|
||
const text = download.mock.calls[0][0].text;
|
||
const results = text.trimEnd().split('\n').slice(1).map(line => JSON.parse(line).result);
|
||
expect(results).toHaveLength(2);
|
||
expect(results.every(result => result.disclosureTrace)).toBe(true);
|
||
expect(text).not.toMatch(/review|Synthetic Owner/i);
|
||
expect(results.some(result => 'score' in result)).toBe(false);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it('shows the stored trace of a row and opens its original in Work without showing it in the Result DB', async () => {
|
||
const stored = discloseResultDbProjectionWithTrace({
|
||
...reviewedFinding('traced'), affectedAsset: 'SYNTHETIC-HOST-9', description: 'Host SRVDC01 answers for 3 hosts.'
|
||
}, new ResultDbIdentityGuard(['SRVDC01']));
|
||
const opened = vi.fn();
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const container = document.createElement('div');
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<ResultRow item={stored} expanded={true} onToggle={() => undefined}
|
||
workRecordIds={['work-record-1']} onOpenInWork={opened}/>));
|
||
const detail = container.querySelector('.results-disclosure-trace')?.textContent ?? '';
|
||
expect(detail).toContain('Checked content');
|
||
expect(detail).toContain('Redacted in Description: count × 1, identity-guard × 1');
|
||
expect(detail).toContain('Withheld fields:');
|
||
expect(container.textContent).not.toContain('SRVDC01');
|
||
const button = [...container.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent === 'Open original in Work')!;
|
||
await act(async () => button.click());
|
||
expect(opened).toHaveBeenCalledWith(['work-record-1']);
|
||
|
||
await act(async () => root.render(<ResultRow item={stored} expanded={true} onToggle={() => undefined} workRecordIds={[]} onOpenInWork={opened}/>));
|
||
expect(container.textContent).toContain('The original is not part of the loaded Work scope.');
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
});
|
||
|
||
it('exports every filtered result to Excel from the button, with the JSONL disclosure rules', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const items = Array.from({length: 1001}, (_, i) => reviewedFinding(String(i)));
|
||
items.push({...reviewedFinding('passed'), isDeviation:false, outcomeNormalized:'pass'});
|
||
vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResultFacets').mockImplementation(async q => deriveUnifiedResultFacets(items, q));
|
||
const query = vi.spyOn(AssessorApiClient.prototype, 'queryUnifiedResults').mockImplementation(async q => queryUnifiedResultItems(items, q));
|
||
vi.spyOn(AssessorApiClient.prototype, 'getResultDbStatus').mockRejectedValue(new Error('Synthetic unavailable status'));
|
||
const download = vi.spyOn(browserDownloads, 'downloadBrowserBlob').mockImplementation(() => {});
|
||
const container = document.createElement('div'); const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<ResultsBrowser backendStatus="connected" projectId="synthetic" assessmentIds={['slot']}/>));
|
||
expect(container.querySelectorAll('.results-browser-title')).toHaveLength(50);
|
||
await act(async () => [...container.querySelectorAll<HTMLButtonElement>('button')].find(b => b.textContent === 'Export Excel')!.click());
|
||
expect(download).toHaveBeenCalledOnce();
|
||
const [blob, name] = download.mock.calls[0];
|
||
expect(name).toMatch(/\.xlsx$/);
|
||
const bytes = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||
const reader = new FileReader(); reader.onload=()=>resolve(reader.result as ArrayBuffer); reader.onerror=()=>reject(reader.error); reader.readAsArrayBuffer(blob);
|
||
});
|
||
const workbook = read(bytes, {type:'array'});
|
||
const rows = utils.sheet_to_json(workbook.Sheets.Results);
|
||
expect(rows).toHaveLength(1001);
|
||
expect(JSON.stringify(rows)).not.toMatch(/reviewOwner|Synthetic Owner|projectId|assessmentId/);
|
||
expect(query.mock.calls.some(([q]) => q?.offset === 1000 && q.deviation === true && q.projectId === 'synthetic')).toBe(true);
|
||
expect(workbook.SheetNames).toContain('Export info');
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it('keeps full long values, nested evidence and formula-like text in literal Excel cells', () => {
|
||
const input = {...reviewedFinding('long'), sourceTool:'pingcastle', sourceRuleId:'A-NotEnoughDC',
|
||
description:'A'.repeat(70000), recommendation:'=HYPERLINK("https://microsoft.com", "Documentation")'};
|
||
const query = {projectId:'synthetic',search:'private search'};
|
||
const disclosed = JSON.parse(buildUnifiedResultJsonl(query,[input],'2026-10-01T12:00:00Z').trim().split('\n')[1]).result;
|
||
const wb = read(buildUnifiedResultExcel(query,[input],'2026-10-01T12:00:00Z'), {type:'array'});
|
||
const row = utils.sheet_to_json<Record<string,unknown>>(wb.Sheets.Results)[0];
|
||
expect(row.description).toContain('Complete value in Long text');
|
||
const parts = utils.sheet_to_json<{Text:string}>(wb.Sheets['Long text']);
|
||
expect(parts.map(p=>p.Text).join('')).toBe(disclosed.description);
|
||
expect(row.recommendation).toBe(disclosed.recommendation);
|
||
for (const [address, cell] of Object.entries(wb.Sheets.Results)) {
|
||
if (!address.startsWith('!')) expect(cell.f).toBeUndefined();
|
||
}
|
||
expect(JSON.stringify(utils.sheet_to_json(wb.Sheets['Export info']))).not.toContain('private search');
|
||
});
|
||
|
||
it('exports the complete filtered CSV from the Result DB button', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const items = Array.from({length:1001},(_,i)=>reviewedFinding(String(i)));
|
||
items.push({...reviewedFinding('passed'),isDeviation:false,outcomeNormalized:'pass'});
|
||
vi.spyOn(AssessorApiClient.prototype,'queryUnifiedResultFacets').mockImplementation(async q=>deriveUnifiedResultFacets(items,q));
|
||
vi.spyOn(AssessorApiClient.prototype,'queryUnifiedResults').mockImplementation(async q=>queryUnifiedResultItems(items,q));
|
||
vi.spyOn(AssessorApiClient.prototype,'getResultDbStatus').mockRejectedValue(new Error('Synthetic status'));
|
||
const download=vi.spyOn(browserDownloadAdapter,'download').mockImplementation(()=>{});
|
||
const container=document.createElement('div');const root=createRoot(container);
|
||
try {
|
||
await act(async()=>root.render(<ResultsBrowser backendStatus="connected" projectId="synthetic" assessmentIds={['slot']}/>));
|
||
await act(async()=>[...container.querySelectorAll<HTMLButtonElement>('button')].find(b=>b.textContent==='Export CSV')!.click());
|
||
expect(download).toHaveBeenCalledOnce();
|
||
const artifact=download.mock.calls[0][0];
|
||
expect(artifact.fileName).toMatch(/\.csv$/);expect(artifact.mediaType).toBe('text/csv;charset=utf-8');
|
||
expect(artifact.text.startsWith('\uFEFF')).toBe(true);
|
||
const wb=read(artifact.text,{type:'string',FS:';',raw:true});
|
||
const rows=utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]);
|
||
expect(rows).toHaveLength(1001);
|
||
expect(artifact.text).not.toMatch(/Synthetic Owner|reviewOwner|projectId|assessmentId/);
|
||
expect(artifact.text).toContain('confidential-security-posture');
|
||
} finally {await act(async()=>root.unmount());}
|
||
});
|
||
|
||
it('round-trips CSV separators, quotes, line breaks, Unicode and long disclosed values safely',()=>{
|
||
const input={...reviewedFinding('csv'),sourceTool:'pingcastle',sourceRuleId:'A-NotEnoughDC',
|
||
description:'A'.repeat(70000)+'; "Übersicht"\nNext line',recommendation:'\n=HYPERLINK("https://microsoft.com", "Documentation")'};
|
||
const expected=JSON.parse(buildUnifiedResultJsonl({},[input],'2026-10-01').trim().split('\n')[1]).result;
|
||
const csv=buildUnifiedResultCsv({},[input],'2026-10-01');
|
||
const wb=read(csv,{type:'string',FS:';',raw:true});
|
||
const row=utils.sheet_to_json<Record<string,string>>(wb.Sheets[wb.SheetNames[0]])[0];
|
||
expect(row.description).toBe(expected.description);
|
||
expect(row.recommendation).toBe("'"+expected.recommendation);
|
||
expect(row.categories).toBe(JSON.stringify(expected.categories));
|
||
});
|