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>
This commit is contained in:
Knacky
2026-06-10 19:50:11 +02:00
parent 53755a31d6
commit 5ff6ae8940
12 changed files with 1253 additions and 1 deletions

View File

@@ -0,0 +1,108 @@
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 { EngagementFormPage } from '@/pages/EngagementFormPage';
import { renderWithProviders } from './utils';
import type { Engagement } from '@/api/types';
const ENGAGEMENT: Engagement = {
id: 5,
name: 'Test Engagement',
description: null,
start_date: '2026-06-01',
end_date: null,
status: 'active',
created_at: '2026-06-01T08:00:00',
created_by: { id: 1, username: 'alice' },
};
type MockRole = 'admin' | 'redteam' | 'soc';
let mockRole: MockRole = 'admin';
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/:id/edit" element={<EngagementFormPage />} />
</Routes>
);
}
function NewPage() {
return (
<Routes>
<Route path="/engagements/new" element={<EngagementFormPage />} />
</Routes>
);
}
describe('EngagementFormPage — C2 config card visibility', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
mock.onGet('/engagements/5').reply(200, ENGAGEMENT);
mock.onGet('/engagements/5/c2-config').reply(404);
});
afterEach(() => {
mock.restore();
vi.clearAllMocks();
});
it('shows C2 config card in EDIT mode for admin', async () => {
mockRole = 'admin';
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/5/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-config-card')).toBeInTheDocument();
});
});
it('shows C2 config card in EDIT mode for redteam', async () => {
mockRole = 'redteam';
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/5/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-config-card')).toBeInTheDocument();
});
});
it('does NOT show C2 config card in EDIT mode for SOC', async () => {
mockRole = 'soc';
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/5/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-config-card')).toBeNull();
});
it('does NOT show C2 config card on the NEW engagement form', async () => {
mockRole = 'admin';
renderWithProviders(<NewPage />, {
routerProps: { initialEntries: ['/engagements/new'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /create engagement/i })).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-config-card')).toBeNull();
});
});

View File

@@ -67,6 +67,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
mock.onGet('/engagements/42/c2-config').reply(404);
});
afterEach(() => {
@@ -154,6 +155,8 @@ describe('SimulationFormPage — SOC role + pending (blocked)', () => {
mockRole = 'soc';
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);
});
afterEach(() => {
@@ -202,6 +205,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
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);
});
afterEach(() => {
@@ -273,3 +277,58 @@ describe('SimulationFormPage — new simulation', () => {
expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument();
});
});
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();
});
});

View File

