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

131 lines
8.5 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 { 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 { 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()); }
});
});