diff --git a/frontend/src/api/c2.ts b/frontend/src/api/c2.ts index d4fe677..b0aa68d 100644 --- a/frontend/src/api/c2.ts +++ b/frontend/src/api/c2.ts @@ -1,11 +1,15 @@ import { apiClient } from './client'; import type { + C2CallbackHistoryResponse, C2Config, C2ConfigInput, C2TestResult, C2CallbacksResponse, C2ExecuteInput, C2ExecuteResponse, + C2ImportInput, + C2ImportResponse, + C2TasksResponse, } from './types'; export async function getC2Config(engagementId: number): Promise { @@ -58,3 +62,33 @@ export async function executeC2( ); return data; } + +export async function getC2Tasks(simulationId: number): Promise { + const { data } = await apiClient.get( + `/simulations/${simulationId}/c2/tasks`, + ); + return data; +} + +export async function listCallbackHistory( + engagementId: number, + callbackDisplayId: number, + params: { page: number; pageSize: number }, +): Promise { + const { data } = await apiClient.get( + `/engagements/${engagementId}/c2/callbacks/${callbackDisplayId}/history`, + { params: { page: params.page, page_size: params.pageSize } }, + ); + return data; +} + +export async function importC2( + simulationId: number, + input: C2ImportInput, +): Promise { + const { data } = await apiClient.post( + `/simulations/${simulationId}/c2/import`, + input, + ); + return data; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b51f45b..fb81155 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -187,7 +187,8 @@ export interface C2CallbacksResponse { callbacks: C2Callback[]; } -export interface C2Task { +// Thin shape returned by the execute endpoint +export interface C2ExecuteTask { id: number; mythic_task_display_id: number; command: string; @@ -201,5 +202,52 @@ export interface C2ExecuteInput { } export interface C2ExecuteResponse { - tasks: C2Task[]; + tasks: C2ExecuteTask[]; +} + +// Full shape returned by the tasks list endpoint (M3) +export interface C2TaskListItem { + id: number; + mythic_task_display_id: number; + callback_display_id: number; + command: string; + params: string | null; + status: string; + completed: boolean; + output: string | null; + mapping_applied: boolean; + created_at: string; + completed_at: string | null; +} + +export interface C2TasksResponse { + tasks: C2TaskListItem[]; +} + +// Callback history (M4) +export interface C2HistoryTask { + display_id: number; + command: string; + status: string; + completed: boolean; + completed_at: string | null; + created_at: string; +} + +export interface C2CallbackHistoryResponse { + tasks: C2HistoryTask[]; + total: number; + page: number; + page_size: number; +} + +// Import (M4) +export interface C2ImportInput { + callback_display_id: number; + task_display_ids: number[]; +} + +export interface C2ImportResponse { + imported: number; + skipped: number; } diff --git a/frontend/src/components/C2CallbackPicker.tsx b/frontend/src/components/C2CallbackPicker.tsx new file mode 100644 index 0000000..ee3566c --- /dev/null +++ b/frontend/src/components/C2CallbackPicker.tsx @@ -0,0 +1,87 @@ +import { extractApiError } from '@/api/client'; +import type { C2Callback } from '@/api/types'; + +interface C2CallbackPickerProps { + callbacks: C2Callback[]; + isLoading: boolean; + isError: boolean; + error: unknown; + selectedId: number | null; + onSelect: (id: number) => void; + rowTestId?: string; +} + +export function C2CallbackPicker({ + callbacks, + isLoading, + isError, + error, + selectedId, + onSelect, + rowTestId = 'c2-callback-row', +}: C2CallbackPickerProps): JSX.Element { + if (isLoading) { + return

Loading callbacks…

; + } + + if (isError) { + return ( +

+ Could not load callbacks: {extractApiError(error, 'Unknown error')} +

+ ); + } + + if (callbacks.length === 0) { + return

No callbacks available.

; + } + + return ( +
+ + + + + + + + + + + + + {callbacks.map((cb) => { + const isSelected = selectedId === cb.display_id; + return ( + onSelect(cb.display_id)} + className={`cursor-pointer border-b border-hairline ${ + isSelected ? 'bg-primary-soft' : 'hover:bg-cloud' + }`} + > + + + + + + + + ); + })} + +
Display IDActiveHostUserDomainLast check-in
{cb.display_id} + + {cb.active ? 'Active' : 'Inactive'} + + {cb.host}{cb.user}{cb.domain}{cb.last_checkin}
+
+ ); +} diff --git a/frontend/src/components/C2TaskStatusBadge.tsx b/frontend/src/components/C2TaskStatusBadge.tsx new file mode 100644 index 0000000..36e9487 --- /dev/null +++ b/frontend/src/components/C2TaskStatusBadge.tsx @@ -0,0 +1,27 @@ +// Dedicated badge for Mythic task statuses — separate from simulation status badges. +// submitted / processed → primary-soft (in-flight) +// completed → success-soft +// error* / fail* → warn-soft (task-level issue, not system error) +// anything else → cloud / graphite (unknown/neutral) + +interface C2TaskStatusBadgeProps { + status: string; +} + +function badgeClass(status: string): string { + const s = status.toLowerCase(); + if (s === 'completed') return 'bg-success-soft text-success'; + if (s.startsWith('error') || s.startsWith('fail')) return 'bg-warn-soft text-warn'; + if (s === 'submitted' || s === 'processed') return 'bg-primary-soft text-primary-deep'; + return 'bg-cloud text-graphite border border-hairline'; +} + +export function C2TaskStatusBadge({ status }: C2TaskStatusBadgeProps): JSX.Element { + return ( + + {status} + + ); +} diff --git a/frontend/src/components/C2TasksPanel.tsx b/frontend/src/components/C2TasksPanel.tsx new file mode 100644 index 0000000..466dab2 --- /dev/null +++ b/frontend/src/components/C2TasksPanel.tsx @@ -0,0 +1,126 @@ +import { Fragment, useState } from 'react'; +import { ChevronRight, ChevronDown } from 'lucide-react'; +import { useC2Tasks } from '@/hooks/useC2'; +import { C2TaskStatusBadge } from './C2TaskStatusBadge'; + +interface C2TasksPanelProps { + simulationId: number; +} + +export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element { + const query = useC2Tasks(simulationId, { enabled: true }); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const tasks = query.data?.tasks ?? []; + const isRefreshing = query.isFetching && !query.isLoading; + + function toggleExpand(id: number, completed: boolean) { + if (!completed) return; + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + } + + return ( +
+
+

