Assessor_2.0/tests/features/security-html-export.test.tsx

151 lines
14 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useApp } from '../../src/app/AppProvider';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
import { resetAssessmentContextRequestsForTests } from '../../src/features/context-layer/use-assessment-context';
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
import { browserDownloadAdapter } from '../../src/platform/browser/browser-download-adapter';
import { makeSheet } from '../../src/importers/sheet-factory';
import { emptyHeatmapContext } from '../assessment-context/context-heatmap-fixture';
import { clientFactsSnapshot, clientFactsState } from '../fixtures/client-info-facts';
vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; });
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => <span>Synthetic assessment</span> }));
// Vitest suppresses CSS modules in jsdom; use the actual style source for the export assertion.
vi.mock('../../src/styles/ui-tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/ui-tokens.css', 'utf8') }));
vi.mock('../../src/styles/tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/tokens.css', 'utf8') }));
vi.mock('../../src/styles/base.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/base.css', 'utf8') }));
vi.mock('../../src/styles/components.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/components.css', 'utf8') }));
vi.mock('../../src/styles/datahub.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/datahub.css', 'utf8') }));
vi.mock('../../src/styles/client-security.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/client-security.css', 'utf8') }));
vi.mock('../../src/styles/policy-analysis.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/policy-analysis.css', 'utf8') }));
vi.mock('../../src/styles/security-scoring.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/security-scoring.css', 'utf8') }));
vi.mock('../../src/styles/functional-icons.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/functional-icons.css', 'utf8') }));
function setup(theme: string) {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); resetAssessmentContextRequestsForTests();
document.documentElement.dataset.theme = theme; document.documentElement.dataset.density = 'reading';
const sheet = makeSheet('Synthetic full assessment', [['Name', 'Status', 'Severity', 'Description', 'Remediation', 'Source: Scan'],
['Synthetic privileged A', 'Failed', 'High', '<p>Original explanation A.</p>', 'Original remediation A.', 'Maester'],
['Synthetic privileged B', 'Failed', 'Medium', 'Original explanation B.', 'Original remediation B.', 'Purple Knight'],
['Synthetic passed', 'Passed', 'High', 'Passed evidence', '', 'Maester'],
['Synthetic unassessed', 'Not configured', '', 'No verdict', '', 'GPOs']
], '', 'synthetic.csv', { isProjectSheet: true });
const context = emptyHeatmapContext();
const cluster = context.clusters.find(item => item.clusterId === 'cloud-privileged-access-exposure')!;
sheet.analysis.records.slice(0, 2).forEach((record, index) => {
const occurrenceId = `synthetic-${index}`; record.findingOccurrenceId = occurrenceId;
context.observations.push({ id: occurrenceId, conditionId: cluster.conditions[0].conditionId, occurrenceId, resultProjectionId: occurrenceId,
tool: 'maester', sourceRuleId: 'SYNTHETIC', ruleText: record.title, matchedBy: 'rule-id', outcome: 'fail', assertion: 'observed-by-scanner' });
context.findings.push({ occurrenceId, resultProjectionId: occurrenceId, tool: 'maester', sourceRuleId: 'SYNTHETIC', observationIds: [occurrenceId],
conditionIds: [cluster.conditions[0].conditionId], clusterIds: [cluster.clusterId], declaredReferenceIds: [], cisControlAreaIds: [] });
});
const snapshot = structuredClone(clientFactsSnapshot);
snapshot.sections[0].tables.push({ name: 'Synthetic 1000 rows', headers: ['Native account'], rows: Array.from({ length: 1000 }, (_, index) => [`Native row ${index}`]), cellLinks: [{ rowIndex: 0, columnIndex: 0, targetId: 'modal' }] });
snapshot.sections[0].tables.push({ name: 'Synthetic linked detail', headers: ['Native detail'], rows: [['Last modal evidence']], presentation: 'modal', targetId: 'modal' });
const app = { ...clientFactsState(), activeSheet: sheet, dataset: { fileName: 'Synthetic <client>', sheets: [sheet], scanTool: 'Generic' },
search: 'absent Work search', statusFilter: 'passed', actionOnly: true, pathFindingIds: ['absent Graph row'],
facetFilters: { 'provenance:source-scan': { values: ['Absent source'] } } };
vi.mocked(useApp).mockReturnValue(app as unknown as ReturnType<typeof useApp>);
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(snapshot);
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context);
const health = vi.spyOn(AssessorApiClient.prototype, 'health').mockResolvedValue({ repositoryRevision: 1, productVersion: 'synthetic' } as never);
vi.spyOn(AssessorApiClient.prototype, 'getProject').mockResolvedValue({ name: 'Synthetic <client>' } as never);
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({ assessments: [{ id: 'synthetic-assessment', name: 'Synthetic timeslot', effectiveAt: '2026-10-12' }], scanRuns: [] } as never);
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return { width: this.classList.contains('client-security-map') ? 1280 : 2320, height: 800, x: 0, y: 0, left: 0, top: 0, right: 2320, bottom: 800, toJSON() {} } as DOMRect;
});
const download = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => {});
return { app, snapshot, context, health, download };
}
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); localStorage.clear(); resetEnginePreferencesForTests(); resetAssessmentContextRequestsForTests(); });
describe.each(['light', 'dark'])('Complete Security HTML export (%s)', theme => {
it('exports the complete actual tab including closed details, all native rows, branding, confidentiality and exact scope without changing Work', async () => {
const fixture = setup(theme); const before = JSON.stringify(fixture); const openWork = vi.fn();
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
await act(async () => [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(tab => tab.textContent === 'Security')!.click());
const panel = host.querySelector('.client-security-panel')!;
const originalSvg = panel.querySelector('.client-security-map svg')!.outerHTML;
expect(panel.querySelector('.client-security-finding-detail')).toBeNull();
const button = [...panel.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'HTML Security Export')!;
expect(button.disabled).toBe(false);
await act(async () => { button.click(); await vi.dynamicImportSettled(); });
expect(fixture.download).toHaveBeenCalledTimes(1);
const output = fixture.download.mock.calls[0][0];
if (process.env.ASSESSOR_SECURITY_EXPORT_PREVIEW_DIR) {
mkdirSync(process.env.ASSESSOR_SECURITY_EXPORT_PREVIEW_DIR, { recursive: true });
writeFileSync(join(process.env.ASSESSOR_SECURITY_EXPORT_PREVIEW_DIR, `synthetic-security-${theme}.html`), output.text);
}
expect(output.fileName).toMatch(/^ASSESSOR-Security-Synthetic-client-.*\.html$/);
const report = new DOMParser().parseFromString(output.text, 'text/html');
expect(report.documentElement.dataset.theme).toBe(theme); expect(report.documentElement.dataset.density).toBe('reading');
expect(report.querySelector('title')?.textContent).toContain('Synthetic <client>');
expect(report.querySelector('.security-export-confidential')?.textContent).toBe('VERTRAULICH');
expect(report.querySelector('img[alt="ASSESSOR Logo"]')?.getAttribute('src')).toMatch(/^data:image/);
expect(report.querySelector('style')?.textContent).toContain('width:min(2320px,calc(100vw * 2 / 3))');
expect(/minmax\(0,\s*2fr\)\s+minmax\(0,\s*1fr\)/.test(report.querySelector('style')?.textContent ?? '')).toBe(true);
expect(report.querySelector('.client-security-remediation strong')?.textContent).toBe('2');
expect(report.querySelectorAll('.client-security-group')).toHaveLength(panel.querySelectorAll('.client-security-group').length);
expect(report.querySelectorAll('.client-security-finding')).toHaveLength(2);
for (const phrase of ['Original explanation A.', 'Original remediation B.', 'Native row 999', 'Last modal evidence', 'Revision 1', 'Synthetic timeslot', 'Assessment facts', 'Scoring methodology', 'Datenstand und Versionen']) expect(report.body.textContent).toContain(phrase);
const exportedSvg = report.querySelector('.client-security-map svg')!;
const nativeTable = [...report.querySelectorAll('table')].find(table => table.querySelector('th')?.textContent === 'Native account')!;
expect(nativeTable.querySelectorAll('tbody tr')).toHaveLength(1000);
const nestedTrigger = nativeTable.querySelector<HTMLButtonElement>('button[popovertarget]')!;
expect(nestedTrigger.textContent).toBe('Native row 0');
expect(report.getElementById(nestedTrigger.getAttribute('popovertarget')!)?.textContent).toContain('Last modal evidence');
const resultsTrigger = report.querySelector<HTMLButtonElement>('[aria-label="Open Assessment facts results"]')!;
const popup = report.getElementById(resultsTrigger.getAttribute('popovertarget')!)!;
expect(popup.getAttribute('popover')).toBe('auto'); expect(popup.getAttribute('role')).toBe('dialog');
expect(popup.closest('.security-export')).toBeNull();
expect(popup.querySelector('header')?.lastElementChild?.getAttribute('popovertargetaction')).toBe('hide');
const reportCss = report.querySelector('style')?.textContent ?? '';
expect(reportCss).not.toContain('zoom:');
expect(reportCss).toContain('.security-export-popup-body{overflow:auto;overscroll-behavior:contain;min-height:0;padding:var(--panel-inset);font-size:var(--type-table-body-size)}');
expect(reportCss).toContain('.client-security-map-scroll>svg{width:1280px;max-width:none}');
expect(reportCss).toContain('@container security-report (max-width:1800px)');
expect(reportCss).toContain('.security-export-popup .security-export-popup-body p');
expect(reportCss).toContain('.security-export-popup .security-export-popup-body dd');
expect(report.querySelector('style')?.textContent?.includes('.triangle-icon-right { transform: rotate(180deg); }')).toBe(true);
expect(report.querySelector('style')?.textContent?.includes('transform:rotate(-90deg)')).toBe(true);
expect(exportedSvg.getAttribute('viewBox')).toBe(new DOMParser().parseFromString(originalSvg, 'text/html').querySelector('svg')!.getAttribute('viewBox'));
expect(exportedSvg.querySelectorAll('path')).toHaveLength(panel.querySelectorAll('.client-security-map path').length);
expect(report.querySelectorAll('script,iframe,link[src],img[src^="http"]')).toHaveLength(0);
expect(report.querySelector('[http-equiv="Content-Security-Policy"]')?.getAttribute('content')).toContain("default-src 'none'");
expect(report.querySelector('.client-security-group')?.tagName).toBe('DETAILS');
expect(fixture.health).toHaveBeenCalledTimes(2); expect(openWork).not.toHaveBeenCalled();
expect(panel.querySelector('.client-security-finding-detail')).toBeNull();
expect(JSON.stringify(fixture)).toBe(before);
} finally { await act(async () => root.unmount()); host.remove(); }
});
it('blocks mixed revisions and suppresses a late export when Report Engine is switched off', async () => {
const fixture = setup(theme); let finish!: (value: never) => void;
fixture.health.mockResolvedValueOnce({ repositoryRevision: 2 } as never);
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DashboardPanel onOpenWork={() => {}}/>));
await act(async () => [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(tab => tab.textContent === 'Security')!.click());
const clickExport = () => [...host.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'HTML Security Export')!.click();
await act(async () => { clickExport(); await vi.dynamicImportSettled(); });
expect(fixture.download).not.toHaveBeenCalled(); expect(host.textContent).toContain('Reload the client');
fixture.health.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await act(async () => { clickExport(); await vi.dynamicImportSettled(); });
await act(async () => setEngineEnabled('report', false));
await act(async () => finish({ repositoryRevision: 1 } as never));
expect(fixture.download).not.toHaveBeenCalled();
expect([...host.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'HTML Security Export')!.disabled).toBe(true);
} finally { await act(async () => root.unmount()); host.remove(); }
});
});