215 lines
15 KiB
TypeScript
215 lines
15 KiB
TypeScript
// @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: () => <span>Synthetic timeslot</span> }));
|
|
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<typeof useApp>);
|
|
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(<ContextHeatmap context={context} onOpenCluster={onOpenCluster} />));
|
|
expect(host.querySelectorAll('.context-heatmap__cell')).toHaveLength(22);
|
|
const tile = host.querySelector<HTMLButtonElement>('[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<HTMLElement>('.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(<ContextHeatmap context={context} onOpenCluster={onOpenCluster} />));
|
|
let tile = host.querySelector<HTMLButtonElement>(`[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<HTMLButtonElement>('.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(<ContextHeatmap context={populated} onOpenCluster={onOpenCluster} />));
|
|
tile = host.querySelector<HTMLButtonElement>(`[data-cluster="${clusterId}"]`)!;
|
|
expect(tile.dataset.severity).toBe('high');
|
|
expect(tile.textContent).toContain('1 finding');
|
|
cells = [...host.querySelectorAll<HTMLButtonElement>('.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(<ContextHeatmap context={context} onOpenCluster={onOpenCluster} />));
|
|
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<HTMLButtonElement>('[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(<ContextHeatmap context={emptyHeatmapContext()} onOpenCluster={onOpenCluster} />));
|
|
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(<DashboardPanel onOpenWork={onOpenWork} />));
|
|
await act(async () => host.querySelector<HTMLButtonElement>('[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(<DashboardPanel onOpenWork={onOpenWork} />));
|
|
const tile = host.querySelector<HTMLButtonElement>('[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(<DashboardPanel onOpenWork={vi.fn()} />));
|
|
// 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<typeof syntheticHeatmapContext>) => void;
|
|
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockImplementation(() => new Promise(resolve => { complete = resolve; }));
|
|
await act(async () => root.render(<ClientContextHeatmap onOpenCluster={vi.fn()} />));
|
|
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();
|
|
});
|