2026-05-26 11:13:14 +02:00
|
|
|
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',
|
feat(frontend): sprint 3 — multi-technique MITRE selection + matrix modal
- types: replace mitre_technique_id/name scalars with techniques:MitreTechnique[]
on Simulation; add MitreTactic/MitreMatrixTechnique/MitreMatrixSubtechnique;
SimulationPatchInput now uses technique_ids:string[]
- api/mitre.ts: add getMitreMatrix() → GET /api/mitre/matrix
- hooks/useMitre: add useMitreMatrix(enabled) with staleTime:Infinity
- MitreTechniquePicker: clean rewrite — onSelect(technique) one-shot, resets
input after selection, no incoming value props
- MitreTechniqueTag: chip component with id+name and × remove button
- MitreMatrixModal: tactic columns (220px fixed), expand/collapse subtechniques,
search filter (auto-expands parent on sub match), selection state, focus trap
(Tab wrap, Escape, search autofocus), backdrop click cancel, Apply N techniques
- MitreTechniquesField: orchestrates tags+picker+matrix with auto-save PATCH on
every add/remove/Apply, dedup guard, disabled read-only mode for SOC
- SimulationFormPage: swap MitreTechniquePicker for MitreTechniquesField; remove
technique state from RT form (techniques have independent auto-save cycle)
- SimulationList: MITRE column → T1059 +2 counter format, — when empty
- Tests: 84 passing (13 test files); new suites for Tag, Field, Modal;
MitreTechniquePicker + SimulationFormPage + SimulationList adapted to new API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 04:04:23 +02:00
|
|
|
techniques: [],
|
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
|
|
|
tactics: [],
|
2026-05-26 11:13:14 +02:00
|
|
|
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);
|
feat(frontend): c2 config card + execute modal (sprint 8 phase 1)
- frontend/src/api/c2.ts: 6 typed API calls (getC2Config, putC2Config,
deleteC2Config, testC2Config, listCallbacks, executeC2) following the
frozen M1+M2 backend contracts
- frontend/src/api/types.ts: C2Config, C2ConfigInput, C2TestResult,
C2Callback, C2CallbacksResponse, C2Task, C2ExecuteInput, C2ExecuteResponse
- frontend/src/hooks/useC2.ts: useC2Config, useUpdateC2Config,
useDeleteC2Config, useTestC2Config, useC2Callbacks, useExecuteC2
- frontend/src/components/C2ConfigCard.tsx: engagement-scoped C2 config
card (url + write-only token + verify-tls + save/delete/test-connection),
503 disabled state, ConfirmDialog on delete
- frontend/src/components/ExecuteViaC2Modal.tsx: callback picker table
(mono data cells), commands textarea pre-filled from rt.commands,
Launch disabled until row selected + non-empty commands
- frontend/src/pages/EngagementFormPage.tsx: embed C2ConfigCard in edit
mode only, admin+redteam only (canEditEngagements gate)
- frontend/src/pages/SimulationFormPage.tsx: Execute via C2 button in RT
card, visible only when !isDone && canEditRT && hasC2Config; opens modal
- Tests: 33 new tests across api/c2, components/C2ConfigCard,
components/ExecuteViaC2Modal, EngagementFormPage, SimulationFormPage
(172 total, 139 baseline + 33 new, all passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:50:11 +02:00
|
|
|
mock.onGet('/engagements/42/c2-config').reply(404);
|
2026-05-26 11:13:14 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-26 16:08:46 +02:00
|
|
|
it('shows "Mark for review" button when status is pending', async () => {
|
2026-05-26 11:13:14 +02:00
|
|
|
renderWithProviders(<EditPage />, {
|
|
|
|
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
2026-05-26 16:08:46 +02:00
|
|
|
expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument();
|
2026-05-26 11:13:14 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-26 16:08:46 +02:00
|
|
|
it('does not show "Close" when status is pending', async () => {
|
2026-05-26 11:13:14 +02:00
|
|
|
renderWithProviders(<EditPage />, {
|
|
|
|
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-26 16:08:46 +02:00
|
|
|
await waitFor(() => screen.getByRole('button', { name: /Mark for review/i }));
|
|
|
|
|
expect(screen.queryByRole('button', { name: /^Close$/i })).toBeNull();
|
2026-05-26 11:13:14 +02:00
|
|
|
});
|
|
|
|
|
|
2026-05-26 16:08:46 +02:00
|
|
|
it('shows "Mark for review" for in_progress status', async () => {
|
2026-05-26 11:13:14 +02:00
|
|
|
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'in_progress' });
|
|
|
|
|
renderWithProviders(<EditPage />, {
|
|
|
|
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
2026-05-26 16:08:46 +02:00
|
|
|
expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument();
|
2026-05-26 11:13:14 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-26 16:08:46 +02:00
|
|
|
it('shows "Close" button when status is review_required', async () => {
|
2026-05-26 11:13:14 +02:00
|
|
|
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
|
|
|
|
renderWithProviders(<EditPage />, {
|
|
|
|
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
2026-05-26 16:08:46 +02:00
|
|
|
expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument();
|
2026-05-26 11:13:14 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-26 16:08:46 +02:00
|
|
|
it('shows "Delete" button for redteam', async () => {
|
2026-05-26 11:13:14 +02:00
|
|
|
renderWithProviders(<EditPage />, {
|
|
|
|
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
2026-05-26 16:08:46 +02:00
|
|
|
expect(screen.getByRole('button', { name: /^Delete$/i })).toBeInTheDocument();
|
2026-05-26 11:13:14 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('SimulationFormPage — SOC role + pending (blocked)', () => {
|
|
|
|
|
let mock: MockAdapter;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
mockRole = 'soc';
|
|
|
|
|
mock = new MockAdapter(apiClient);
|
|
|
|
|
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
feat(frontend): c2 config card + execute modal (sprint 8 phase 1)
- frontend/src/api/c2.ts: 6 typed API calls (getC2Config, putC2Config,
deleteC2Config, testC2Config, listCallbacks, executeC2) following the
frozen M1+M2 backend contracts
- frontend/src/api/types.ts: C2Config, C2ConfigInput, C2TestResult,
C2Callback, C2CallbacksResponse, C2Task, C2ExecuteInput, C2ExecuteResponse
- frontend/src/hooks/useC2.ts: useC2Config, useUpdateC2Config,
useDeleteC2Config, useTestC2Config, useC2Callbacks, useExecuteC2
- frontend/src/components/C2ConfigCard.tsx: engagement-scoped C2 config
card (url + write-only token + verify-tls + save/delete/test-connection),
503 disabled state, ConfirmDialog on delete
- frontend/src/components/ExecuteViaC2Modal.tsx: callback picker table
(mono data cells), commands textarea pre-filled from rt.commands,
Launch disabled until row selected + non-empty commands
- frontend/src/pages/EngagementFormPage.tsx: embed C2ConfigCard in edit
mode only, admin+redteam only (canEditEngagements gate)
- frontend/src/pages/SimulationFormPage.tsx: Execute via C2 button in RT
card, visible only when !isDone && canEditRT && hasC2Config; opens modal
- Tests: 33 new tests across api/c2, components/C2ConfigCard,
components/ExecuteViaC2Modal, EngagementFormPage, SimulationFormPage
(172 total, 139 baseline + 33 new, all passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:50:11 +02:00
|
|
|
// SOC role: useC2Config disabled (canEditRT=false), so no request expected — stub anyway
|
|
|
|
|
mock.onGet('/engagements/42/c2-config').reply(404);
|
2026-05-26 11:13:14 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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' });
|
feat(frontend): c2 config card + execute modal (sprint 8 phase 1)
- frontend/src/api/c2.ts: 6 typed API calls (getC2Config, putC2Config,
deleteC2Config, testC2Config, listCallbacks, executeC2) following the
frozen M1+M2 backend contracts
- frontend/src/api/types.ts: C2Config, C2ConfigInput, C2TestResult,
C2Callback, C2CallbacksResponse, C2Task, C2ExecuteInput, C2ExecuteResponse
- frontend/src/hooks/useC2.ts: useC2Config, useUpdateC2Config,
useDeleteC2Config, useTestC2Config, useC2Callbacks, useExecuteC2
- frontend/src/components/C2ConfigCard.tsx: engagement-scoped C2 config
card (url + write-only token + verify-tls + save/delete/test-connection),
503 disabled state, ConfirmDialog on delete
- frontend/src/components/ExecuteViaC2Modal.tsx: callback picker table
(mono data cells), commands textarea pre-filled from rt.commands,
Launch disabled until row selected + non-empty commands
- frontend/src/pages/EngagementFormPage.tsx: embed C2ConfigCard in edit
mode only, admin+redteam only (canEditEngagements gate)
- frontend/src/pages/SimulationFormPage.tsx: Execute via C2 button in RT
card, visible only when !isDone && canEditRT && hasC2Config; opens modal
- Tests: 33 new tests across api/c2, components/C2ConfigCard,
components/ExecuteViaC2Modal, EngagementFormPage, SimulationFormPage
(172 total, 139 baseline + 33 new, all passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:50:11 +02:00
|
|
|
mock.onGet('/engagements/42/c2-config').reply(404);
|
2026-05-26 11:13:14 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-26 16:08:46 +02:00
|
|
|
it('shows "Close" for SOC when review_required', async () => {
|
2026-05-26 11:13:14 +02:00
|
|
|
renderWithProviders(<EditPage />, {
|
|
|
|
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
2026-05-26 16:08:46 +02:00
|
|
|
expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument();
|
2026-05-26 11:13:14 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
feat(frontend): c2 config card + execute modal (sprint 8 phase 1)
- frontend/src/api/c2.ts: 6 typed API calls (getC2Config, putC2Config,
deleteC2Config, testC2Config, listCallbacks, executeC2) following the
frozen M1+M2 backend contracts
- frontend/src/api/types.ts: C2Config, C2ConfigInput, C2TestResult,
C2Callback, C2CallbacksResponse, C2Task, C2ExecuteInput, C2ExecuteResponse
- frontend/src/hooks/useC2.ts: useC2Config, useUpdateC2Config,
useDeleteC2Config, useTestC2Config, useC2Callbacks, useExecuteC2
- frontend/src/components/C2ConfigCard.tsx: engagement-scoped C2 config
card (url + write-only token + verify-tls + save/delete/test-connection),
503 disabled state, ConfirmDialog on delete
- frontend/src/components/ExecuteViaC2Modal.tsx: callback picker table
(mono data cells), commands textarea pre-filled from rt.commands,
Launch disabled until row selected + non-empty commands
- frontend/src/pages/EngagementFormPage.tsx: embed C2ConfigCard in edit
mode only, admin+redteam only (canEditEngagements gate)
- frontend/src/pages/SimulationFormPage.tsx: Execute via C2 button in RT
card, visible only when !isDone && canEditRT && hasC2Config; opens modal
- Tests: 33 new tests across api/c2, components/C2ConfigCard,
components/ExecuteViaC2Modal, EngagementFormPage, SimulationFormPage
(172 total, 139 baseline + 33 new, all passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:50:11 +02:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
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);
|
|
|
|
|
renderWithProviders(<EditPage />, {
|
|
|
|
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
|
|
|
|
});
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByLabelText(/^Name/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,
|
|
|
|
|
});
|
|
|
|
|
renderWithProviders(<EditPage />, {
|
|
|
|
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
|
|
|
|
});
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('reopen-btn')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-10 20:11:12 +02:00
|
|
|
|
|
|
|
|
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(/^Name/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,
|
2026-06-10 20:22:45 +02:00
|
|
|
source: 'import',
|
2026-06-10 20:11:12 +02:00
|
|
|
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 () => {
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|