feat(frontend): sprint 2 — simulations UI + MITRE picker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
243
frontend/tests/MitreTechniquePicker.test.tsx
Normal file
243
frontend/tests/MitreTechniquePicker.test.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { MitreTechniquePicker } from '@/components/MitreTechniquePicker';
|
||||
import { renderWithProviders } from './utils';
|
||||
import type { MitreTechnique } from '@/api/types';
|
||||
|
||||
const TECHNIQUES: MitreTechnique[] = [
|
||||
{ id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] },
|
||||
{ id: 'T1059.001', name: 'PowerShell', tactics: ['execution'] },
|
||||
{ id: 'T1021', name: 'Remote Services', tactics: ['lateral-movement'] },
|
||||
];
|
||||
|
||||
describe('MitreTechniquePicker', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders input with placeholder', () => {
|
||||
vi.useRealTimers();
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/Search by ID or name/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows preselected value when techniqueId and name provided', () => {
|
||||
vi.useRealTimers();
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId="T1059"
|
||||
techniqueName="Command and Scripting Interpreter"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByRole('combobox') as HTMLInputElement;
|
||||
expect(input.value).toContain('T1059');
|
||||
expect(input.value).toContain('Command and Scripting Interpreter');
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
vi.useRealTimers();
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
disabled
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('debounces search: no request fires before 200ms', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.type(input, 'T');
|
||||
|
||||
// Before debounce fires
|
||||
expect(mock.history.get.length).toBe(0);
|
||||
|
||||
// Advance past debounce
|
||||
act(() => { vi.advanceTimersByTime(300); });
|
||||
|
||||
await waitFor(() => expect(mock.history.get.length).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it('displays results in dropdown after debounce', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.type(input, 'T1059');
|
||||
act(() => { vi.advanceTimersByTime(300); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
expect(options[0].textContent).toContain('T1059');
|
||||
});
|
||||
|
||||
it('selecting a result calls onChange with id and name', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.type(input, 'T1059');
|
||||
act(() => { vi.advanceTimersByTime(300); });
|
||||
|
||||
await waitFor(() => screen.getByRole('listbox'));
|
||||
|
||||
const options = screen.getAllByRole('option');
|
||||
await user.click(options[0]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('T1059', 'Command and Scripting Interpreter');
|
||||
});
|
||||
|
||||
it('populates input display string after selection', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox') as HTMLInputElement;
|
||||
await user.click(input);
|
||||
await user.type(input, 'T1059');
|
||||
act(() => { vi.advanceTimersByTime(300); });
|
||||
|
||||
await waitFor(() => screen.getByRole('listbox'));
|
||||
|
||||
const options = screen.getAllByRole('option');
|
||||
await user.click(options[0]);
|
||||
|
||||
expect(input.value).toContain('T1059');
|
||||
expect(input.value).toContain('Command and Scripting Interpreter');
|
||||
});
|
||||
|
||||
it('keyboard ArrowDown + Enter selects item', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.type(input, 'T105');
|
||||
act(() => { vi.advanceTimersByTime(300); });
|
||||
|
||||
await waitFor(() => screen.getByRole('listbox'));
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Escape closes the dropdown', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.type(input, 'T1059');
|
||||
act(() => { vi.advanceTimersByTime(300); });
|
||||
|
||||
await waitFor(() => screen.getByRole('listbox'));
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows inline error when API returns 503', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(503, { error: 'mitre bundle not loaded' });
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.type(input, 'T1059');
|
||||
act(() => { vi.advanceTimersByTime(300); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
275
frontend/tests/SimulationFormPage.test.tsx
Normal file
275
frontend/tests/SimulationFormPage.test.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { SimulationFormPage } from '@/pages/SimulationFormPage';
|
||||
import { renderWithProviders } from './utils';
|
||||
import type { Simulation } from '@/api/types';
|
||||
|
||||
const BASE_SIM: Simulation = {
|
||||
id: 7,
|
||||
engagement_id: 42,
|
||||
name: 'Recon test',
|
||||
mitre_technique_id: null,
|
||||
mitre_technique_name: null,
|
||||
description: 'Some description',
|
||||
commands: 'whoami\nipconfig',
|
||||
prerequisites: null,
|
||||
executed_at: null,
|
||||
execution_result: null,
|
||||
log_source: null,
|
||||
logs: null,
|
||||
soc_comment: null,
|
||||
incident_number: null,
|
||||
status: 'pending',
|
||||
created_at: '2026-05-26T08:00:00',
|
||||
updated_at: null,
|
||||
created_by: { id: 1, username: 'alice' },
|
||||
};
|
||||
|
||||
let mockRole: 'admin' | 'redteam' | 'soc' = 'redteam';
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'alice', role: mockRole, created_at: '2026-01-01' },
|
||||
status: 'authenticated',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAdmin: mockRole === 'admin',
|
||||
isRedteam: mockRole === 'redteam',
|
||||
isSoc: mockRole === 'soc',
|
||||
canEditEngagements: mockRole === 'admin' || mockRole === 'redteam',
|
||||
}),
|
||||
}));
|
||||
|
||||
// Wrap the page in a Route so useParams gets eid and sid
|
||||
function EditPage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/engagements/:eid/simulations/:sid/edit" element={<SimulationFormPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function NewPage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/engagements/:eid/simulations/new" element={<SimulationFormPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRole = 'redteam';
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
mock.onGet('/simulations/7').reply(() => new Promise(() => {}));
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
expect(screen.getByTestId('loading-state')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('all Red Team fields are enabled for redteam', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/Description/i)).not.toBeDisabled();
|
||||
expect(screen.getByLabelText(/Commands/i)).not.toBeDisabled();
|
||||
expect(screen.getByLabelText(/Executed at/i)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows "Marquer en revue" button when status is pending', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Marquer en revue/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show "Clôturer" when status is pending', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => screen.getByRole('button', { name: /Marquer en revue/i }));
|
||||
expect(screen.queryByRole('button', { name: /Clôturer/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows "Marquer en revue" for in_progress status', async () => {
|
||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'in_progress' });
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Marquer en revue/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Clôturer" button when status is review_required', async () => {
|
||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Clôturer/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Supprimer" button for redteam', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Supprimer/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SimulationFormPage — SOC role + pending (blocked)', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRole = 'soc';
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows the SOC blocked banner', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('SOC inputs are disabled when status is pending', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Log source/i)).toBeDisabled();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/Incident number/i)).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Red Team inputs are disabled for SOC', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/^Name/i)).toBeDisabled();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/Description/i)).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SimulationFormPage — SOC role + review_required (can edit SOC fields)', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRole = 'soc';
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('SOC inputs are enabled when status is review_required', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Log source/i)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/Incident number/i)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('Red Team inputs remain disabled for SOC even when review_required', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/^Name/i)).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show the blocked banner when status is review_required', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Log source/i)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('soc-blocked-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows "Clôturer" for SOC when review_required', async () => {
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Clôturer/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SimulationFormPage — new simulation', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRole = 'redteam';
|
||||
mock = new MockAdapter(apiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('renders the new simulation form with name field', () => {
|
||||
renderWithProviders(<NewPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/new'] },
|
||||
});
|
||||
expect(screen.getByLabelText(/^Name/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
138
frontend/tests/SimulationList.test.tsx
Normal file
138
frontend/tests/SimulationList.test.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { SimulationList } from '@/components/SimulationList';
|
||||
import { renderWithProviders } from './utils';
|
||||
import type { Simulation } from '@/api/types';
|
||||
|
||||
const SIMULATIONS: Simulation[] = [
|
||||
{
|
||||
id: 1,
|
||||
engagement_id: 42,
|
||||
name: 'Lateral movement test',
|
||||
mitre_technique_id: 'T1021',
|
||||
mitre_technique_name: 'Remote Services',
|
||||
description: null,
|
||||
commands: null,
|
||||
prerequisites: null,
|
||||
executed_at: '2026-06-01T10:00:00',
|
||||
execution_result: null,
|
||||
log_source: null,
|
||||
logs: null,
|
||||
soc_comment: null,
|
||||
incident_number: null,
|
||||
status: 'in_progress',
|
||||
created_at: '2026-05-26T08:00:00',
|
||||
updated_at: null,
|
||||
created_by: { id: 1, username: 'alice' },
|
||||
},
|
||||
];
|
||||
|
||||
let mockCanEdit = true;
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'alice', role: mockCanEdit ? 'admin' : 'soc', created_at: '2026-01-01' },
|
||||
status: 'authenticated',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAdmin: mockCanEdit,
|
||||
isRedteam: false,
|
||||
isSoc: !mockCanEdit,
|
||||
canEditEngagements: mockCanEdit,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SimulationList — admin/redteam', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCanEdit = true;
|
||||
mock = new MockAdapter(apiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows loading state initially', () => {
|
||||
mock.onGet('/engagements/42/simulations').reply(() => new Promise(() => {}));
|
||||
renderWithProviders(<SimulationList engagementId={42} />);
|
||||
expect(screen.getByTestId('loading-state')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state when request fails', async () => {
|
||||
mock.onGet('/engagements/42/simulations').reply(500, { error: 'Server error' });
|
||||
renderWithProviders(<SimulationList engagementId={42} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-state')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no simulations', async () => {
|
||||
mock.onGet('/engagements/42/simulations').reply(200, []);
|
||||
renderWithProviders(<SimulationList engagementId={42} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Nouvelle simulation" button for admin/redteam in empty state', async () => {
|
||||
mock.onGet('/engagements/42/simulations').reply(200, []);
|
||||
renderWithProviders(<SimulationList engagementId={42} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('new-simulation-btn')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the simulation list with correct data', async () => {
|
||||
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
|
||||
renderWithProviders(<SimulationList engagementId={42} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('T1021')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('simulation-status-badge')).toHaveAttribute('data-status', 'in_progress');
|
||||
});
|
||||
|
||||
it('shows "Nouvelle simulation" button in header when simulations exist', async () => {
|
||||
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
|
||||
renderWithProviders(<SimulationList engagementId={42} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('new-simulation-btn')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SimulationList — SOC role (no edit button)', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCanEdit = false;
|
||||
mock = new MockAdapter(apiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('does not show "Nouvelle simulation" button for SOC in empty state', async () => {
|
||||
mock.onGet('/engagements/42/simulations').reply(200, []);
|
||||
renderWithProviders(<SimulationList engagementId={42} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('new-simulation-btn')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not show "Nouvelle simulation" button for SOC when simulations exist', async () => {
|
||||
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
|
||||
renderWithProviders(<SimulationList engagementId={42} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('new-simulation-btn')).toBeNull();
|
||||
});
|
||||
});
|
||||
44
frontend/tests/SimulationStatusBadge.test.tsx
Normal file
44
frontend/tests/SimulationStatusBadge.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
|
||||
import type { SimulationStatus } from '@/api/types';
|
||||
|
||||
const CASES: { status: SimulationStatus; label: string }[] = [
|
||||
{ status: 'pending', label: 'Pending' },
|
||||
{ status: 'in_progress', label: 'In progress' },
|
||||
{ status: 'review_required', label: 'Review required' },
|
||||
{ status: 'done', label: 'Done' },
|
||||
];
|
||||
|
||||
describe('SimulationStatusBadge', () => {
|
||||
it.each(CASES)('renders $status with correct label and data attr', ({ status, label }) => {
|
||||
render(<SimulationStatusBadge status={status} />);
|
||||
const badge = screen.getByTestId('simulation-status-badge');
|
||||
expect(badge).toHaveAttribute('data-status', status);
|
||||
expect(badge.textContent).toBe(label);
|
||||
});
|
||||
|
||||
it('applies fog surface for pending', () => {
|
||||
render(<SimulationStatusBadge status="pending" />);
|
||||
const badge = screen.getByTestId('simulation-status-badge');
|
||||
expect(badge.className).toContain('bg-fog');
|
||||
});
|
||||
|
||||
it('applies primary-soft surface for in_progress', () => {
|
||||
render(<SimulationStatusBadge status="in_progress" />);
|
||||
const badge = screen.getByTestId('simulation-status-badge');
|
||||
expect(badge.className).toContain('bg-primary-soft');
|
||||
});
|
||||
|
||||
it('applies bloom-coral surface for review_required', () => {
|
||||
render(<SimulationStatusBadge status="review_required" />);
|
||||
const badge = screen.getByTestId('simulation-status-badge');
|
||||
expect(badge.className).toContain('bg-bloom-coral');
|
||||
});
|
||||
|
||||
it('applies storm-deep surface for done', () => {
|
||||
render(<SimulationStatusBadge status="done" />);
|
||||
const badge = screen.getByTestId('simulation-status-badge');
|
||||
expect(badge.className).toContain('bg-storm-deep');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user