@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import {
deleteC2Config,
executeC2,
getC2Config,
listCallbacks,
putC2Config,
testC2Config,
} from '@/api/c2';
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
describe('getC2Config', () => {
it('returns config on 200', async () => {
mock.onGet('/engagements/1/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
const result = await getC2Config(1);
expect(result).toEqual({ has_token: true, url: 'https://mythic.lab:7443', verify_tls: true });
});
it('returns null on 404', async () => {
mock.onGet('/engagements/1/c2-config').reply(404);
const result = await getC2Config(1);
expect(result).toBeNull();
});
it('throws on other errors', async () => {
mock.onGet('/engagements/1/c2-config').reply(503);
await expect(getC2Config(1)).rejects.toThrow();
});
});
describe('putC2Config', () => {
it('sends PUT to correct URL with body', async () => {
mock.onPut('/engagements/2/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: false,
});
const result = await putC2Config(2, {
url: 'https://mythic.lab:7443',
api_token: 'secret',
verify_tls: false,
});
expect(result.has_token).toBe(true);
expect(result.verify_tls).toBe(false);
const req = mock.history['put'][0];
expect(req.url).toBe('/engagements/2/c2-config');
const body = JSON.parse(req.data as string);
expect(body.api_token).toBe('secret');
expect(body.verify_tls).toBe(false);
});
});
describe('deleteC2Config', () => {
it('sends DELETE to correct URL', async () => {
mock.onDelete('/engagements/3/c2-config').reply(204);
await expect(deleteC2Config(3)).resolves.toBeUndefined();
expect(mock.history['delete'][0].url).toBe('/engagements/3/c2-config');
});
});
describe('testC2Config', () => {
it('sends POST and returns test result', async () => {
mock.onPost('/engagements/1/c2-config/test').reply(200, { ok: true, error: null });
const result = await testC2Config(1);
expect(result.ok).toBe(true);
expect(result.error).toBeNull();
expect(mock.history['post'][0].url).toBe('/engagements/1/c2-config/test');
});
it('returns error message when connection fails', async () => {
mock
.onPost('/engagements/1/c2-config/test')
.reply(200, { ok: false, error: 'Connection refused' });
const result = await testC2Config(1);
expect(result.ok).toBe(false);
expect(result.error).toBe('Connection refused');
});
});
describe('listCallbacks', () => {
it('sends GET and returns callbacks', async () => {
mock.onGet('/engagements/1/c2/callbacks').reply(200, {
callbacks: [
{
display_id: 1,
active: true,
host: 'WIN-TARGET',
user: 'administrator',
domain: 'lab.local',
last_checkin: '2026-06-10T10:00:00',
},
],
});
const result = await listCallbacks(1);
expect(result.callbacks).toHaveLength(1);
expect(result.callbacks[0].display_id).toBe(1);
expect(result.callbacks[0].host).toBe('WIN-TARGET');
expect(mock.history['get'][0].url).toBe('/engagements/1/c2/callbacks');
});
});
describe('executeC2', () => {
it('sends POST with callback_display_id and commands', async () => {
mock.onPost('/simulations/5/c2/execute').reply(200, {
tasks: [
{ id: 1, mythic_task_display_id: 42, command: 'whoami', status: 'submitted', completed: false },
],
});
const result = await executeC2(5, {
callback_display_id: 1,
commands: ['whoami'],
});
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].command).toBe('whoami');
const req = mock.history['post'][0];
expect(req.url).toBe('/simulations/5/c2/execute');
const body = JSON.parse(req.data as string);
expect(body.callback_display_id).toBe(1);
expect(body.commands).toEqual(['whoami']);
});
});

View File

@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { C2ConfigCard } from '@/components/C2ConfigCard';
import { renderWithProviders } from '../utils';
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: true,
isRedteam: false,
isSoc: false,
canEditEngagements: true,
}),
}));
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
vi.clearAllMocks();
});
describe('C2ConfigCard — no config (404)', () => {
it('renders the card with empty fields when no config exists', async () => {
mock.onGet('/engagements/1/c2-config').reply(404);
renderWithProviders(<C2ConfigCard engagementId={1} />);
// Wait for loading to finish — query resolves to null on 404
await waitFor(() => {
expect(screen.getByTestId('c2-url-input')).toBeInTheDocument();
});
expect(screen.getByTestId('c2-token-input')).toBeInTheDocument();
expect(screen.getByTestId('c2-verify-tls')).toBeInTheDocument();
expect(screen.getByTestId('c2-save-btn')).toBeInTheDocument();
// Delete button only shown when has_token
expect(screen.queryByTestId('c2-delete-btn')).toBeNull();
});
});
describe('C2ConfigCard — with config (has_token=true)', () => {
beforeEach(() => {
mock.onGet('/engagements/1/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
});
it('shows Replace token affordance when has_token=true', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByText('Replace token')).toBeInTheDocument();
});
// Token input shows placeholder bullets (readOnly)
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
expect(tokenInput.readOnly).toBe(true);
expect(tokenInput.placeholder).toBe('••••••••');
});
it('shows Delete configuration button when has_token=true', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByTestId('c2-delete-btn')).toBeInTheDocument();
});
});
it('clicking Replace token makes input editable', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByText('Replace token')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Replace token'));
await waitFor(() => {
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
expect(tokenInput.readOnly).toBeFalsy();
});
});
it('Test connection button is enabled when config exists', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
});
});
it('shows Connected on successful test', async () => {
mock.onPost('/engagements/1/c2-config/test').reply(200, { ok: true, error: null });
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
});
fireEvent.click(screen.getByTestId('c2-test-btn'));
await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
});
it('shows error message on failed test', async () => {
mock
.onPost('/engagements/1/c2-config/test')
.reply(200, { ok: false, error: 'Connection refused' });
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
});
fireEvent.click(screen.getByTestId('c2-test-btn'));
await waitFor(() => {
expect(screen.getByText('Connection refused')).toBeInTheDocument();
});
});
});
describe('C2ConfigCard — 503 disabled state', () => {
it('shows 503 banner and disables all inputs', async () => {
mock.onGet('/engagements/1/c2-config').reply(503);
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(
screen.getByText(/C2 features are disabled/i),
).toBeInTheDocument();
});
expect(screen.getByTestId('c2-save-btn')).toBeDisabled();
expect(screen.getByTestId('c2-url-input')).toBeDisabled();
expect(screen.getByTestId('c2-token-input')).toBeDisabled();
expect(screen.getByTestId('c2-verify-tls')).toBeDisabled();
});
});

