Files
mimic/frontend/tests/MitreTechniquesField.test.tsx
Knacky f5ea9d16af feat(frontend): sprint 4 — dark mode + matrix overhaul + tactic selection + done read-only + UI polish
US-17: fix duplicate "Create engagement" button, icon conventions (Save/RotateCcw/Grid2x2), UsersAdminPage form baseline alignment
US-18: done status fully read-only + Reopen button (done → review_required) for all roles
US-19: invalidate engagement queries on simulation PATCH/transition for auto-status propagation
US-20: MitreMatrixModal rewritten — CSS grid 12-column layout, no horizontal scroll, attack.mitre.org compact look
US-21: tactic header clickable in matrix, tactic chips (MitreTacticTag) in field, single atomic PATCH with technique_ids + tactic_ids
US-22: MitreTechniquesField chips-only area + inline search input + matrix icon button; chips show ID-only (name in title=)
US-23: useTheme hook — 3-state light/dark/system, CSS variables, Tailwind darkMode class, localStorage persistence

92/92 tests passing, typecheck and lint clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:06:01 +02:00

168 lines
6.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { MitreTechniquesField } from '@/components/MitreTechniquesField';
import { renderWithProviders } from './utils';
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
const T1059: MitreTechnique = { id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] };
const T1078: MitreTechnique = { id: 'T1078', name: 'Valid Accounts', tactics: ['initial-access'] };
const TA0007: MitreTacticRef = { id: 'TA0007', name: 'Discovery' };
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: 'redteam', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: false,
isRedteam: true,
isSoc: false,
canEditEngagements: true,
}),
}));
const SIM_RESPONSE = {
id: 7, engagement_id: 42, name: 'test', techniques: [], tactics: [],
description: null, commands: null, prerequisites: null,
executed_at: null, execution_result: null, log_source: null,
logs: null, soc_comment: null, incident_number: null,
status: 'pending', created_at: '2026-01-01', updated_at: null,
created_by: { id: 1, username: 'alice' },
};
describe('MitreTechniquesField', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
it('shows empty state message when no techniques or tactics', () => {
renderWithProviders(
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
);
expect(screen.getByText(/No techniques selected/i)).toBeInTheDocument();
});
it('renders technique tags for each technique', () => {
renderWithProviders(
<MitreTechniquesField value={[T1059, T1078]} tactics={[]} simulationId={7} engagementId={42} />,
);
expect(screen.getAllByTestId('mitre-technique-tag')).toHaveLength(2);
expect(screen.getByTitle(/T1059/)).toBeInTheDocument();
expect(screen.getByTitle(/T1078/)).toBeInTheDocument();
});
it('renders tactic chips alongside technique chips', () => {
renderWithProviders(
<MitreTechniquesField value={[T1059]} tactics={[TA0007]} simulationId={7} engagementId={42} />,
);
expect(screen.getAllByTestId('mitre-tactic-tag')).toHaveLength(1);
expect(screen.getByTitle(/TA0007/)).toBeInTheDocument();
});
it('shows search input and matrix icon when not disabled', () => {
renderWithProviders(
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
);
expect(screen.getByRole('button', { name: /Open MITRE matrix/i })).toBeInTheDocument();
// The search placeholder button
expect(screen.getByRole('button', { name: /Search technique/i })).toBeInTheDocument();
});
it('hides input row when disabled', () => {
renderWithProviders(
<MitreTechniquesField value={[T1059]} tactics={[]} simulationId={7} engagementId={42} disabled />,
);
expect(screen.queryByRole('button', { name: /Open MITRE matrix/i })).toBeNull();
});
it('× button on technique tag calls PATCH with technique removed', async () => {
mock.onPatch('/simulations/7').reply(200, SIM_RESPONSE);
mock.onGet('/engagements/42/simulations').reply(200, []);
mock.onGet('/simulations/7').reply(200, SIM_RESPONSE);
const user = userEvent.setup();
renderWithProviders(
<MitreTechniquesField value={[T1059, T1078]} tactics={[]} simulationId={7} engagementId={42} />,
);
const removeBtn = screen.getByRole('button', { name: /Remove T1059/i });
await user.click(removeBtn);
await waitFor(() => {
expect(mock.history.patch.length).toBe(1);
const body = JSON.parse(mock.history.patch[0].data as string);
expect(body.technique_ids).toEqual(['T1078']);
expect(body.tactic_ids).toEqual([]);
});
});
it('× button on tactic tag calls PATCH with tactic removed', async () => {
mock.onPatch('/simulations/7').reply(200, SIM_RESPONSE);
mock.onGet('/engagements/42/simulations').reply(200, []);
mock.onGet('/simulations/7').reply(200, SIM_RESPONSE);
const user = userEvent.setup();
renderWithProviders(
<MitreTechniquesField value={[T1059]} tactics={[TA0007]} simulationId={7} engagementId={42} />,
);
const removeBtn = screen.getByRole('button', { name: /Remove TA0007/i });
await user.click(removeBtn);
await waitFor(() => {
expect(mock.history.patch.length).toBe(1);
const body = JSON.parse(mock.history.patch[0].data as string);
expect(body.tactic_ids).toEqual([]);
expect(body.technique_ids).toEqual(['T1059']);
});
});
it('clicking search placeholder shows combobox input', async () => {
const user = userEvent.setup();
renderWithProviders(
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
);
await user.click(screen.getByRole('button', { name: /Search technique/i }));
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('dedup: selecting an already-present technique does not PATCH', async () => {
mock.onGet('/mitre/techniques').reply(200, [T1059]);
const user = userEvent.setup();
renderWithProviders(
<MitreTechniquesField value={[T1059]} tactics={[]} simulationId={7} engagementId={42} />,
);
await user.click(screen.getByRole('button', { name: /Search technique/i }));
const combobox = screen.getByRole('combobox');
await user.type(combobox, 'T1059');
const option = await screen.findByRole('option', { name: /T1059/i });
expect(option).toBeInTheDocument();
await user.pointer({ target: option, keys: '[MouseLeft>]' });
expect(mock.history.patch.length).toBe(0);
});
it('opens matrix modal when matrix icon is clicked', async () => {
mock.onGet('/mitre/matrix').reply(200, []);
const user = userEvent.setup();
renderWithProviders(
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
);
await user.click(screen.getByRole('button', { name: /Open MITRE matrix/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});