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
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
|
import MockAdapter from 'axios-mock-adapter';
|
|
|
|
|
import { apiClient } from '@/api/client';
|
|
|
|
|
import {
|
|
|
|
|
deleteC2Config,
|
|
|
|
|
executeC2,
|
|
|
|
|
getC2Config,
|
2026-06-10 20:11:12 +02:00
|
|
|
getC2Tasks,
|
|
|
|
|
importC2,
|
|
|
|
|
listCallbackHistory,
|
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
|
|
|
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']);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-10 20:11:12 +02:00
|
|
|
|
|
|
|
|
describe('getC2Tasks', () => {
|
|
|
|
|
it('GET /simulations/:id/c2/tasks returns tasks list', 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: 'NT AUTHORITY\\SYSTEM',
|
|
|
|
|
mapping_applied: true,
|
|
|
|
|
created_at: '2026-06-10T10:00:00',
|
|
|
|
|
completed_at: '2026-06-10T10:00:05',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
const result = await getC2Tasks(7);
|
|
|
|
|
expect(result.tasks).toHaveLength(1);
|
|
|
|
|
expect(result.tasks[0].status).toBe('completed');
|
|
|
|
|
expect(result.tasks[0].output).toBe('NT AUTHORITY\\SYSTEM');
|
|
|
|
|
expect(mock.history['get'][0].url).toBe('/simulations/7/c2/tasks');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('listCallbackHistory', () => {
|
|
|
|
|
it('GET with page/page_size params', async () => {
|
|
|
|
|
mock.onGet('/engagements/1/c2/callbacks/2/history').reply(200, {
|
|
|
|
|
tasks: [],
|
|
|
|
|
total: 0,
|
|
|
|
|
page: 1,
|
|
|
|
|
page_size: 25,
|
|
|
|
|
});
|
|
|
|
|
const result = await listCallbackHistory(1, 2, { page: 1, pageSize: 25 });
|
|
|
|
|
expect(result.total).toBe(0);
|
|
|
|
|
const req = mock.history['get'][0];
|
|
|
|
|
expect(req.url).toBe('/engagements/1/c2/callbacks/2/history');
|
|
|
|
|
expect(req.params).toMatchObject({ page: 1, page_size: 25 });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('importC2', () => {
|
|
|
|
|
it('POST /simulations/:id/c2/import with task_display_ids', async () => {
|
|
|
|
|
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 3, skipped: 1 });
|
|
|
|
|
const result = await importC2(7, {
|
|
|
|
|
callback_display_id: 2,
|
|
|
|
|
task_display_ids: [10, 11, 12, 13],
|
|
|
|
|
});
|
|
|
|
|
expect(result.imported).toBe(3);
|
|
|
|
|
expect(result.skipped).toBe(1);
|
|
|
|
|
const req = mock.history['post'][0];
|
|
|
|
|
expect(req.url).toBe('/simulations/7/c2/import');
|
|
|
|
|
const body = JSON.parse(req.data as string);
|
|
|
|
|
expect(body.callback_display_id).toBe(2);
|
|
|
|
|
expect(body.task_display_ids).toEqual([10, 11, 12, 13]);
|
|
|
|
|
});
|
|
|
|
|
});
|