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:
160
frontend/tests/components/ExecuteViaC2Modal.test.tsx
Normal file
160
frontend/tests/components/ExecuteViaC2Modal.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user