Assessor_2.0/tests/features/results-browser-export-disc...

217 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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));
});