190 lines
11 KiB
TypeScript
190 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
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 { 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: () => [] }));
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
Reflect.deleteProperty(Element.prototype, 'setPointerCapture');
|
|
});
|
|
|
|
it('shows node text by default, keeps it on after selecting a node, and toggles it only with Text', async () => {
|
|
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 finding', 'High', 'Synthetic category', 'Failed', 'T1001', 'S-Synthetic']
|
|
], '', 'synthetic.csv');
|
|
vi.mocked(useFilteredRecords).mockReturnValue(sheet.analysis.records);
|
|
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: vi.fn(), 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);
|
|
try {
|
|
await act(async () => root.render(<ObsidianGraphView active onOpenFinding={() => {}} />));
|
|
const graph = container.querySelector<SVGSVGElement>('.obsidian-graph-surface')!;
|
|
const textButton = [...container.querySelectorAll<HTMLButtonElement>('.obsidian-graph-toolbar button')]
|
|
.find(button => button.textContent === 'Text')!;
|
|
expect(textButton).toBeDefined();
|
|
expect(textButton.getAttribute('aria-pressed')).toBe('true');
|
|
expect(container.querySelector('.obsidian-node-inline-label')).not.toBeNull();
|
|
expect(container.querySelector('.obsidian-graph-cards')).toBeNull();
|
|
expect([...container.querySelectorAll('.obsidian-graph-toolbar button')].map(button => button.textContent)).not.toContain('Off');
|
|
expect([...container.querySelectorAll('.obsidian-graph-toolbar button')].map(button => button.textContent)).not.toContain('Labels');
|
|
|
|
const node = container.querySelector<SVGGElement>('.obsidian-node-finding')!;
|
|
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;
|
|
};
|
|
await act(async () => {
|
|
node.dispatchEvent(pointer('pointerdown'));
|
|
graph.dispatchEvent(pointer('pointerup'));
|
|
});
|
|
expect(node.getAttribute('aria-pressed')).toBe('true');
|
|
const details = container.querySelector('.obsidian-graph-inspector-content')!;
|
|
const inspectorStyles = readFileSync(resolve(process.cwd(), 'src/styles/obsidian-graph.css'), 'utf8');
|
|
const fieldRowsRule = inspectorStyles.match(/\.obsidian-graph-inspector dl div \{[^}]*\}/g)?.at(-1);
|
|
const fieldValueRule = inspectorStyles.match(/\.obsidian-graph-inspector dd \{[^}]*\}/g)?.at(-1);
|
|
expect(fieldRowsRule).toBeDefined();
|
|
expect(fieldValueRule).toBeDefined();
|
|
const style = document.createElement('style');
|
|
style.textContent = `${fieldRowsRule}\n${fieldValueRule}`;
|
|
document.head.append(style);
|
|
const sourceRuleRow = [...details.querySelectorAll('dl > div')]
|
|
.find(row => row.querySelector('dt')?.textContent === 'Source Rule ID')!;
|
|
expect(getComputedStyle(sourceRuleRow).gridTemplateColumns).toBe('minmax(0, 1fr) minmax(0, 1.4fr)');
|
|
expect(getComputedStyle(sourceRuleRow.querySelector('dd')!).overflowWrap).toBe('normal');
|
|
expect(getComputedStyle(sourceRuleRow.querySelector('dd')!).wordBreak).toBe('normal');
|
|
style.remove();
|
|
expect(details.textContent).toContain('Synthetic finding');
|
|
expect(details.querySelector('.obsidian-graph-inspector-title.severity-high .data-tile-hex')).not.toBeNull();
|
|
const facts = Object.fromEntries([...details.querySelectorAll('dl > div')].map(row => [
|
|
row.querySelector('dt')!.textContent, row.querySelector('dd')!.textContent
|
|
]));
|
|
expect(facts).toMatchObject({ 'Source status': 'Failed', 'Source Rule ID': 'S-Synthetic', Severity: 'High', Category: 'Synthetic category', 'MITRE ATT&CK': 'T1001' });
|
|
expect(textButton.getAttribute('aria-pressed')).toBe('true');
|
|
expect(container.querySelector('.obsidian-node-inline-label')).not.toBeNull();
|
|
expect(container.querySelector('.obsidian-graph-cards')).toBeNull();
|
|
|
|
await act(async () => textButton.click());
|
|
expect(textButton.getAttribute('aria-pressed')).toBe('false');
|
|
expect(container.querySelector('.obsidian-node-inline-label')).toBeNull();
|
|
expect(container.querySelector('.obsidian-graph-inspector-content')).not.toBeNull();
|
|
await act(async () => {
|
|
node.dispatchEvent(pointer('pointerdown'));
|
|
graph.dispatchEvent(pointer('pointerup'));
|
|
});
|
|
expect(node.getAttribute('aria-pressed')).toBe('false');
|
|
expect(textButton.getAttribute('aria-pressed')).toBe('false');
|
|
const resetButton = [...container.querySelectorAll<HTMLButtonElement>('.obsidian-graph-toolbar button')]
|
|
.find(button => button.textContent?.includes('Reset view'))!;
|
|
await act(async () => resetButton.click());
|
|
expect(textButton.getAttribute('aria-pressed')).toBe('true');
|
|
expect(container.querySelector('.obsidian-node-inline-label')).not.toBeNull();
|
|
expect(container.querySelector('.obsidian-graph-inspector-empty')).not.toBeNull();
|
|
await act(async () => textButton.click());
|
|
expect(textButton.getAttribute('aria-pressed')).toBe('false');
|
|
await act(async () => textButton.click());
|
|
expect(textButton.getAttribute('aria-pressed')).toBe('true');
|
|
expect(container.querySelector('.obsidian-node-inline-label')).not.toBeNull();
|
|
const categoryNode = container.querySelector<SVGGElement>('.obsidian-node-category')!;
|
|
await act(async () => {
|
|
categoryNode.dispatchEvent(pointer('pointerdown'));
|
|
graph.dispatchEvent(pointer('pointerup'));
|
|
});
|
|
const categoryDetails = container.querySelector('.obsidian-graph-inspector-content')!;
|
|
expect(categoryDetails.textContent).toContain('Synthetic category');
|
|
expect(categoryDetails.querySelector('.data-tile-hex')).toBeNull();
|
|
expect(categoryDetails.querySelector('dl')).toBeNull();
|
|
} finally {
|
|
await act(async () => root.unmount());
|
|
container.remove();
|
|
}
|
|
});
|
|
|
|
it('extracts only the selected Pathfinder path and restores the graph on exit', async () => {
|
|
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'],
|
|
['First finding', 'High', 'Shared category', 'Failed', 'T1001', 'S-First'],
|
|
['Second finding', 'High', 'Shared category', 'Failed', 'T1002', 'S-Second']
|
|
], '', 'synthetic.csv');
|
|
vi.mocked(useFilteredRecords).mockReturnValue(sheet.analysis.records);
|
|
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: vi.fn(), 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);
|
|
try {
|
|
await act(async () => root.render(<ObsidianGraphView active onOpenFinding={() => {}} />));
|
|
const graph = container.querySelector<SVGSVGElement>('.obsidian-graph-surface')!;
|
|
const extractButton = [...container.querySelectorAll<HTMLButtonElement>('.obsidian-graph-toolbar button')]
|
|
.find(button => button.textContent === 'Extract Path')!;
|
|
const allNodeCount = container.querySelectorAll('.obsidian-node').length;
|
|
expect(allNodeCount).toBeGreaterThan(3);
|
|
expect(extractButton.disabled).toBe(true);
|
|
|
|
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;
|
|
};
|
|
const selectNode = async (node: Element) => act(async () => {
|
|
node.dispatchEvent(pointer('pointerdown'));
|
|
graph.dispatchEvent(pointer('pointerup'));
|
|
});
|
|
|
|
await selectNode(container.querySelector<SVGGElement>('.obsidian-node-category')!);
|
|
expect(extractButton.disabled).toBe(false);
|
|
await act(async () => extractButton.click());
|
|
expect(extractButton.textContent).toBe('Exit Extract Path');
|
|
expect(container.querySelectorAll('.obsidian-node')).toHaveLength(3);
|
|
expect(container.querySelectorAll('.obsidian-graph-edges line')).toHaveLength(2);
|
|
expect(container.querySelector('.obsidian-node-technique')).toBeNull();
|
|
await act(async () => extractButton.click());
|
|
expect(extractButton.textContent).toBe('Extract Path');
|
|
expect(container.querySelectorAll('.obsidian-node')).toHaveLength(allNodeCount);
|
|
|
|
await selectNode(container.querySelector<SVGGElement>('.obsidian-node-finding')!);
|
|
await act(async () => extractButton.click());
|
|
expect(container.querySelectorAll('.obsidian-node-finding')).toHaveLength(1);
|
|
expect(container.querySelectorAll('.obsidian-graph-edges line').length).toBeGreaterThan(0);
|
|
} finally {
|
|
await act(async () => root.unmount());
|
|
container.remove();
|
|
}
|
|
});
|