121 lines
7.4 KiB
TypeScript
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();
|
|
}
|
|
});
|