C2 Tasks

+ {isRefreshing && ( + + Refreshing… + + )} +
+ + {tasks.length === 0 ? ( +
+

+ No C2 tasks yet. Use Execute via C2 to launch commands. +

+
+ ) : ( +
+ + + + + + + + + + + + {tasks.map((task) => { + const isExpanded = expandedIds.has(task.id); + const canExpand = task.completed && Boolean(task.output); + return ( + + toggleExpand(task.id, task.completed)} + className={`border-b border-hairline ${canExpand ? 'cursor-pointer hover:bg-cloud' : ''}`} + > + + + + + + + + {isExpanded && task.output && ( + + + + )} + + ); + })} + +
+ TaskCommandSourceStatusCompleted at
+ {canExpand ? ( + isExpanded ? ( + + ) : ( + + ) + ) : null} + #{task.mythic_task_display_id} + {task.command} + + + {task.mapping_applied ? 'MIMIC' : 'IMPORT'} + + + + + {task.completed_at ?? '—'} +
+
+                            {task.output}
+                          
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ExecuteViaC2Modal.tsx b/frontend/src/components/ExecuteViaC2Modal.tsx index 2d621a0..5eee92b 100644 --- a/frontend/src/components/ExecuteViaC2Modal.tsx +++ b/frontend/src/components/ExecuteViaC2Modal.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { extractApiError } from '@/api/client'; import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2'; -import type { C2Callback } from '@/api/types'; +import { C2CallbackPicker } from './C2CallbackPicker'; import { useToast } from '@/hooks/useToast'; interface ExecuteViaC2ModalProps { @@ -11,11 +11,6 @@ interface ExecuteViaC2ModalProps { onClose: () => void; } -function formatCheckin(ts: string): string { - // Show ISO timestamp as-is — it's a data cell (font-mono) - return ts; -} - export function ExecuteViaC2Modal({ simulationId, engagementId, @@ -31,7 +26,7 @@ export function ExecuteViaC2Modal({ const [commands, setCommands] = useState(initialCommands); const [submitError, setSubmitError] = useState(null); - const callbacks: C2Callback[] = callbacksQuery.data?.callbacks ?? []; + const callbacks = callbacksQuery.data?.callbacks ?? []; const commandLines = commands .split('\n') @@ -70,70 +65,18 @@ export function ExecuteViaC2Modal({ Execute via C2 - {/* Callback table */} + {/* Callback picker */}
Select callback - - {callbacksQuery.isLoading && ( -

Loading callbacks…

- )} - - {callbacksQuery.isError && ( -

- Could not load callbacks: {extractApiError(callbacksQuery.error, 'Unknown error')} -

- )} - - {!callbacksQuery.isLoading && callbacks.length === 0 && !callbacksQuery.isError && ( -

No callbacks available.

- )} - - {callbacks.length > 0 && ( -
- - - - - - - - - - - - - {callbacks.map((cb) => { - const isSelected = selectedId === cb.display_id; - return ( - setSelectedId(cb.display_id)} - className={`cursor-pointer border-b border-hairline ${ - isSelected - ? 'bg-primary-soft' - : 'hover:bg-cloud' - }`} - > - - - - - - - - ); - })} - -
Display IDActiveHostUserDomainLast check-in
{cb.display_id} - - {cb.active ? 'Active' : 'Inactive'} - - {cb.host}{cb.user}{cb.domain}{formatCheckin(cb.last_checkin)}
-
- )} +
{/* Commands */} diff --git a/frontend/src/components/ImportC2HistoryModal.tsx b/frontend/src/components/ImportC2HistoryModal.tsx new file mode 100644 index 0000000..2fa17e2 --- /dev/null +++ b/frontend/src/components/ImportC2HistoryModal.tsx @@ -0,0 +1,252 @@ +import { useState } from 'react'; +import { extractApiError } from '@/api/client'; +import { useC2Callbacks, useC2CallbackHistory, useImportC2 } from '@/hooks/useC2'; +import { C2CallbackPicker } from './C2CallbackPicker'; +import { C2TaskStatusBadge } from './C2TaskStatusBadge'; +import { useToast } from '@/hooks/useToast'; + +const PAGE_SIZE = 25; + +interface ImportC2HistoryModalProps { + simulationId: number; + engagementId: number; + onClose: () => void; +} + +export function ImportC2HistoryModal({ + simulationId, + engagementId, + onClose, +}: ImportC2HistoryModalProps): JSX.Element { + const { push } = useToast(); + + const callbacksQuery = useC2Callbacks(engagementId, { enabled: true }); + const importMutation = useImportC2(simulationId); + + const [selectedCallbackId, setSelectedCallbackId] = useState(null); + const [page, setPage] = useState(1); + const [checkedIds, setCheckedIds] = useState>(new Set()); + const [submitError, setSubmitError] = useState(null); + + const historyQuery = useC2CallbackHistory(engagementId, selectedCallbackId, { + page, + pageSize: PAGE_SIZE, + enabled: selectedCallbackId !== null, + }); + + const historyTasks = historyQuery.data?.tasks ?? []; + const total = historyQuery.data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + const callbacks = callbacksQuery.data?.callbacks ?? []; + + function handleCallbackSelect(id: number) { + setSelectedCallbackId(id); + setPage(1); + setCheckedIds(new Set()); + } + + function toggleCheck(displayId: number) { + setCheckedIds((prev) => { + const next = new Set(prev); + if (next.has(displayId)) { + next.delete(displayId); + } else { + next.add(displayId); + } + return next; + }); + } + + const canImport = checkedIds.size > 0 && selectedCallbackId !== null; + + const onImport = async () => { + if (!canImport) return; + setSubmitError(null); + try { + const result = await importMutation.mutateAsync({ + callback_display_id: selectedCallbackId, + task_display_ids: Array.from(checkedIds), + }); + const msg = + result.skipped > 0 + ? `Imported ${result.imported} task(s), ${result.skipped} already attached` + : `Imported ${result.imported} task(s)`; + push(msg, 'success'); + onClose(); + } catch (err) { + setSubmitError(extractApiError(err, 'Could not import tasks')); + } + }; + + return ( +
+ + ); +} diff --git a/frontend/src/hooks/useC2.ts b/frontend/src/hooks/useC2.ts index 343db3f..aba1700 100644 --- a/frontend/src/hooks/useC2.ts +++ b/frontend/src/hooks/useC2.ts @@ -3,11 +3,19 @@ import { deleteC2Config, executeC2, getC2Config, + getC2Tasks, + importC2, + listCallbackHistory, listCallbacks, putC2Config, testC2Config, } from '@/api/c2'; -import type { C2ConfigInput, C2ExecuteInput } from '@/api/types'; +import type { + C2ConfigInput, + C2ExecuteInput, + C2ImportInput, + C2TasksResponse, +} from '@/api/types'; function c2ConfigKey(engagementId: number) { return ['c2-config', engagementId] as const; @@ -17,6 +25,14 @@ function c2CallbacksKey(engagementId: number) { return ['c2-callbacks', engagementId] as const; } +function c2TasksKey(simulationId: number) { + return ['c2-tasks', simulationId] as const; +} + +function c2HistoryKey(engagementId: number, callbackDisplayId: number, page: number, pageSize: number) { + return ['c2-history', engagementId, callbackDisplayId, page, pageSize] as const; +} + function simulationKey(id: number) { return ['simulations', id] as const; } @@ -69,6 +85,22 @@ export function useC2Callbacks(engagementId: number | undefined, options?: { ena }); } +export function useC2Tasks(simulationId: number | undefined, options?: { enabled?: boolean }) { + const enabled = + typeof simulationId === 'number' && + !Number.isNaN(simulationId) && + (options?.enabled !== false); + + return useQuery({ + queryKey: simulationId ? c2TasksKey(simulationId) : ['c2-tasks', 'none'], + queryFn: () => getC2Tasks(simulationId as number), + enabled, + // Poll every 2500 ms while any task is incomplete; stop when all done. + refetchInterval: (query: { state: { data?: C2TasksResponse } }) => + query.state.data?.tasks?.some((t) => !t.completed) ? 2500 : false, + }); +} + export function useExecuteC2(simulationId: number, engagementId: number) { const qc = useQueryClient(); return useMutation({ @@ -76,6 +108,45 @@ export function useExecuteC2(simulationId: number, engagementId: number) { onSuccess: () => { qc.invalidateQueries({ queryKey: simulationKey(simulationId) }); qc.invalidateQueries({ queryKey: ['engagements', engagementId, 'simulations'] }); + qc.invalidateQueries({ queryKey: c2TasksKey(simulationId) }); + }, + }); +} + +export function useC2CallbackHistory( + engagementId: number | undefined, + callbackDisplayId: number | null, + options?: { page?: number; pageSize?: number; enabled?: boolean }, +) { + const page = options?.page ?? 1; + const pageSize = options?.pageSize ?? 25; + const enabled = + typeof engagementId === 'number' && + !Number.isNaN(engagementId) && + callbackDisplayId !== null && + (options?.enabled !== false); + + return useQuery({ + queryKey: + engagementId && callbackDisplayId !== null + ? c2HistoryKey(engagementId, callbackDisplayId, page, pageSize) + : ['c2-history', 'none'], + queryFn: () => + listCallbackHistory(engagementId as number, callbackDisplayId as number, { + page, + pageSize, + }), + enabled, + }); +} + +export function useImportC2(simulationId: number) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: C2ImportInput) => importC2(simulationId, input), + onSuccess: () => { + qc.invalidateQueries({ queryKey: c2TasksKey(simulationId) }); + qc.invalidateQueries({ queryKey: simulationKey(simulationId) }); }, }); } diff --git a/frontend/src/pages/SimulationFormPage.tsx b/frontend/src/pages/SimulationFormPage.tsx index 3fa75e8..ae9ac3a 100644 --- a/frontend/src/pages/SimulationFormPage.tsx +++ b/frontend/src/pages/SimulationFormPage.tsx @@ -12,7 +12,7 @@ import { useTransitionSimulation, useUpdateSimulation, } from '@/hooks/useSimulations'; -import { useC2Config } from '@/hooks/useC2'; +import { useC2Config, useC2Tasks } from '@/hooks/useC2'; import { FormField, TextArea, TextInput } from '@/components/FormField'; import { LoadingState } from '@/components/LoadingState'; import { ErrorState } from '@/components/ErrorState'; @@ -20,6 +20,8 @@ import { SimulationStatusBadge } from '@/components/SimulationStatusBadge'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { MitreTechniquesField } from '@/components/MitreTechniquesField'; import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal'; +import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal'; +import { C2TasksPanel } from '@/components/C2TasksPanel'; interface RedteamFormState { name: string; @@ -70,6 +72,13 @@ export function SimulationFormPage(): JSX.Element { ); const hasC2Config = c2ConfigQuery.data !== null && c2ConfigQuery.data !== undefined; + const c2TasksQuery = useC2Tasks(!isNew ? simulationId : undefined, { + enabled: !isNew && canEditRT, + }); + const hasTasks = (c2TasksQuery.data?.tasks?.length ?? 0) > 0; + // Show panel when: has C2 config (so Execute button is visible) OR already has tasks + const showTasksPanel = !isNew && canEditRT && (hasC2Config || hasTasks); + const detail = useSimulation(isNew ? undefined : simulationId); const createMutation = useCreateSimulation(engagementId ?? 0); const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0); @@ -82,6 +91,7 @@ export function SimulationFormPage(): JSX.Element { const [submitError, setSubmitError] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showC2Modal, setShowC2Modal] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); useEffect(() => { if (!isNew && detail.data) { @@ -307,8 +317,10 @@ export function SimulationFormPage(): JSX.Element {
)} - {/* 2-column grid: RT left, SOC right. Stacks vertically below lg. */} + {/* 2-column grid: RT+tasks left, SOC right. Stacks vertically below lg. */}
+ {/* Left column: RT card + C2 tasks panel */} +
{/* Red Team card */}
{!isDone && canEditRT && hasC2Config && ( -
+
+
)} + {/* C2 tasks panel — under RT card, same left column */} + {showTasksPanel && simulationId && ( + + )} +
{/* end left column */} + {/* SOC card */}
setShowC2Modal(false)} /> )} + + {showImportModal && simulationId && typeof engagementId === 'number' && ( + setShowImportModal(false)} + /> + )}
); } diff --git a/frontend/tests/SimulationFormPage.test.tsx b/frontend/tests/SimulationFormPage.test.tsx index cba548a..bee5da6 100644 --- a/frontend/tests/SimulationFormPage.test.tsx +++ b/frontend/tests/SimulationFormPage.test.tsx @@ -332,3 +332,104 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => { 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(, { + 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(, { + 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, + created_at: '2026-06-10T10:00:00', + completed_at: '2026-06-10T10:00:05', + }, + ], + }); + renderWithProviders(, { + 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(, { + 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(, { + routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, + }); + await waitFor(() => { + expect(screen.getByTestId('c2-import-trigger-btn')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/tests/api/c2.test.ts b/frontend/tests/api/c2.test.ts index e9d9c9d..d6a25d1 100644 --- a/frontend/tests/api/c2.test.ts +++ b/frontend/tests/api/c2.test.ts @@ -5,6 +5,9 @@ import { deleteC2Config, executeC2, getC2Config, + getC2Tasks, + importC2, + listCallbackHistory, listCallbacks, putC2Config, testC2Config, @@ -134,3 +137,63 @@ describe('executeC2', () => { expect(body.commands).toEqual(['whoami']); }); }); + +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]); + }); +}); diff --git a/frontend/tests/components/C2TasksPanel.test.tsx b/frontend/tests/components/C2TasksPanel.test.tsx new file mode 100644 index 0000000..fde40de --- /dev/null +++ b/frontend/tests/components/C2TasksPanel.test.tsx @@ -0,0 +1,196 @@ +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 { C2TasksPanel } from '@/components/C2TasksPanel'; +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 COMPLETED_TASK = { + 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 PENDING_TASK = { + id: 2, + mythic_task_display_id: 11, + callback_display_id: 1, + command: 'ipconfig', + params: null, + status: 'submitted', + completed: false, + output: null, + mapping_applied: false, + created_at: '2026-06-10T10:00:10', + completed_at: null, +}; + +let mock: MockAdapter; + +beforeEach(() => { + mock = new MockAdapter(apiClient); +}); + +afterEach(() => { + mock.restore(); + vi.clearAllMocks(); +}); + +describe('C2TasksPanel — empty state', () => { + it('shows empty state copy when tasks array is empty', async () => { + mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument(); + }); + expect(screen.getByText(/No C2 tasks yet/i)).toBeInTheDocument(); + expect(screen.queryByTestId('c2-task-row')).toBeNull(); + }); +}); + +describe('C2TasksPanel — populated rows', () => { + beforeEach(() => { + mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [COMPLETED_TASK, PENDING_TASK] }); + }); + + it('renders one row per task', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-task-row')).toHaveLength(2); + }); + }); + + it('displays task command and mythic display id', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('whoami')).toBeInTheDocument(); + expect(screen.getByText('ipconfig')).toBeInTheDocument(); + expect(screen.getByText('#10')).toBeInTheDocument(); + expect(screen.getByText('#11')).toBeInTheDocument(); + }); + }); + + it('shows MIMIC source badge for mapping_applied=true', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('MIMIC')).toBeInTheDocument(); + }); + }); + + it('shows IMPORT source badge for mapping_applied=false', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('IMPORT')).toBeInTheDocument(); + }); + }); + + it('shows completed_at timestamp for completed task', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('2026-06-10T10:00:05')).toBeInTheDocument(); + }); + }); + + it('shows em dash for null completed_at', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('—')).toBeInTheDocument(); + }); + }); +}); + +describe('C2TasksPanel — expand on click', () => { + beforeEach(() => { + mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [COMPLETED_TASK] }); + }); + + it('output row is hidden before click', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1); + }); + expect(screen.queryByTestId('c2-task-output')).toBeNull(); + }); + + it('clicking a completed row reveals the output', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1); + }); + fireEvent.click(screen.getByTestId('c2-task-row')); + expect(screen.getByTestId('c2-task-output')).toBeInTheDocument(); + expect(screen.getByTestId('c2-task-output')).toHaveTextContent('NT AUTHORITY\\SYSTEM'); + }); + + it('clicking the expanded row collapses the output', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1); + }); + fireEvent.click(screen.getByTestId('c2-task-row')); + expect(screen.getByTestId('c2-task-output')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('c2-task-row')); + expect(screen.queryByTestId('c2-task-output')).toBeNull(); + }); + + it('clicking an incomplete task row does not expand', async () => { + mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [PENDING_TASK] }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1); + }); + fireEvent.click(screen.getByTestId('c2-task-row')); + expect(screen.queryByTestId('c2-task-output')).toBeNull(); + }); +}); + +describe('C2TasksPanel — refresh indicator', () => { + it('does not show refresh indicator on initial load', async () => { + mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument(); + }); + // During isLoading, isFetching is true but isRefreshing = isFetching && !isLoading = false + expect(screen.queryByTestId('c2-task-refresh-indicator')).toBeNull(); + }); +}); + +describe('C2TasksPanel — polling behaviour', () => { + it('does not refetch when all tasks are completed (refetchInterval false)', async () => { + // With all completed tasks, refetchInterval returns false — only one GET call expected + let callCount = 0; + mock.onGet('/simulations/7/c2/tasks').reply(() => { + callCount++; + return [200, { tasks: [COMPLETED_TASK] }]; + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1); + }); + // Wait a bit and confirm no extra fetches happened beyond initial + await new Promise((r) => setTimeout(r, 100)); + expect(callCount).toBe(1); + }); +}); diff --git a/frontend/tests/components/ImportC2HistoryModal.test.tsx b/frontend/tests/components/ImportC2HistoryModal.test.tsx new file mode 100644 index 0000000..58f98d2 --- /dev/null +++ b/frontend/tests/components/ImportC2HistoryModal.test.tsx @@ -0,0 +1,380 @@ +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 { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal'; +import { ToastViewport } from '@/components/Toast'; +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', + }, +]; + +const HISTORY_TASKS = [ + { + display_id: 10, + command: 'whoami', + status: 'completed', + completed: true, + completed_at: '2026-06-10T10:00:05', + created_at: '2026-06-10T10:00:00', + }, + { + display_id: 11, + command: 'ipconfig', + status: 'completed', + completed: true, + completed_at: '2026-06-10T10:00:10', + created_at: '2026-06-10T10:00:05', + }, +]; + +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() { + const onClose = vi.fn(); + renderWithProviders( + <> + + + , + ); + return { onClose }; +} + +describe('ImportC2HistoryModal — step 1: callback picker', () => { + it('renders modal with title and callback rows', async () => { + renderModal(); + expect(screen.getByTestId('c2-import-modal')).toBeInTheDocument(); + expect(screen.getByText('Import C2 history')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + }); + + it('history table is not shown before selecting a callback', async () => { + renderModal(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + expect(screen.queryByTestId('c2-history-row')).toBeNull(); + }); + + it('Import button is disabled with no selection', async () => { + renderModal(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + expect(screen.getByTestId('c2-import-submit-btn')).toBeDisabled(); + }); +}); + +describe('ImportC2HistoryModal — step 2: history table appears after callback select', () => { + beforeEach(() => { + mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, { + tasks: HISTORY_TASKS, + total: 2, + page: 1, + page_size: 25, + }); + }); + + it('shows history table after selecting a callback', async () => { + renderModal(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]); + await waitFor(() => { + expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2); + }); + }); + + it('shows history task commands in the table', async () => { + renderModal(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]); + await waitFor(() => { + expect(screen.getByText('whoami')).toBeInTheDocument(); + expect(screen.getByText('ipconfig')).toBeInTheDocument(); + }); + }); +}); + +describe('ImportC2HistoryModal — multi-checkbox selection', () => { + beforeEach(() => { + mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, { + tasks: HISTORY_TASKS, + total: 2, + page: 1, + page_size: 25, + }); + }); + + it('Import button remains disabled with no tasks checked', async () => { + renderModal(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]); + await waitFor(() => { + expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2); + }); + expect(screen.getByTestId('c2-import-submit-btn')).toBeDisabled(); + }); + + it('Import button becomes enabled after checking a task row', async () => { + renderModal(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]); + await waitFor(() => { + expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-history-row')[0]); + expect(screen.getByTestId('c2-import-submit-btn')).not.toBeDisabled(); + }); + + it('checking via checkbox also enables Import', async () => { + renderModal(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]); + await waitFor(() => { + expect(screen.getAllByTestId('c2-history-row-checkbox')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-history-row-checkbox')[1]); + expect(screen.getByTestId('c2-import-submit-btn')).not.toBeDisabled(); + }); + + it('unchecking a row disables Import when it was the only selection', async () => { + renderModal(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]); + await waitFor(() => { + expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2); + }); + // Check then uncheck + fireEvent.click(screen.getAllByTestId('c2-history-row')[0]); + fireEvent.click(screen.getAllByTestId('c2-history-row')[0]); + expect(screen.getByTestId('c2-import-submit-btn')).toBeDisabled(); + }); +}); + +describe('ImportC2HistoryModal — pagination', () => { + it('shows Prev/Next buttons when tasks exceed page_size', async () => { + // 30 tasks, page_size 25 → 2 pages + const manyTasks = Array.from({ length: 25 }, (_, i) => ({ + display_id: i + 1, + command: `cmd${i + 1}`, + status: 'completed', + completed: true, + completed_at: '2026-06-10T10:00:00', + created_at: '2026-06-10T10:00:00', + })); + mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, { + tasks: manyTasks, + total: 30, + page: 1, + page_size: 25, + }); + + renderModal(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]); + await waitFor(() => { + expect(screen.getByTestId('c2-history-prev')).toBeInTheDocument(); + expect(screen.getByTestId('c2-history-next')).toBeInTheDocument(); + }); + }); + + it('Prev is disabled on page 1', async () => { + mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, { + tasks: HISTORY_TASKS, + total: 50, + page: 1, + page_size: 25, + }); + + renderModal(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]); + await waitFor(() => { + expect(screen.getByTestId('c2-history-prev')).toBeDisabled(); + }); + expect(screen.getByTestId('c2-history-next')).not.toBeDisabled(); + }); +}); + +describe('ImportC2HistoryModal — submit payload', () => { + beforeEach(() => { + mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, { + tasks: HISTORY_TASKS, + total: 2, + page: 1, + page_size: 25, + }); + }); + + it('sends correct callback_display_id and task_display_ids on import', async () => { + mock.onPost('/simulations/7/c2/import').reply(200, { imported: 2, skipped: 0 }); + const { onClose } = renderModal(); + + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]); + await waitFor(() => { + expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2); + }); + // Select both tasks + fireEvent.click(screen.getAllByTestId('c2-history-row')[0]); + fireEvent.click(screen.getAllByTestId('c2-history-row')[1]); + + fireEvent.click(screen.getByTestId('c2-import-submit-btn')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + + 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(1); + expect(body.task_display_ids).toContain(10); + expect(body.task_display_ids).toContain(11); + }); +}); + +describe('ImportC2HistoryModal — toast wording', () => { + beforeEach(() => { + mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, { + tasks: [HISTORY_TASKS[0]], + total: 1, + page: 1, + page_size: 25, + }); + }); + + it('shows "Imported N task(s)" when skipped is 0', async () => { + mock.onPost('/simulations/7/c2/import').reply(200, { imported: 1, skipped: 0 }); + renderModal(); + + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]); + await waitFor(() => { + expect(screen.getAllByTestId('c2-history-row')).toHaveLength(1); + }); + fireEvent.click(screen.getAllByTestId('c2-history-row')[0]); + fireEvent.click(screen.getByTestId('c2-import-submit-btn')); + + await waitFor(() => { + expect(screen.getByText('Imported 1 task(s)')).toBeInTheDocument(); + }); + }); + + it('shows skipped count when skipped > 0', async () => { + mock.onPost('/simulations/7/c2/import').reply(200, { imported: 0, skipped: 1 }); + renderModal(); + + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]); + await waitFor(() => { + expect(screen.getAllByTestId('c2-history-row')).toHaveLength(1); + }); + fireEvent.click(screen.getAllByTestId('c2-history-row')[0]); + fireEvent.click(screen.getByTestId('c2-import-submit-btn')); + + await waitFor(() => { + expect( + screen.getByText('Imported 0 task(s), 1 already attached'), + ).toBeInTheDocument(); + }); + }); + + it('shows inline error and keeps modal open on import failure', async () => { + mock.onPost('/simulations/7/c2/import').reply(500, { error: 'Import failed' }); + const { onClose } = renderModal(); + + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]); + await waitFor(() => { + expect(screen.getAllByTestId('c2-history-row')).toHaveLength(1); + }); + fireEvent.click(screen.getAllByTestId('c2-history-row')[0]); + fireEvent.click(screen.getByTestId('c2-import-submit-btn')); + + await waitFor(() => { + expect(screen.getByText('Import failed')).toBeInTheDocument(); + }); + expect(onClose).not.toHaveBeenCalled(); + }); +}); + +describe('ImportC2HistoryModal — Cancel button', () => { + it('Cancel button calls onClose', async () => { + const { onClose } = renderModal(); + await waitFor(() => { + expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); + }); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onClose).toHaveBeenCalled(); + }); +});