Files
mimic/frontend/tests/SimulationFormPage.test.tsx
Knacky 0378792dc4 feat(frontend): SimulationFormPage 3-tab layout (Red Team / SOC / Tâche C2)
- Tabs: disabled per-item (aria-disabled, native disabled, tab-underline-disabled,
  arrow-key skip); aria-controls wired
- useHashTab: optional validIds fallback to defaultId
- SimulationFormPage: 3-tab refactor; SOC tab gated on review_required|done;
  C2 tab disabled in create mode; contextual sticky bar hidden on C2 tab;
  safeTab guard against stale hash; SOC-blocked banner removed
- fr.json: simulation.form.tab.{redTeam,soc,c2} keys
- DESIGN.md: disabled tab variant + aria-controls documented
- Tests: 253 passing (5 new Tabs disabled tests, SimulationFormPage rewritten
  for tab layout)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 10:48:18 +02:00

490 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
// Reset hash between tests so useHashTab doesn't bleed state
afterEach(() => { history.replaceState(null, '', '/'); });
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',
techniques: [],
tactics: [],
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',
}),
}));
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);
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
});
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(/^Nom/i)).not.toBeDisabled();
});
expect(screen.getByLabelText(/^Description/i)).not.toBeDisabled();
expect(screen.getByLabelText(/^Commandes/i)).not.toBeDisabled();
expect(screen.getByLabelText(/^Exécuté le/i)).not.toBeDisabled();
});
it('shows "Mark for review" button when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('mark-review-btn')).toBeInTheDocument();
});
});
it('does not show "Close" when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => screen.getByTestId('mark-review-btn'));
expect(screen.queryByTestId('close-btn')).toBeNull();
});
it('shows "Mark for review" 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.getByTestId('mark-review-btn')).toBeInTheDocument();
});
});
it('shows "Close" button on SOC tab 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'] },
});
// Switch to SOC tab to see Close button
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
await waitFor(() => {
expect(screen.getByTestId('close-btn')).toBeInTheDocument();
});
});
it('shows "Delete" button for redteam', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('delete-btn')).toBeInTheDocument();
});
});
});
describe('SimulationFormPage — tabs', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
});
afterEach(() => {
mock.restore();
});
it('renders 3 tabs: Red Team, SOC, Tâche C2', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
expect(screen.getByRole('tab', { name: /Red Team/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /SOC/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Tâche C2/i })).toBeInTheDocument();
});
it('SOC tab is disabled when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
const socTab = screen.getByRole('tab', { name: /SOC/i });
expect(socTab).toBeDisabled();
});
it('SOC tab is enabled 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(() => {
const socTab = screen.getByRole('tab', { name: /SOC/i });
expect(socTab).not.toBeDisabled();
});
});
it('SOC tab is enabled when status is done', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'done' });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
const socTab = screen.getByRole('tab', { name: /SOC/i });
expect(socTab).not.toBeDisabled();
});
});
it('C2 tab is disabled in edit mode (no tasks shown until tab visible)', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
// C2 tab should exist and be enabled (it's edit mode, not create mode)
const c2Tab = screen.getByRole('tab', { name: /Tâche C2/i });
expect(c2Tab).not.toBeDisabled();
});
it('SOC fields are visible after switching to SOC tab', 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('tab', { name: /SOC/i })).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
await waitFor(() => {
expect(screen.getByLabelText(/^Source de log/i)).toBeInTheDocument();
});
expect(screen.getByLabelText(/^Numéro d'incident/i)).toBeInTheDocument();
});
it('sticky bar is not rendered on C2 tab', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /Tâche C2/i }));
await waitFor(() => expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument());
// Save button should not appear on C2 tab
expect(screen.queryByRole('button', { name: /Enregistrer/i })).toBeNull();
});
it('C2 tasks count pill shows when tasks exist', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, {
tasks: [
{
id: 1,
mythic_task_display_id: 10,
callback_display_id: 1,
command: 'whoami',
params: null,
status: 'completed',
completed: true,
output: 'SYSTEM',
mapping_applied: false,
source: 'import',
created_at: '2026-06-10T10:00:00',
completed_at: '2026-06-10T10:00:05',
},
],
});
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
// Count pill shows "1" on the C2 tab
expect(screen.getByText('1')).toBeInTheDocument();
});
});
});
describe('SimulationFormPage — SOC role + pending (tab disabled)', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'soc';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
});
afterEach(() => {
mock.restore();
});
it('SOC tab is disabled for SOC role when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/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(/^Nom/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' });
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
});
afterEach(() => {
mock.restore();
});
it('SOC inputs are enabled when status is review_required', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
// Switch to SOC tab
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
await waitFor(() => {
expect(screen.getByLabelText(/^Source de log/i)).not.toBeDisabled();
});
expect(screen.getByLabelText(/^Numéro d'incident/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(/^Nom/i)).toBeDisabled();
});
});
it('shows "Close" for SOC when review_required', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
// Switch to SOC tab
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
await waitFor(() => {
expect(screen.getByTestId('close-btn')).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(/^Nom/i)).toBeInTheDocument();
expect(screen.getByTestId('create-sim-btn')).toBeInTheDocument();
});
it('SOC and C2 tabs are disabled in create mode', () => {
renderWithProviders(<NewPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/new'] },
});
expect(screen.getByRole('tab', { name: /SOC/i })).toBeDisabled();
expect(screen.getByRole('tab', { name: /Tâche C2/i })).toBeDisabled();
});
});
describe('SimulationFormPage — Execute via C2 button visibility', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
});
afterEach(() => {
mock.restore();
});
it('shows Execute via C2 button when c2 config exists', async () => {
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-execute-btn')).toBeInTheDocument();
});
});
it('hides Execute via C2 button when no c2 config (404)', async () => {
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled();
});
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
});
it('hides Execute via C2 button when simulation is done', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'done' });
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
// Wait for RT tab to load; done sim disables RT editing
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).toBeDisabled());
// Execute via C2 not shown on done simulation
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
// Reopen is on the SOC tab — switch to confirm
const socTab = screen.getByRole('tab', { name: /SOC/i });
expect(socTab).not.toBeDisabled();
fireEvent.click(socTab);
await waitFor(() => {
expect(screen.getByTestId('reopen-btn')).toBeInTheDocument();
});
});
it('shows Import C2 history button when c2 config exists', async () => {
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-import-trigger-btn')).toBeInTheDocument();
});
});
});