90 lines
4.9 KiB
TypeScript
90 lines
4.9 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { FindingPathItem, MitreGraphData } from '../../src/features/mitre-graph/mitre-graph';
|
|
import { buildObsidianGraph } from '../../src/features/mitre-graph/obsidian-graph';
|
|
|
|
const graphData: MitreGraphData = {
|
|
version: 'synthetic',
|
|
tacticOrder: ['Initial Access', 'Execution'],
|
|
techniques: [
|
|
{ id: 'T1001', name: 'Synthetic Entry', tactics: ['Initial Access'], platforms: [], parent: null, url: '', sourceCount: 0 },
|
|
{ id: 'T1002', name: 'Synthetic Run', tactics: ['Execution'], platforms: [], parent: null, url: '', sourceCount: 0 }
|
|
],
|
|
entities: []
|
|
};
|
|
|
|
const item = (id: string, techniqueId: string, tactic: string): FindingPathItem => ({
|
|
id,
|
|
record: { id, index: 0, title: `Finding ${id}`, sourceSheet: 'Synthetic', fields: [], filled: [], meta: [] },
|
|
title: `Finding ${id}`,
|
|
category: 'Synthetic',
|
|
categories: ['Synthetic'],
|
|
defenses: ['Synthetic defense'],
|
|
targets: ['DC-01'],
|
|
tactics: [tactic],
|
|
primaryTactic: tactic,
|
|
techniqueIds: [techniqueId],
|
|
severity: 'high',
|
|
weight: 1,
|
|
score: 1,
|
|
risk: 410,
|
|
mappingOrigin: 'native'
|
|
});
|
|
|
|
describe('Obsidian-style interactive graph', () => {
|
|
it('builds a deterministic finding-technique-tactic network', () => {
|
|
const findings = [item('one', 'T1001', 'Initial Access'), item('two', 'T1002', 'Execution')];
|
|
const first = buildObsidianGraph(findings, graphData);
|
|
const second = buildObsidianGraph(findings, graphData);
|
|
|
|
expect(first).toEqual(second);
|
|
expect(first.nodes.filter(node => node.kind === 'finding')).toHaveLength(2);
|
|
expect(first.nodes.filter(node => node.kind === 'category')).toHaveLength(1);
|
|
expect(first.nodes.filter(node => node.kind === 'defense')).toHaveLength(1);
|
|
expect(first.nodes.filter(node => node.kind === 'target')).toHaveLength(1);
|
|
expect(first.nodes.filter(node => node.kind === 'technique')).toHaveLength(2);
|
|
expect(first.nodes.filter(node => node.kind === 'tactic')).toHaveLength(2);
|
|
expect(Math.max(...first.nodes.map(node => Math.hypot(node.x, node.y)))).toBeGreaterThan(300);
|
|
expect(first.nodes.find(node => node.kind === 'tactic')?.radius).toBe(10);
|
|
expect(first.nodes.find(node => node.kind === 'technique')?.radius).toBe(7);
|
|
expect(first.edges).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ source: 'finding:one', target: 'technique:T1001' }),
|
|
expect.objectContaining({ source: 'finding:one', target: 'category:Synthetic', kind: 'category' }),
|
|
expect.objectContaining({ source: 'finding:one', target: 'defense:Synthetic defense', kind: 'defense' }),
|
|
expect.objectContaining({ source: 'finding:one', target: 'target:DC-01', kind: 'target' }),
|
|
expect.objectContaining({ source: 'technique:T1001', target: 'tactic:Initial Access', kind: 'technique' })
|
|
]));
|
|
});
|
|
|
|
it('connects tactic-only findings to every declared tactic without orphan hubs', () => {
|
|
const finding = { ...item('multi', '', 'Initial Access'), techniqueIds: [], tactics: ['Initial Access', 'Execution'] };
|
|
const graph = buildObsidianGraph([finding], graphData);
|
|
|
|
expect(graph.edges).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ source: 'finding:multi', target: 'tactic:Initial Access', kind: 'tactic' }),
|
|
expect.objectContaining({ source: 'finding:multi', target: 'tactic:Execution', kind: 'tactic' })
|
|
]));
|
|
expect(graph.nodes.filter(node => node.kind === 'tactic').every(node => graph.edges.some(edge => edge.source === node.id || edge.target === node.id))).toBe(true);
|
|
});
|
|
|
|
it('keeps graph text independent of node selection and removes in-graph detail cards', () => {
|
|
const app = readFileSync(new URL('../../src/app/App.tsx', import.meta.url), 'utf8');
|
|
const view = readFileSync(new URL('../../src/features/mitre-graph/ObsidianGraphView.tsx', import.meta.url), 'utf8');
|
|
const layout = readFileSync(new URL('../../src/styles/layout.css', import.meta.url), 'utf8');
|
|
expect(app).toContain('<ObsidianGraphView');
|
|
expect(view).toContain('const [showText, setShowText] = useState(true);');
|
|
expect(view).toContain("onClick={() => setShowText(current => !current)}>Text</button>");
|
|
expect(view).toContain('const showInlineLabel = showText && nodeIsInViewport && labelIsFullyVisible;');
|
|
expect(view).toContain('togglePinnedNode(operation.nodeId);');
|
|
expect(view).toContain('setShowText(true);');
|
|
expect(view).not.toContain('setShowText(savedView.showText);');
|
|
expect(view).toContain('className="obsidian-graph-inspector-content"');
|
|
expect(view).not.toContain('>Off</button>');
|
|
expect(view).not.toContain('>Labels</button>');
|
|
expect(view).not.toContain('setDisplayMode');
|
|
expect(view).not.toContain('obsidian-graph-cards');
|
|
expect(view).not.toContain('obsidian-graph-card-connectors');
|
|
expect(layout).toMatch(/\.viewer\.app-area-graph\s*\{[^}]*background-image:\s*var\(--app-grid-pattern\);/s);
|
|
});
|
|
});
|