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( , ); expect(container.firstChild).toBeNull(); }); it('renders dialog with tactic columns when open', async () => { renderWithProviders( , ); await waitFor(() => { expect(screen.getByText('Initial Access')).toBeInTheDocument(); expect(screen.getByText('Execution')).toBeInTheDocument(); }); }); it('renders techniques for each tactic', async () => { renderWithProviders( , ); 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( , ); 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( , ); 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( , ); await user.keyboard('{Escape}'); expect(onCancel).toHaveBeenCalled(); }); it('shows initial selection as selected', async () => { renderWithProviders( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); // 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(); }); });