92 lines
4.8 KiB
TypeScript
92 lines
4.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { FILTERED_GRAPH_SELECTION_ID, graphSelectionNodeIds, graphSelectionUnionNodeIds, relationBranchNodeIds, selectedPathNodeIds } from '../../src/features/mitre-graph/category-branch';
|
|
import type { ObsidianGraphModel } from '../../src/features/mitre-graph/obsidian-graph';
|
|
|
|
const graph: ObsidianGraphModel = {
|
|
nodes: [
|
|
{ id: 'category:a', kind: 'category', label: 'A', x: 0, y: 0, radius: 8 },
|
|
{ id: 'category:b', kind: 'category', label: 'B', x: 0, y: 0, radius: 8 },
|
|
{ id: 'finding:a', kind: 'finding', label: 'A finding', x: 0, y: 0, radius: 8 },
|
|
{ id: 'finding:b', kind: 'finding', label: 'B finding', x: 0, y: 0, radius: 8 },
|
|
{ id: 'finding:c', kind: 'finding', label: 'Direct tactic finding', x: 0, y: 0, radius: 8 },
|
|
{ id: 'technique:shared', kind: 'technique', label: 'Shared', x: 0, y: 0, radius: 8 },
|
|
{ id: 'target:a', kind: 'target', label: 'Target A', x: 0, y: 0, radius: 8 },
|
|
{ id: 'tactic:execution', kind: 'tactic', label: 'Execution', x: 0, y: 0, radius: 8 }
|
|
],
|
|
edges: [
|
|
{ id: 'a-category', source: 'finding:a', target: 'category:a', kind: 'category' },
|
|
{ id: 'b-category', source: 'finding:b', target: 'category:b', kind: 'category' },
|
|
{ id: 'a-technique', source: 'finding:a', target: 'technique:shared', kind: 'technique' },
|
|
{ id: 'b-technique', source: 'finding:b', target: 'technique:shared', kind: 'technique' },
|
|
{ id: 'a-target', source: 'finding:a', target: 'target:a', kind: 'target' },
|
|
{ id: 'technique-tactic', source: 'technique:shared', target: 'tactic:execution', kind: 'technique' },
|
|
{ id: 'direct-tactic', source: 'finding:c', target: 'tactic:execution', kind: 'tactic' }
|
|
]
|
|
};
|
|
|
|
describe('category graph branch', () => {
|
|
it('extracts only the selected node and its direct links, without following shared neighbors', () => {
|
|
expect(selectedPathNodeIds(graph, 'category:a')).toEqual(['category:a', 'finding:a']);
|
|
expect(selectedPathNodeIds(graph, 'finding:a')).toEqual([
|
|
'category:a', 'finding:a', 'technique:shared', 'target:a'
|
|
]);
|
|
expect(selectedPathNodeIds(graph, FILTERED_GRAPH_SELECTION_ID)).toEqual([]);
|
|
});
|
|
it('includes the selected category branch without escaping through shared MITRE nodes', () => {
|
|
expect(relationBranchNodeIds(graph, 'category:a')).toEqual([
|
|
'category:a', 'finding:a', 'technique:shared', 'target:a', 'tactic:execution'
|
|
]);
|
|
expect(relationBranchNodeIds(graph, 'category:a')).not.toContain('finding:b');
|
|
expect(relationBranchNodeIds(graph, 'category:a')).not.toContain('category:b');
|
|
});
|
|
|
|
it('supports shared MITRE technique and tactic roots without escaping recursively', () => {
|
|
expect(relationBranchNodeIds(graph, 'technique:shared')).toEqual([
|
|
'category:a', 'category:b', 'finding:a', 'finding:b', 'technique:shared', 'target:a', 'tactic:execution'
|
|
]);
|
|
expect(relationBranchNodeIds(graph, 'tactic:execution')).toEqual([
|
|
'category:a', 'category:b', 'finding:a', 'finding:b', 'finding:c', 'technique:shared', 'target:a', 'tactic:execution'
|
|
]);
|
|
});
|
|
|
|
it('keeps every same-kind endpoint attached to a matched finding', () => {
|
|
const multiDefenseGraph: ObsidianGraphModel = {
|
|
nodes: [
|
|
{ id: 'finding:a', kind: 'finding', label: 'Finding', x: 0, y: 0, radius: 8 },
|
|
{ id: 'defense:a', kind: 'defense', label: 'Defense A', x: 0, y: 0, radius: 8 },
|
|
{ id: 'defense:b', kind: 'defense', label: 'Defense B', x: 0, y: 0, radius: 8 },
|
|
{ id: 'target:a', kind: 'target', label: 'Target', x: 0, y: 0, radius: 8 }
|
|
],
|
|
edges: [
|
|
{ id: 'defense-a', source: 'finding:a', target: 'defense:a', kind: 'defense' },
|
|
{ id: 'defense-b', source: 'finding:a', target: 'defense:b', kind: 'defense' },
|
|
{ id: 'target-a', source: 'finding:a', target: 'target:a', kind: 'target' }
|
|
]
|
|
};
|
|
|
|
expect(relationBranchNodeIds(multiDefenseGraph, 'defense:a')).toEqual([
|
|
'finding:a', 'defense:a', 'defense:b', 'target:a'
|
|
]);
|
|
});
|
|
|
|
it('ignores finding roots', () => {
|
|
expect(relationBranchNodeIds(graph, 'finding:a')).toEqual([]);
|
|
});
|
|
|
|
it('selects every node for filters without a dedicated relation node', () => {
|
|
expect(graphSelectionNodeIds(graph, FILTERED_GRAPH_SELECTION_ID)).toEqual(graph.nodes.map(node => node.id));
|
|
});
|
|
|
|
it('combines multiple selected relation branches for extraction', () => {
|
|
expect(graphSelectionUnionNodeIds(graph, ['category:a', 'category:b'])).toEqual([
|
|
'category:a', 'category:b', 'finding:a', 'finding:b', 'technique:shared', 'target:a', 'tactic:execution'
|
|
]);
|
|
});
|
|
|
|
it('keeps a selected finding and its direct relations extractable', () => {
|
|
expect(graphSelectionUnionNodeIds(graph, ['finding:a'])).toEqual([
|
|
'category:a', 'finding:a', 'technique:shared', 'target:a'
|
|
]);
|
|
});
|
|
});
|