- MitreTechniquesField test: rewrite dedup test to actually exercise picker selection path — types query, waits for option, fires pointerDown, asserts no PATCH sent (dedup guard in handleSelect now truly covered) - MitreMatrixModal: Apply button disabled only when totalSelected === 0 AND initialSelection.length === 0 (no-op case); when totalSelected === 0 but initialSelection was non-empty, shows "Clear all" and stays enabled so user can explicitly wipe the list - MitreMatrixModal tests: update disabled test to match "Clear all" label, add "Clear all" enabled + onApply([]) path test - SimulationList: stopPropagation on Name <Link> to prevent double-navigate with row onClick handler Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
242 lines
7.6 KiB
TypeScript
242 lines
7.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import MockAdapter from 'axios-mock-adapter';
|
|
import { apiClient } from '@/api/client';
|
|
import { MitreMatrixModal } from '@/components/MitreMatrixModal';
|
|
import { renderWithProviders } from './utils';
|
|
import type { MitreTactic, MitreTechnique } from '@/api/types';
|
|
|
|
const MATRIX: MitreTactic[] = [
|
|
{
|
|
tactic_id: 'TA0001',
|
|
tactic_name: 'Initial Access',
|
|
techniques: [
|
|
{
|
|
id: 'T1078',
|
|
name: 'Valid Accounts',
|
|
subtechniques: [
|
|
{ id: 'T1078.001', name: 'Default Accounts' },
|
|
{ id: 'T1078.002', name: 'Domain Accounts' },
|
|
],
|
|
},
|
|
{
|
|
id: 'T1190',
|
|
name: 'Exploit Public-Facing Application',
|
|
subtechniques: [],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
tactic_id: 'TA0002',
|
|
tactic_name: 'Execution',
|
|
techniques: [
|
|
{
|
|
id: 'T1059',
|
|
name: 'Command and Scripting Interpreter',
|
|
subtechniques: [{ id: 'T1059.001', name: 'PowerShell' }],
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const SELECTION: MitreTechnique[] = [
|
|
{ id: 'T1078', name: 'Valid Accounts', tactics: ['initial-access'] },
|
|
];
|
|
|
|
describe('MitreMatrixModal', () => {
|
|
let mock: MockAdapter;
|
|
|
|
beforeEach(() => {
|
|
mock = new MockAdapter(apiClient);
|
|
mock.onGet('/mitre/matrix').reply(200, MATRIX);
|
|
});
|
|
|
|
afterEach(() => {
|
|
mock.restore();
|
|
});
|
|
|
|
it('returns null when isOpen=false', () => {
|
|
const { container } = renderWithProviders(
|
|
<MitreMatrixModal isOpen={false} initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
|
);
|
|
expect(container.firstChild).toBeNull();
|
|
});
|
|
|
|
it('renders dialog with tactic columns when open', async () => {
|
|
renderWithProviders(
|
|
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Initial Access')).toBeInTheDocument();
|
|
expect(screen.getByText('Execution')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('renders techniques for each tactic', async () => {
|
|
renderWithProviders(
|
|
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('T1078')).toBeInTheDocument();
|
|
expect(screen.getByText('T1059')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('Apply button calls onApply with selected techniques', async () => {
|
|
const onApply = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
renderWithProviders(
|
|
<MitreMatrixModal isOpen initialSelection={[]} onApply={onApply} onCancel={vi.fn()} />,
|
|
);
|
|
|
|
await waitFor(() => screen.getByText('T1078'));
|
|
|
|
// Click the label button for T1078 to select it
|
|
const t1078Btn = screen.getAllByRole('button').find(
|
|
(btn) => btn.textContent?.includes('T1078') && !btn.getAttribute('aria-label'),
|
|
);
|
|
await user.click(t1078Btn!);
|
|
|
|
await user.click(screen.getByRole('button', { name: /Apply/i }));
|
|
|
|
expect(onApply).toHaveBeenCalledWith(
|
|
expect.arrayContaining([expect.objectContaining({ id: 'T1078' })]),
|
|
);
|
|
});
|
|
|
|
it('Cancel button calls onCancel without onApply', async () => {
|
|
const onApply = vi.fn();
|
|
const onCancel = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
renderWithProviders(
|
|
<MitreMatrixModal isOpen initialSelection={[]} onApply={onApply} onCancel={onCancel} />,
|
|
);
|
|
|
|
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
|
|
|
expect(onCancel).toHaveBeenCalled();
|
|
expect(onApply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('Escape key calls onCancel', async () => {
|
|
const onCancel = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
renderWithProviders(
|
|
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={onCancel} />,
|
|
);
|
|
|
|
await user.keyboard('{Escape}');
|
|
|
|
expect(onCancel).toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows initial selection as selected', async () => {
|
|
renderWithProviders(
|
|
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={vi.fn()} onCancel={vi.fn()} />,
|
|
);
|
|
|
|
await waitFor(() => screen.getByText('T1078'));
|
|
|
|
// T1078 should show selected count in tactic header
|
|
expect(screen.getByText('1 selected')).toBeInTheDocument();
|
|
});
|
|
|
|
it('search filter narrows visible techniques', async () => {
|
|
const user = userEvent.setup();
|
|
renderWithProviders(
|
|
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
|
);
|
|
|
|
await waitFor(() => screen.getByText('T1078'));
|
|
|
|
const searchInput = screen.getByPlaceholderText(/Filter techniques/i);
|
|
await user.type(searchInput, 'T1059');
|
|
|
|
// T1059 column should be visible, T1078 should not
|
|
expect(screen.queryByText('T1078')).toBeNull();
|
|
expect(screen.getByText('T1059')).toBeInTheDocument();
|
|
});
|
|
|
|
it('chevron expands subtechniques', async () => {
|
|
const user = userEvent.setup();
|
|
renderWithProviders(
|
|
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
|
);
|
|
|
|
await waitFor(() => screen.getByText('T1078'));
|
|
|
|
// Subtechniques should not be visible initially
|
|
expect(screen.queryByText(/Default Accounts/)).toBeNull();
|
|
|
|
// Click the expand chevron for T1078
|
|
const expandBtn = screen.getByRole('button', { name: /Expand T1078/i });
|
|
await user.click(expandBtn);
|
|
|
|
expect(screen.getByText(/Default Accounts/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('Apply button shows technique count', async () => {
|
|
renderWithProviders(
|
|
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={vi.fn()} onCancel={vi.fn()} />,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /Apply 1 technique/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('Apply button is disabled when no techniques selected and no initial selection', async () => {
|
|
renderWithProviders(
|
|
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
|
);
|
|
|
|
await waitFor(() => screen.getByText('T1078'));
|
|
|
|
// Label is "Clear all" when totalSelected === 0, but it's disabled when initialSelection is also empty
|
|
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
|
|
expect(applyBtn).toBeDisabled();
|
|
});
|
|
|
|
it('Apply button shows "Clear all" and stays enabled when initial selection is deselected', async () => {
|
|
const onApply = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
renderWithProviders(
|
|
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={onApply} onCancel={vi.fn()} />,
|
|
);
|
|
|
|
await waitFor(() => screen.getByText('T1078'));
|
|
|
|
// Deselect T1078 (it was pre-selected)
|
|
const t1078Btn = screen.getAllByRole('button').find(
|
|
(btn) => btn.textContent?.includes('T1078') && !btn.getAttribute('aria-label'),
|
|
);
|
|
await user.click(t1078Btn!);
|
|
|
|
// Button should show "Clear all" and be enabled (user explicitly clearing the list)
|
|
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
|
|
expect(applyBtn).not.toBeDisabled();
|
|
await user.click(applyBtn);
|
|
expect(onApply).toHaveBeenCalledWith([]);
|
|
});
|
|
|
|
it('backdrop click calls onCancel', async () => {
|
|
const onCancel = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
renderWithProviders(
|
|
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={onCancel} />,
|
|
);
|
|
|
|
// Click the backdrop (the fixed inset div behind the modal)
|
|
const backdrop = document.querySelector('.bg-ink\\/60') as HTMLElement;
|
|
if (backdrop) await user.click(backdrop);
|
|
|
|
expect(onCancel).toHaveBeenCalled();
|
|
});
|
|
});
|