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>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
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';
|
||||
@@ -43,7 +45,6 @@ vi.mock('@/hooks/useAuth', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Wrap the page in a Route so useParams gets eid and sid
|
||||
function EditPage() {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -68,6 +69,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
||||
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(() => {
|
||||
@@ -126,12 +128,16 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Close" button when status is review_required', async () => {
|
||||
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();
|
||||
});
|
||||
@@ -148,41 +154,160 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SimulationFormPage — SOC role + pending (blocked)', () => {
|
||||
describe('SimulationFormPage — tabs', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRole = 'soc';
|
||||
mockRole = 'redteam';
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||
// SOC role: useC2Config disabled (canEditRT=false), so no request expected — stub anyway
|
||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows the SOC blocked banner', async () => {
|
||||
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(() => {
|
||||
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument();
|
||||
const socTab = screen.getByRole('tab', { name: /SOC/i });
|
||||
expect(socTab).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('SOC inputs are disabled when status is pending', async () => {
|
||||
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(() => {
|
||||
expect(screen.getByLabelText(/^Source de log/i)).toBeDisabled();
|
||||
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'] },
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/^Numéro d'incident/i)).toBeDisabled();
|
||||
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 () => {
|
||||
@@ -206,6 +331,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
||||
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(() => {
|
||||
@@ -217,10 +343,13 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -234,23 +363,15 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
||||
});
|
||||
});
|
||||
|
||||
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(/^Source de log/i)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('soc-blocked-banner')).toBeNull();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -276,6 +397,15 @@ describe('SimulationFormPage — new simulation', () => {
|
||||
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', () => {
|
||||
@@ -297,6 +427,7 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
||||
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'] },
|
||||
});
|
||||
@@ -307,6 +438,7 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
||||
|
||||
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'] },
|
||||
});
|
||||
@@ -323,100 +455,21 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
||||
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();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SimulationFormPage — C2 tasks panel visibility', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRole = 'redteam';
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows C2 tasks panel when c2 config exists (even with no tasks)', 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-tasks-panel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides C2 tasks panel when no c2 config and no tasks', 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'] },
|
||||
});
|
||||
// Wait for page data to load then confirm no panel
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows C2 tasks panel when tasks exist even without c2 config', async () => {
|
||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||
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(() => {
|
||||
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('SOC role never sees C2 tasks panel', async () => {
|
||||
mockRole = 'soc';
|
||||
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('soc-blocked-banner')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Import C2 history button when c2 config exists', async () => {
|
||||
|
||||
Reference in New Issue
Block a user