Assessor_2.0/tests/features/pathfinder-context-layer.te...

121 lines
7.4 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { useApp } from '../../src/app/AppProvider';
import { ObsidianGraphView } from '../../src/features/mitre-graph/ObsidianGraphView';
import { useFilteredRecords } from '../../src/features/search-filter/use-filtered-records';
import { useContextClusterIndex } from '../../src/features/context-layer/use-context-cluster-index';
import { contextClusterIndex } from '../../src/features/context-layer/context-cluster-facet';
import { buildAssessmentContext } from '../../src/assessment-context/context-engine';
import { RELEASED_ASSESSMENT_CONTEXT_CATALOG } from '../../src/assessment-context/context-catalog';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { makeSheet } from '../../src/importers/sheet-factory';
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
vi.mock('../../src/features/search-filter/use-filtered-records', () => ({ useFilteredRecords: vi.fn() }));
vi.mock('../../src/features/search-filter/SearchFilterBar', () => ({ SearchFilterBar: () => null, SearchTextControl: () => null }));
vi.mock('../../src/features/view-bookmarks/ViewBookmarks', () => ({ ViewBookmarks: () => null }));
vi.mock('../../src/features/search-filter/dynamic-facets', () => ({ deriveDynamicFacets: () => [] }));
vi.mock('../../src/features/context-layer/use-context-cluster-index', () => ({ useContextClusterIndex: vi.fn() }));
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
Reflect.deleteProperty(Element.prototype, 'setPointerCapture');
});
// Synthetic context from a real engine run: one failing Purple Knight rule of the password cluster.
function syntheticIndex() {
const context = buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 1,
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
scannerFindings: [{ resultProjectionId: 'scanner-finding:occ-weak', sourceKind: 'scanner-finding', sourceResultId: 'occ-weak',
sourceTool: 'purple-knight', sourceRuleId: 'SI000096', outcomeNormalized: 'fail', title: 'Synthetic weak password policy' } as unknown as UnifiedResultProjection]
});
return contextClusterIndex(context);
}
async function renderPathfinder(layerOn: boolean, setFacetFilter = vi.fn()) {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
vi.stubGlobal('cancelAnimationFrame', vi.fn());
Object.defineProperty(Element.prototype, 'setPointerCapture', { value: vi.fn(), configurable: true });
const sheet = makeSheet('Synthetic graph', [
['Title', 'Severity', 'Category', 'Status', 'MITRE ATT&CK', 'Source Rule ID'],
['Synthetic weak password policy', 'High', 'Account Security', 'Failed', 'T1110', 'SI000096']
], '', 'synthetic.csv');
sheet.analysis.records[0].findingOccurrenceId = 'occ-weak';
vi.mocked(useFilteredRecords).mockReturnValue(sheet.analysis.records);
vi.mocked(useContextClusterIndex).mockReturnValue(layerOn ? syntheticIndex() : undefined);
vi.mocked(useApp).mockReturnValue({
dataset: { sheets: [sheet], profileId: 'generic' }, activeSheet: sheet, activeSheetId: sheet.id,
activeProfile: { defaults: () => ({ statusFilter: 'deviation' }) }, titleColumn: 'Title', reviews: {},
actionOnly: false, facetFilters: {}, pathFindingIds: [], search: '', statusFilter: 'deviation',
setFacetFilter, setSelectedRecord: vi.fn(), setView: vi.fn(), toggleWorkspace: vi.fn(), updateReview: vi.fn()
} as unknown as ReturnType<typeof useApp>);
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => root.render(<ObsidianGraphView active onOpenFinding={() => {}} />));
return { container, cleanup: async () => { await act(async () => root.unmount()); container.remove(); } };
}
const pointer = (type: string) => {
const event = new MouseEvent(type, { bubbles: true, button: 0, clientX: 100, clientY: 100 });
Object.defineProperty(event, 'pointerId', { value: 1 });
return event;
};
it('shows context clusters as Pathfinder hubs with details while the layer is on', async () => {
const setFacetFilter = vi.fn();
const { container, cleanup } = await renderPathfinder(true, setFacetFilter);
try {
const graph = container.querySelector<SVGSVGElement>('.obsidian-graph-surface')!;
const hub = container.querySelector<SVGGElement>('.obsidian-node-context')!;
expect(hub.getAttribute('aria-label')).toBe('Password Guessing Exposure');
expect(container.querySelectorAll('.obsidian-graph-edges .obsidian-edge-context')).toHaveLength(1);
expect(graph.getAttribute('aria-label')).toContain('context clusters');
// Selecting the finding lists its cluster among the source-backed facts.
const finding = container.querySelector<SVGGElement>('.obsidian-node-finding')!;
await act(async () => { finding.dispatchEvent(pointer('pointerdown')); graph.dispatchEvent(pointer('pointerup')); });
expect(container.querySelector('[data-context-detail] dd')?.textContent).toBe('Password Guessing Exposure');
await act(async () => { finding.dispatchEvent(pointer('pointerdown')); graph.dispatchEvent(pointer('pointerup')); });
// Selecting the hub applies the shared Context Cluster filter and shows the cluster only in Details.
await act(async () => { hub.dispatchEvent(pointer('pointerdown')); graph.dispatchEvent(pointer('pointerup')); });
expect(setFacetFilter).toHaveBeenCalledWith('context:cluster', { values: ['password-guessing-exposure'] });
const details = container.querySelector('.obsidian-graph-inspector-content')!;
expect(details.querySelector('small')?.textContent).toBe('Context');
const facts = Object.fromEntries([...details.querySelectorAll('[data-context-cluster-detail] > div')].map(row => [
row.querySelector('dt')!.textContent, row.querySelector('dd')!.textContent
]));
expect(facts.State).toBe('Undetermined');
expect(facts.Conditions).toMatch(/^1 of \d+ met, 2 required · \d+ unknown$/);
expect(facts.Reading).toBe('Derived second reading; it does not change any result.');
expect(details.querySelector('.data-tile-hex')).toBeNull();
// Pathfinder rules stay: no in-graph cards, text still on.
expect(container.querySelector('.obsidian-graph-cards')).toBeNull();
expect(container.querySelector('.obsidian-node-inline-label')).not.toBeNull();
} finally {
await cleanup();
}
});
it('renders the Pathfinder without any context element while the layer is off', async () => {
const { container, cleanup } = await renderPathfinder(false);
try {
expect(container.querySelector('.obsidian-node-context')).toBeNull();
expect(container.querySelector('.obsidian-edge-context')).toBeNull();
expect(container.querySelector('.obsidian-graph-surface')?.getAttribute('aria-label')).not.toContain('context');
const graph = container.querySelector<SVGSVGElement>('.obsidian-graph-surface')!;
const finding = container.querySelector<SVGGElement>('.obsidian-node-finding')!;
await act(async () => { finding.dispatchEvent(pointer('pointerdown')); graph.dispatchEvent(pointer('pointerup')); });
expect(container.querySelector('[data-context-detail]')).toBeNull();
} finally {
await cleanup();
}
});