Assessor_2.0/tests/features/client-business-impact.test...

123 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
import { useApp } from '../../src/app/AppProvider';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { makeSheet } from '../../src/importers/sheet-factory';
import { genericProfile } from '../../src/profiles/generic/generic-profile';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
import { resetAssessmentContextRequestsForTests } from '../../src/features/context-layer/use-assessment-context';
import { emptyHeatmapContext } from '../assessment-context/context-heatmap-fixture';
import { SidebarSecurityDomains } from '../../src/features/sidebar/SidebarSecurityDomains';
import { filterAndSortRecords } from '../../src/features/search-filter/filter-records';
import { contextClusterIndex, CONTEXT_CLUSTER_FACET_ID } from '../../src/features/context-layer/context-cluster-facet';
import { BUSINESS_IMPACT_FACET_ID } from '../../src/features/search-filter/business-impact-controls';
import type { ImportedDataset } from '../../src/domain/dataset';
import { selectWorkFacets } from '../../src/features/search-filter/work-facets';
import { DynamicFilterControls } from '../../src/features/search-filter/DynamicFilterControls';
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> }));
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); delete document.documentElement.dataset.theme; });
it.each(['dark', 'light'])('places four disjoint theme bars below Security Domains, omits unassigned and preserves Core with engines off (%s)', async theme => {
document.documentElement.dataset.theme = theme;
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
localStorage.clear(); resetEnginePreferencesForTests(); resetAssessmentContextRequestsForTests();
const sheet = makeSheet('Synthetic', [['Name', 'Status'], ['One', 'Failed'], ['Two', 'Failed'], ['Unknown', 'Failed']], '', 'synthetic.csv', { isProjectSheet: true });
sheet.analysis.records.forEach((record, index) => { record.findingOccurrenceId = `occurrence-${index}`; });
const dataset = { fileName: 'Synthetic client', sheets: [sheet], scanTool: 'Generic' } as ImportedDataset;
vi.mocked(useApp).mockReturnValue({ dataset, activeSheet: sheet, activeProfile: genericProfile, reviews: {},
targetProjectId: 'synthetic-client', activeAssessmentIds: ['synthetic-timeslot'], activeScanRunIds: ['synthetic-run'], facetFilters: {}, search: '', statusFilter: 'all', actionOnly: false,
setStatusFilter: vi.fn(), setFacetFilter: vi.fn(), setPathFindingIds: vi.fn(), resetFilters: vi.fn(), setView: vi.fn(), setActionOnly: vi.fn(), setActiveSheet: vi.fn()
} as unknown as ReturnType<typeof useApp>);
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue({ observations: [], sections: [] } as never);
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
const context = emptyHeatmapContext();
const cluster = context.clusters.find(item => item.clusterId === 'adcs-escalation')!;
const conditionId = cluster.conditions[0].conditionId;
for (let index = 0; index < 2; index++) {
context.observations.push({ id: `observation-${index}`, conditionId, occurrenceId: `occurrence-${index}`, resultProjectionId: `result-${index}`,
tool: 'purple-knight', sourceRuleId: 'synthetic-rule', ruleText: 'Synthetic', outcome: 'fail', matchedBy: 'rule-id', assertion: 'observed-by-scanner' });
context.findings.push({ occurrenceId: `occurrence-${index}`, resultProjectionId: `result-${index}`, tool: 'purple-knight', sourceRuleId: 'synthetic-rule',
observationIds: [], conditionIds: [conditionId], clusterIds: ['adcs-escalation'], declaredReferenceIds: [], cisControlAreaIds: [] });
}
// A category attachment without published deviation evidence must not inflate the Business Impact filter.
context.findings.push({ occurrenceId: 'occurrence-2', resultProjectionId: 'result-2', tool: 'purple-knight', sourceRuleId: 'unmapped',
observationIds: [], conditionIds: [], clusterIds: ['adcs-escalation'], declaredReferenceIds: [], cisControlAreaIds: [] });
const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context);
const before = JSON.stringify(dataset);
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
const openWork = vi.fn();
try {
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
const chart = host.querySelector('.client-domains .client-business-impact')!;
expect(chart).toBeTruthy();
expect(chart.querySelector('h3')?.textContent).toBe('Possible Business Impact');
// User decision 2026-10-04: four disjoint themes in a four-column row; financial and compliance are never bars.
expect(chart.querySelectorAll('[role="listitem"]')).toHaveLength(4);
expect([...chart.querySelectorAll('.client-business-impact-label')].map(item => item.textContent)).toEqual(
['Control & trust', 'Information & data', 'Operational continuity', 'Resilience & recovery']);
expect([...chart.querySelectorAll('strong')].map(item => item.textContent)).toEqual(['2', '0', '0', '0']);
expect(chart.textContent).not.toMatch(/Financial|Compliance/);
expect(chart.textContent).not.toMatch(/unassigned|not assigned|nicht eindeutig/i);
expect(chart.querySelector('[data-theme="control"] .client-business-impact-label')?.textContent).toBe('Control & trust');
await act(async () => (chart.querySelector('[data-theme="control"] button') as HTMLButtonElement).click());
const app = vi.mocked(useApp).mock.results.at(-1)!.value as ReturnType<typeof useApp>;
const selected = { values: ['control'] };
expect(app.setFacetFilter).toHaveBeenLastCalledWith(BUSINESS_IMPACT_FACET_ID, selected);
expect(app.setFacetFilter).not.toHaveBeenCalledWith(CONTEXT_CLUSTER_FACET_ID, expect.anything());
expect(app.setStatusFilter).toHaveBeenLastCalledWith('deviation');
expect(openWork).toHaveBeenCalledTimes(1);
const matching = filterAndSortRecords(sheet, { dataset, activeProfile: genericProfile, reviews: {}, search: '', actionOnly: false,
sortColumn: '', sortDirection: 'asc', statusFilter: 'deviation', facetFilters: { [BUSINESS_IMPACT_FACET_ID]: selected }, contextClusters: contextClusterIndex(context) });
expect(matching.map(record => record.findingOccurrenceId)).toEqual(['occurrence-0', 'occurrence-1']);
const core = () => { const clone = host.cloneNode(true) as HTMLElement; clone.querySelector('.client-business-impact')?.remove(); clone.querySelector('.context-heatmap')?.remove();
clone.querySelector('.client-report-actions')?.remove(); clone.querySelector('.client-report-error')?.remove();
return clone.querySelector('.client-overview-main')?.innerHTML; };
const baseline = core();
await act(async () => setEngineEnabled('report', false));
expect(host.querySelector('.client-business-impact')).toBeNull(); expect(core()).toBe(baseline);
await act(async () => setEngineEnabled('report', true));
expect(host.querySelectorAll('.client-business-impact-column')).toHaveLength(4);
expect(request).toHaveBeenCalledTimes(1);
await act(async () => setEngineEnabled('context', false));
expect(host.querySelector('.client-business-impact')).toBeNull(); expect(core()).toBe(baseline);
expect(JSON.stringify(dataset)).toBe(before);
await act(async () => setEngineEnabled('context', true));
await act(async () => root.render(<SidebarSecurityDomains sheet={sheet}/>));
const sidebarTheme = host.querySelector('[data-filter-id="report:business-impact"] button') as HTMLButtonElement;
expect(host.querySelectorAll('[data-filter-id="report:business-impact"] button')).toHaveLength(4);
expect(sidebarTheme.textContent).toBe('Control & trust2');
await act(async () => sidebarTheme.click());
expect(app.setFacetFilter).toHaveBeenLastCalledWith(BUSINESS_IMPACT_FACET_ID, selected);
app.facetFilters = { [BUSINESS_IMPACT_FACET_ID]: selected };
const workFilters = () => <><SidebarSecurityDomains sheet={sheet}/><DynamicFilterControls
facets={selectWorkFacets(sheet, { dataset, activeProfile: genericProfile, reviews: {}, search: '', actionOnly: false,
statusFilter: 'deviation', facetFilters: app.facetFilters, contextClusters: contextClusterIndex(context) })}
filters={app.facetFilters} statusFilter="deviation" onStatusFilter={app.setStatusFilter} onFilter={app.setFacetFilter}/></>;
await act(async () => root.render(workFilters()));
expect(host.querySelector<HTMLSelectElement>('select[aria-label="Business Impact"]')?.value).toBe('control');
expect(host.querySelector<HTMLSelectElement>('select[aria-label="Context Cluster"]')?.value).toBe('');
const contextSelection = { values: ['adcs-escalation'] };
app.facetFilters = { [BUSINESS_IMPACT_FACET_ID]: selected, [CONTEXT_CLUSTER_FACET_ID]: contextSelection };
await act(async () => root.render(workFilters()));
expect(host.querySelector<HTMLSelectElement>('select[aria-label="Context Cluster"]')?.value).toBe('adcs-escalation');
const activeTheme = host.querySelector('[data-filter-id="report:business-impact"] button') as HTMLButtonElement;
expect(activeTheme.getAttribute('aria-pressed')).toBe('true');
await act(async () => activeTheme.click());
expect(app.setFacetFilter).toHaveBeenLastCalledWith(BUSINESS_IMPACT_FACET_ID, null);
expect(app.facetFilters[CONTEXT_CLUSTER_FACET_ID]).toBe(contextSelection);
expect(app.setFacetFilter).not.toHaveBeenCalledWith(CONTEXT_CLUSTER_FACET_ID, expect.anything());
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
await act(async () => (host.querySelector('[data-theme="control"] button') as HTMLButtonElement).click());
expect(app.setFacetFilter).toHaveBeenCalledWith(CONTEXT_CLUSTER_FACET_ID, contextSelection);
expect(app.setFacetFilter).toHaveBeenLastCalledWith(BUSINESS_IMPACT_FACET_ID, selected);
await act(async () => root.render(<SidebarSecurityDomains sheet={sheet}/>));
await act(async () => setEngineEnabled('report', false));
expect(host.querySelector('[data-filter-id="report:business-impact"]')).toBeNull();
} finally { act(() => root.unmount()); host.remove(); }
});