// @vitest-environment jsdom import { act } from 'react'; import { readFileSync } from 'node:fs'; import { createRoot, type Root } from 'react-dom/client'; import { beforeEach, afterEach, expect, it, vi } from 'vitest'; import { useApp } from '../../src/app/AppProvider'; import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences'; import { AssessorApiClient } from '../../src/api/assessor-api-client'; import { ClientContextHeatmap, ContextHeatmap } from '../../src/features/context-layer/ContextHeatmap'; import { resetAssessmentContextRequestsForTests } from '../../src/features/context-layer/use-assessment-context'; import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel'; import { makeSheet } from '../../src/importers/sheet-factory'; import { genericProfile } from '../../src/profiles/generic/generic-profile'; import { syntheticHeatmapContext, reassignedHeatmapContext, emptyHeatmapContext } from './context-heatmap-fixture'; import { contextClusterIndex } from '../../src/features/context-layer/context-cluster-facet'; import type { FindingRecord } from '../../src/domain/finding'; vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => Synthetic timeslot })); let host: HTMLDivElement; let root: Root; beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); localStorage.clear(); resetEnginePreferencesForTests(); resetAssessmentContextRequestsForTests(); host = document.createElement('div'); document.body.append(host); root = createRoot(host); const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity'], ['Control A', 'Failed', 'High']], '', 'synthetic.csv', { isProjectSheet: true }); vi.mocked(useApp).mockReturnValue({ dataset: { fileName: 'Synthetic client', sheets: [sheet], scanTool: 'Generic' }, activeSheet: sheet, activeProfile: genericProfile, reviews: {}, targetProjectId: 'synthetic-client', activeAssessmentIds: ['synthetic-timeslot'], activeScanRunIds: ['synthetic-run'], facetFilters: {}, 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); vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue({ observations: [], sections: [] } as never); vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never); }); afterEach(() => { act(() => root.unmount()); host.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it.each(['dark', 'light'])('shows only severity, category and total findings and opens the cluster directly in %s', async theme => { document.documentElement.dataset.theme = theme; const context = syntheticHeatmapContext(); const onOpenCluster = vi.fn(); await act(async () => root.render()); expect(host.querySelectorAll('.context-heatmap__cell')).toHaveLength(22); const tile = host.querySelector('[data-cluster="cloud-privileged-access-exposure"]')!; expect(tile.dataset.severity).toBe('critical'); expect(tile.children).toHaveLength(3); const contour = readFileSync('src/features/context-layer/context-heatmap.css', 'utf8'); expect(contour).toContain('min-height: var(--tile-h)'); expect(contour).not.toContain('aspect-ratio:'); expect(contour).toContain('padding: var(--tile-inset)'); expect(contour).not.toContain('clip-path:'); expect(contour).not.toContain('--context-hex-width'); expect(tile.querySelector('.context-heatmap__number')?.textContent).toBe('01'); expect(tile.querySelector('strong')?.textContent).toBe('Cloud Privileged Access Exposure'); expect(tile.querySelector('.context-heatmap__category')?.textContent).toBe('Critical'); expect(tile.textContent).not.toContain('Base Severity'); expect(host.querySelector('.context-heatmap__key')).toBeNull(); expect(host.textContent).not.toContain('Badges: Base Severity'); expect(tile.textContent).toContain('2 findings'); expect(host.textContent).not.toMatch(/supporting conditions|recognized|estimated|assigned deviations|Severity catalog/); await act(async () => tile.click()); expect(onOpenCluster).toHaveBeenCalledWith('cloud-privileged-access-exposure'); expect(host.querySelector('[data-cluster="credential-relay-exposure"]')?.getAttribute('data-severity')).toBe('neutral'); expect(host.querySelector('.context-heatmap__detail')).toBeNull(); const ranks = [...host.querySelectorAll('.context-heatmap__cell')].map(cell => ({ critical: 0, high: 1, medium: 2, neutral: 3, undetermined: 3 })[cell.dataset.severity!] ?? 3); expect(ranks).toEqual([...ranks].sort((a, b) => a - b)); }); it.each(['dark', 'light'])('moves an empty High category into the High group when populated and opens its exact findings in %s', async theme => { document.documentElement.dataset.theme = theme; const context = syntheticHeatmapContext(); const clusterId = 'email-spoofing-phishing-exposure'; const cluster = context.clusters.find(item => item.clusterId === clusterId)!; const conditionIds = new Set(cluster.conditions.map(item => item.conditionId)); cluster.satisfied = 0; context.observations = context.observations.filter(item => !conditionIds.has(item.conditionId)); const onOpenCluster = vi.fn(); await act(async () => root.render()); let tile = host.querySelector(`[data-cluster="${clusterId}"]`)!; expect(tile.dataset.severity).toBe('neutral'); expect(tile.querySelector('.context-heatmap__category')?.textContent).toBe('High'); expect(tile.textContent).toContain('0 findings'); let cells = [...host.querySelectorAll('.context-heatmap__cell')]; const lastMediumIndex = cells.reduce((last, item, index) => item.dataset.severity === 'medium' ? index : last, -1); expect(cells.indexOf(tile)).toBeGreaterThan(lastMediumIndex); const conditionId = cluster.conditions[0].conditionId; const populated = { ...context, observations: [...context.observations, { id: 'synthetic-email', conditionId, tool: 'purple-knight' as const, sourceRuleId: 'SYNTHETIC', ruleText: 'Synthetic email deviation', matchedBy: 'rule-id' as const, resultProjectionId: 'synthetic-email', occurrenceId: 'synthetic-email', outcome: 'fail' as const, assertion: 'observed-by-scanner' as const }] }; await act(async () => root.render()); tile = host.querySelector(`[data-cluster="${clusterId}"]`)!; expect(tile.dataset.severity).toBe('high'); expect(tile.textContent).toContain('1 finding'); cells = [...host.querySelectorAll('.context-heatmap__cell')]; expect(cells.indexOf(tile)).toBeLessThan(cells.findIndex(item => item.dataset.severity === 'medium')); await act(async () => tile.click()); expect(onOpenCluster).toHaveBeenCalledExactlyOnceWith(clusterId); }); it.each(['dark', 'light'])('shows only the heading and unique findings total without the removed badge explanation in %s', async theme => { document.documentElement.dataset.theme = theme; const context = reassignedHeatmapContext(); const otherCondition = context.clusters.find(item => item.clusterId === 'adcs-escalation')!.conditions[0].conditionId; context.observations.push({ ...context.observations[0], id: 'overlapping-evidence', conditionId: otherCondition }); context.observations.push({ ...context.observations[0], id: 'passed-evidence', occurrenceId: 'passed-only', outcome: 'pass' }); context.recognitions.push({ ...context.recognitions[0], id: 'overlapping-setting', clusterId: 'adcs-escalation' }); context.evaluations.push({ ...context.evaluations[0], id: 'passed-setting', identityKey: 'passed-only', matchedBy: 'setting', verdict: 'passed' }); const original = JSON.stringify(context); const onOpenCluster = vi.fn(); await act(async () => root.render()); const header = host.querySelector('.context-heatmap__header')!; expect(header.textContent).toBe('Context heatmapFindings: 31'); expect(header.children).toHaveLength(2); expect(header.firstElementChild?.tagName).toBe('H2'); expect(header.firstElementChild?.nextElementSibling?.className).toBe('context-heatmap__total'); expect(host.querySelector('.context-heatmap__legend')).toBeNull(); expect(host.textContent).not.toContain('Selected timeslot'); await act(async () => host.querySelector('[data-cluster="other-deviations"]')!.click()); expect(onOpenCluster).toHaveBeenCalledExactlyOnceWith('other-deviations'); expect(header.textContent).toBe('Context heatmapFindings: 31'); expect(JSON.stringify(context)).toBe(original); await act(async () => root.render()); expect(host.querySelector('.context-heatmap__header')!.textContent).toBe('Context heatmapFindings: 0'); expect([...host.querySelectorAll('.context-heatmap__amount')].every(cell => cell.textContent === '0 findings')).toBe(true); }); it('uses the real Client Info click path to open all sources for exactly the selected Work cluster', async () => { vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticHeatmapContext()); const projectSheet = useApp().activeSheet!; const sourceSheet = makeSheet('Source only', [['Name', 'Status'], ['Control B', 'Fail']], '', 'source.csv'); useApp().dataset!.sheets.push(sourceSheet); useApp().activeSheet = sourceSheet; useApp().facetFilters = { 'provenance:source-scan': { values: ['Scanner A'] } }; const onOpenWork = vi.fn(); await act(async () => root.render()); await act(async () => host.querySelector('[data-cluster="cloud-privileged-access-exposure"]')!.click()); expect(useApp().resetFilters).toHaveBeenCalledOnce(); expect(useApp().setStatusFilter).toHaveBeenCalledWith('deviation'); expect(useApp().setActionOnly).toHaveBeenCalledWith(false); expect(useApp().setFacetFilter).toHaveBeenCalledExactlyOnceWith('context:cluster', { values: ['cloud-privileged-access-exposure'] }); expect(useApp().setView).toHaveBeenCalledWith('columns'); expect(useApp().setActiveSheet).toHaveBeenCalledWith(projectSheet.id); expect(onOpenWork).toHaveBeenCalledOnce(); expect(host.querySelector('.context-heatmap__detail')).toBeNull(); }); it('opens Other with the same count as its actual Work membership after GPO reassignment', async () => { const context = reassignedHeatmapContext(); vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context); const onOpenWork = vi.fn(); await act(async () => root.render()); const tile = host.querySelector('[data-cluster="other-deviations"]')!; expect(tile.textContent).toContain('2 findings'); expect(tile.textContent).not.toContain('31 findings'); expect(host.querySelector('.context-heatmap__header')!.textContent).toBe('Context heatmapFindings: 31'); await act(async () => tile.click()); expect(useApp().setFacetFilter).toHaveBeenCalledWith('context:cluster', { values: ['other-deviations'] }); expect(onOpenWork).toHaveBeenCalledOnce(); const records = [ ...context.findings.map(item => ({ findingOccurrenceId: item.occurrenceId, fields: [] })), ...context.settings.map(item => ({ fields: [{ label: 'Technical Identity', value: item.identityKey }, { label: 'Scope', value: item.scope }] })) ] as unknown as FindingRecord[]; const facet = contextClusterIndex(context).facet; expect(records.filter(record => facet.valuesFor!(record).includes('other-deviations'))).toHaveLength(2); }); it('toggles the real Client Info extension without changing Core Overview content or Security totals or making requests while off', async () => { const context = syntheticHeatmapContext(); const original = JSON.stringify(useApp().dataset); const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context); setEngineEnabled('context', false); await act(async () => root.render()); // Security now has its own published Context categories. Compare the original Overview // surface in full, and independently preserve the authoritative Security total. const core = () => { const clone = host.querySelector('[role="tabpanel"]')!.cloneNode(true) as HTMLElement; clone.querySelector('.context-heatmap')?.remove(); clone.querySelector('.client-business-impact')?.remove(); return clone.innerHTML; }; const securityTotal = () => host.querySelector('.client-security-header')!.textContent; const baseline = core(); const securityBaseline = securityTotal(); expect(securityBaseline).toContain('1 deviations'); expect(request).not.toHaveBeenCalled(); await act(async () => setEngineEnabled('context', true)); expect(request).toHaveBeenCalledTimes(1); expect(request).toHaveBeenCalledWith('synthetic-client', ['synthetic-timeslot'], ['synthetic-run']); expect(host.querySelectorAll('.context-heatmap__cell')).toHaveLength(22); expect(core()).toBe(baseline); expect(securityTotal()).toBe(securityBaseline); await act(async () => setEngineEnabled('context', false)); expect(host.querySelector('.context-heatmap')).toBeNull(); expect(core()).toBe(baseline); expect(securityTotal()).toBe(securityBaseline); expect(JSON.stringify(useApp().dataset)).toBe(original); expect(useApp().setFacetFilter).not.toHaveBeenCalled(); await act(async () => setEngineEnabled('context', true)); expect(request).toHaveBeenCalledTimes(1); }); it('suppresses late results when disabled and shows an error instead of a falsely empty heatmap', async () => { let complete!: (value: ReturnType) => void; vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockImplementation(() => new Promise(resolve => { complete = resolve; })); await act(async () => root.render()); expect(host.textContent).toContain('Loading Context'); await act(async () => setEngineEnabled('context', false)); await act(async () => complete(syntheticHeatmapContext())); expect(host.textContent).toBe(''); resetAssessmentContextRequestsForTests(); vi.mocked(AssessorApiClient.prototype.getAssessmentContext).mockRejectedValue(new Error('Synthetic unavailable')); await act(async () => setEngineEnabled('context', true)); expect(host.querySelector('[role="alert"]')?.textContent).toContain('could not be loaded'); expect(host.querySelector('.context-heatmap__cell')).toBeNull(); });