View File

@@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
import { renderWithProviders } from '../utils';
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 CALLBACKS = [
{
display_id: 1,
active: true,
host: 'WIN-TARGET',
user: 'administrator',
domain: 'lab.local',
last_checkin: '2026-06-10T10:00:00',
},
{
display_id: 2,
active: false,
host: 'WIN-DC01',
user: 'SYSTEM',
domain: 'lab.local',
last_checkin: '2026-06-10T09:00:00',
},
];
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
mock.onGet('/engagements/42/c2/callbacks').reply(200, { callbacks: CALLBACKS });
});
afterEach(() => {
mock.restore();
vi.clearAllMocks();
});
function renderModal(initialCommands = 'whoami\nipconfig') {
const onClose = vi.fn();
renderWithProviders(
<ExecuteViaC2Modal
simulationId={7}
engagementId={42}
initialCommands={initialCommands}
onClose={onClose}
/>,
);
return { onClose };
}
describe('ExecuteViaC2Modal', () => {
it('renders modal with title and callback table', async () => {
renderModal();
expect(screen.getByTestId('c2-modal')).toBeInTheDocument();
expect(screen.getByText('Execute via C2')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
});
it('renders callback rows with mono data', async () => {
renderModal();
await waitFor(() => {
expect(screen.getByText('WIN-TARGET')).toBeInTheDocument();
expect(screen.getByText('WIN-DC01')).toBeInTheDocument();
expect(screen.getByText('administrator')).toBeInTheDocument();
});
});
it('Launch button is disabled before selecting a callback', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
expect(screen.getByTestId('c2-launch-btn')).toBeDisabled();
});
it('Launch button is disabled when commands are empty', async () => {
renderModal('');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
expect(screen.getByTestId('c2-launch-btn')).toBeDisabled();
});
it('Launch button enabled after selecting row and having commands', async () => {
renderModal('whoami');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
expect(screen.getByTestId('c2-launch-btn')).not.toBeDisabled();
});
it('calls executeC2 with correct body and closes modal on success', async () => {
mock.onPost('/simulations/7/c2/execute').reply(200, {
tasks: [
{ id: 1, mythic_task_display_id: 10, command: 'whoami', status: 'submitted', completed: false },
{ id: 2, mythic_task_display_id: 11, command: 'ipconfig', status: 'submitted', completed: false },
],
});
const { onClose } = renderModal('whoami\nipconfig');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
fireEvent.click(screen.getByTestId('c2-launch-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
const req = mock.history['post'][0];
const body = JSON.parse(req.data as string);
expect(body.callback_display_id).toBe(1);
expect(body.commands).toEqual(['whoami', 'ipconfig']);
});
it('shows inline error and keeps modal open on executeC2 failure', async () => {
mock.onPost('/simulations/7/c2/execute').reply(500, { error: 'Mythic unreachable' });
const { onClose } = renderModal('whoami');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
fireEvent.click(screen.getByTestId('c2-launch-btn'));
await waitFor(() => {
expect(screen.getByText('Mythic unreachable')).toBeInTheDocument();
});
expect(onClose).not.toHaveBeenCalled();
});
it('Cancel button calls onClose', async () => {
const { onClose } = renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it('prefills commands textarea from initialCommands', async () => {
renderModal('net user\nwhoami /all');
const textarea = screen.getByTestId('c2-commands-textarea') as HTMLTextAreaElement;
expect(textarea.value).toBe('net user\nwhoami /all');
});
});