feat(frontend): sprint 3 — multi-technique MITRE selection + matrix modal

- types: replace mitre_technique_id/name scalars with techniques:MitreTechnique[]
  on Simulation; add MitreTactic/MitreMatrixTechnique/MitreMatrixSubtechnique;
  SimulationPatchInput now uses technique_ids:string[]
- api/mitre.ts: add getMitreMatrix() → GET /api/mitre/matrix
- hooks/useMitre: add useMitreMatrix(enabled) with staleTime:Infinity
- MitreTechniquePicker: clean rewrite — onSelect(technique) one-shot, resets
  input after selection, no incoming value props
- MitreTechniqueTag: chip component with id+name and × remove button
- MitreMatrixModal: tactic columns (220px fixed), expand/collapse subtechniques,
  search filter (auto-expands parent on sub match), selection state, focus trap
  (Tab wrap, Escape, search autofocus), backdrop click cancel, Apply N techniques
- MitreTechniquesField: orchestrates tags+picker+matrix with auto-save PATCH on
  every add/remove/Apply, dedup guard, disabled read-only mode for SOC
- SimulationFormPage: swap MitreTechniquePicker for MitreTechniquesField; remove
  technique state from RT form (techniques have independent auto-save cycle)
- SimulationList: MITRE column → T1059 +2 counter format, — when empty
- Tests: 84 passing (13 test files); new suites for Tag, Field, Modal;
  MitreTechniquePicker + SimulationFormPage + SimulationList adapted to new API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-27 04:04:23 +02:00
parent 673b25e0b0
commit 771483f3b0
15 changed files with 973 additions and 181 deletions

View File

@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MitreTechniqueTag } from '@/components/MitreTechniqueTag';
import { renderWithProviders } from './utils';
const TECHNIQUE = { id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] };
describe('MitreTechniqueTag', () => {
it('renders id and name', () => {
renderWithProviders(
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} />,
);
expect(screen.getByText('T1059')).toBeInTheDocument();
expect(screen.getByText(/Command and Scripting Interpreter/)).toBeInTheDocument();
});
it('shows remove button when not disabled', () => {
renderWithProviders(
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} />,
);
expect(screen.getByRole('button', { name: /Remove T1059/i })).toBeInTheDocument();
});
it('clicking × calls onRemove', async () => {
const onRemove = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<MitreTechniqueTag technique={TECHNIQUE} onRemove={onRemove} />,
);
await user.click(screen.getByRole('button', { name: /Remove T1059/i }));
expect(onRemove).toHaveBeenCalledOnce();
});
it('hides remove button when disabled', () => {
renderWithProviders(
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} disabled />,
);
expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull();
});
});