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