feat(frontend): c2 tasks panel + history import (sprint 8 phase 2)

- Add getC2Tasks / listCallbackHistory / importC2 API functions + types
- useC2Tasks with 2500ms polling (stops when all tasks completed)
- useC2CallbackHistory, useImportC2 hooks
- C2TaskStatusBadge, C2TasksPanel (expandable output rows, polling indicator)
- C2CallbackPicker extracted as shared component (reused in both modals)
- ImportC2HistoryModal: 2-step callback picker → paginated history table
- SimulationFormPage: RT card + tasks panel share left grid column; Import C2 history button
- 37 new tests (api/c2, C2TasksPanel, ImportC2HistoryModal, SimulationFormPage panel visibility)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-06-10 20:11:12 +02:00
parent 8f23f59601
commit 7ff153905b
13 changed files with 1437 additions and 75 deletions

View File

@@ -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<C2Config | null> {
@@ -58,3 +62,33 @@ export async function executeC2(
);
return data;
}
export async function getC2Tasks(simulationId: number): Promise<C2TasksResponse> {
const { data } = await apiClient.get<C2TasksResponse>(
`/simulations/${simulationId}/c2/tasks`,
);
return data;
}
export async function listCallbackHistory(
engagementId: number,
callbackDisplayId: number,
params: { page: number; pageSize: number },
): Promise<C2CallbackHistoryResponse> {
const { data } = await apiClient.get<C2CallbackHistoryResponse>(
`/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<C2ImportResponse> {
const { data } = await apiClient.post<C2ImportResponse>(
`/simulations/${simulationId}/c2/import`,
input,
);
return data;
}

View File

@@ -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;
}

View File

@@ -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 <p className="text-[14px] text-graphite">Loading callbacks</p>;
}
if (isError) {
return (
<p className="text-[14px] text-bloom-deep">
Could not load callbacks: {extractApiError(error, 'Unknown error')}
</p>
);
}
if (callbacks.length === 0) {
return <p className="text-[14px] text-graphite">No callbacks available.</p>;
}
return (
<div className="border border-hairline overflow-x-auto">
<table className="w-full text-[14px]">
<thead>
<tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm text-left font-medium text-ink">Display ID</th>
<th className="px-md py-sm text-left font-medium text-ink">Active</th>
<th className="px-md py-sm text-left font-medium text-ink">Host</th>
<th className="px-md py-sm text-left font-medium text-ink">User</th>
<th className="px-md py-sm text-left font-medium text-ink">Domain</th>
<th className="px-md py-sm text-left font-medium text-ink">Last check-in</th>
</tr>
</thead>
<tbody>
{callbacks.map((cb) => {
const isSelected = selectedId === cb.display_id;
return (
<tr
key={cb.display_id}
data-testid={rowTestId}
onClick={() => onSelect(cb.display_id)}
className={`cursor-pointer border-b border-hairline ${
isSelected ? 'bg-primary-soft' : 'hover:bg-cloud'
}`}
>
<td className="px-md py-sm font-mono">{cb.display_id}</td>
<td className="px-md py-sm">
<span
className={`inline-flex items-center rounded-pill px-3 py-[4px] text-[12px] leading-[1.3] font-bold ${
cb.active
? 'bg-primary-soft text-primary-deep'
: 'bg-cloud text-graphite border border-hairline'
}`}
>
{cb.active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-md py-sm font-mono">{cb.host}</td>
<td className="px-md py-sm font-mono">{cb.user}</td>
<td className="px-md py-sm font-mono">{cb.domain}</td>
<td className="px-md py-sm font-mono">{cb.last_checkin}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -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 (
<span
className={`inline-flex items-center rounded-pill px-3 py-[4px] text-[12px] leading-[1.3] font-bold ${badgeClass(status)}`}
>
{status}
</span>
);
}

View File

@@ -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<Set<number>>(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 (
<div
data-testid="c2-tasks-panel"
className="card-product flex flex-col gap-md"
>
<div className="flex items-center justify-between">
<h3 className="text-[16px] font-medium text-ink">C2 Tasks</h3>
{isRefreshing && (
<span
data-testid="c2-task-refresh-indicator"
className="text-[12px] text-graphite"
>
Refreshing
</span>
)}
</div>
{tasks.length === 0 ? (
<div className="border border-hairline rounded-none px-md py-md">
<p className="text-[14px] text-graphite">
No C2 tasks yet. Use Execute via C2 to launch commands.
</p>
</div>
) : (
<div className="border border-hairline overflow-x-auto">
<table className="w-full text-[14px]">
<thead>
<tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm text-left font-medium text-ink w-8" aria-label="Expand" />
<th className="px-md py-sm text-left font-medium text-ink">Task</th>
<th className="px-md py-sm text-left font-medium text-ink">Command</th>
<th className="px-md py-sm text-left font-medium text-ink">Source</th>
<th className="px-md py-sm text-left font-medium text-ink">Status</th>
<th className="px-md py-sm text-left font-medium text-ink">Completed at</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => {
const isExpanded = expandedIds.has(task.id);
const canExpand = task.completed && Boolean(task.output);
return (
<Fragment key={task.id}>
<tr
data-testid="c2-task-row"
onClick={() => toggleExpand(task.id, task.completed)}
className={`border-b border-hairline ${canExpand ? 'cursor-pointer hover:bg-cloud' : ''}`}
>
<td className="px-md py-sm text-graphite">
{canExpand ? (
isExpanded ? (
<ChevronDown size={14} aria-hidden />
) : (
<ChevronRight size={14} aria-hidden />
)
) : null}
</td>
<td className="px-md py-sm font-mono">#{task.mythic_task_display_id}</td>
<td
className="px-md py-sm font-mono max-w-[200px] truncate"
title={task.command}
>
{task.command}
</td>
<td className="px-md py-sm">
<span className="inline-flex items-center rounded-pill px-3 py-[4px] text-[12px] leading-[1.3] font-bold bg-cloud text-graphite border border-hairline">
{task.mapping_applied ? 'MIMIC' : 'IMPORT'}
</span>
</td>
<td className="px-md py-sm">
<C2TaskStatusBadge status={task.status} />
</td>
<td className="px-md py-sm font-mono text-graphite">
{task.completed_at ?? '—'}
</td>
</tr>
{isExpanded && task.output && (
<tr className="border-b border-hairline bg-cloud">
<td colSpan={6} className="px-md py-sm">
<pre
data-testid="c2-task-output"
className="font-mono text-[12px] whitespace-pre-wrap text-ink"
>
{task.output}
</pre>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -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<string | null>(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
</h2>
{/* Callback table */}
{/* Callback picker */}
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span>
{callbacksQuery.isLoading && (
<p className="text-[14px] text-graphite">Loading callbacks</p>
)}
{callbacksQuery.isError && (
<p className="text-[14px] text-bloom-deep">
Could not load callbacks: {extractApiError(callbacksQuery.error, 'Unknown error')}
</p>
)}
{!callbacksQuery.isLoading && callbacks.length === 0 && !callbacksQuery.isError && (
<p className="text-[14px] text-graphite">No callbacks available.</p>
)}
{callbacks.length > 0 && (
<div className="border border-hairline overflow-x-auto">
<table className="w-full text-[14px]">
<thead>
<tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm text-left font-medium text-ink">Display ID</th>
<th className="px-md py-sm text-left font-medium text-ink">Active</th>
<th className="px-md py-sm text-left font-medium text-ink">Host</th>
<th className="px-md py-sm text-left font-medium text-ink">User</th>
<th className="px-md py-sm text-left font-medium text-ink">Domain</th>
<th className="px-md py-sm text-left font-medium text-ink">Last check-in</th>
</tr>
</thead>
<tbody>
{callbacks.map((cb) => {
const isSelected = selectedId === cb.display_id;
return (
<tr
key={cb.display_id}
data-testid="c2-callback-row"
onClick={() => setSelectedId(cb.display_id)}
className={`cursor-pointer border-b border-hairline ${
isSelected
? 'bg-primary-soft'
: 'hover:bg-cloud'
}`}
>
<td className="px-md py-sm font-mono">{cb.display_id}</td>
<td className="px-md py-sm">
<span
className={`badge-pill-${cb.active ? 'active' : 'inactive'}`}
>
{cb.active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-md py-sm font-mono">{cb.host}</td>
<td className="px-md py-sm font-mono">{cb.user}</td>
<td className="px-md py-sm font-mono">{cb.domain}</td>
<td className="px-md py-sm font-mono">{formatCheckin(cb.last_checkin)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<C2CallbackPicker
callbacks={callbacks}
isLoading={callbacksQuery.isLoading}
isError={callbacksQuery.isError}
error={callbacksQuery.error}
selectedId={selectedId}
onSelect={setSelectedId}
rowTestId="c2-callback-row"
/>
</div>
{/* Commands */}

View File

@@ -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<number | null>(null);
const [page, setPage] = useState(1);
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
const [submitError, setSubmitError] = useState<string | null>(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 (
<div
role="dialog"
aria-modal="true"
aria-labelledby="c2-import-modal-title"
data-testid="c2-import-modal"
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" />
<div className="relative card-product w-full max-w-4xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
<h2 id="c2-import-modal-title" className="text-[20px] font-medium text-ink">
Import C2 history
</h2>
{/* Step 1: callback picker */}
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span>
<C2CallbackPicker
callbacks={callbacks}
isLoading={callbacksQuery.isLoading}
isError={callbacksQuery.isError}
error={callbacksQuery.error}
selectedId={selectedCallbackId}
onSelect={handleCallbackSelect}
rowTestId="c2-import-callback-row"
/>
</div>
{/* Step 2: history table (shown once a callback is selected) */}
{selectedCallbackId !== null && (
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">
Task history{' '}
{total > 0 && (
<span className="text-graphite font-normal">({total} total)</span>
)}
</span>
{historyQuery.isLoading && (
<p className="text-[14px] text-graphite">Loading history</p>
)}
{historyQuery.isError && (
<p className="text-[14px] text-bloom-deep">
{extractApiError(historyQuery.error, 'Could not load history')}
</p>
)}
{!historyQuery.isLoading && historyTasks.length === 0 && !historyQuery.isError && (
<p className="text-[14px] text-graphite">No task history for this callback.</p>
)}
{historyTasks.length > 0 && (
<>
<div className="border border-hairline overflow-x-auto">
<table className="w-full text-[14px]">
<thead>
<tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm w-8" aria-label="Select" />
<th className="px-md py-sm text-left font-medium text-ink">#</th>
<th className="px-md py-sm text-left font-medium text-ink">Command</th>
<th className="px-md py-sm text-left font-medium text-ink">Status</th>
<th className="px-md py-sm text-left font-medium text-ink">Completed</th>
<th className="px-md py-sm text-left font-medium text-ink">Timestamp</th>
</tr>
</thead>
<tbody>
{historyTasks.map((task) => (
<tr
key={task.display_id}
data-testid="c2-history-row"
onClick={() => toggleCheck(task.display_id)}
className="cursor-pointer border-b border-hairline hover:bg-cloud"
>
<td className="px-md py-sm">
<input
type="checkbox"
data-testid="c2-history-row-checkbox"
checked={checkedIds.has(task.display_id)}
onChange={() => toggleCheck(task.display_id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 accent-primary"
/>
</td>
<td className="px-md py-sm font-mono">{task.display_id}</td>
<td
className="px-md py-sm font-mono max-w-[200px] truncate"
title={task.command}
>
{task.command}
</td>
<td className="px-md py-sm">
<C2TaskStatusBadge status={task.status} />
</td>
<td className="px-md py-sm text-[14px]">
{task.completed ? 'Yes' : 'No'}
</td>
<td className="px-md py-sm font-mono text-graphite">
{task.created_at}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center gap-md text-[14px] text-graphite">
<button
type="button"
data-testid="c2-history-prev"
className="btn-outline-ink"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Prev
</button>
<span>
Page {page} of {totalPages}
</span>
<button
type="button"
data-testid="c2-history-next"
className="btn-outline-ink"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
</button>
{checkedIds.size > 0 && (
<span className="ml-auto text-ink">
{checkedIds.size} selected
</span>
)}
</div>
</>
)}
</div>
)}
{submitError && (
<p role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</p>
)}
{/* Footer */}
<div className="flex items-center gap-md pt-xs">
<button
type="button"
data-testid="c2-import-submit-btn"
className="btn-primary"
onClick={onImport}
disabled={!canImport || importMutation.isPending}
>
{importMutation.isPending ? 'Importing…' : 'Import selected'}
</button>
<button
type="button"
className="btn-outline-ink"
onClick={onClose}
disabled={importMutation.isPending}
>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@@ -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) });
},
});
}

View File

@@ -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<string | null>(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 {
</div>
)}
{/* 2-column grid: RT left, SOC right. Stacks vertically below lg. */}
{/* 2-column grid: RT+tasks left, SOC right. Stacks vertically below lg. */}
<div className="grid gap-xl lg:grid-cols-2 items-start">
{/* Left column: RT card + C2 tasks panel */}
<div className="flex flex-col gap-xl">
{/* Red Team card */}
<form
id="rt-form"
@@ -394,7 +406,7 @@ export function SimulationFormPage(): JSX.Element {
</FormField>
{!isDone && canEditRT && hasC2Config && (
<div className="pt-xs">
<div className="pt-xs flex items-center gap-md flex-wrap">
<button
type="button"
data-testid="c2-execute-btn"
@@ -403,10 +415,24 @@ export function SimulationFormPage(): JSX.Element {
>
Execute via C2
</button>
<button
type="button"
data-testid="c2-import-trigger-btn"
className="btn-outline"
onClick={() => setShowImportModal(true)}
>
Import C2 history
</button>
</div>
)}
</form>
{/* C2 tasks panel — under RT card, same left column */}
{showTasksPanel && simulationId && (
<C2TasksPanel simulationId={simulationId} />
)}
</div>{/* end left column */}
{/* SOC card */}
<form
id="soc-form"
@@ -543,6 +569,14 @@ export function SimulationFormPage(): JSX.Element {
onClose={() => setShowC2Modal(false)}
/>
)}
{showImportModal && simulationId && typeof engagementId === 'number' && (
<ImportC2HistoryModal
simulationId={simulationId}
engagementId={engagementId}
onClose={() => setShowImportModal(false)}
/>
)}
</div>
);
}

View File

@@ -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(<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,
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();
});
});
});

View File

@@ -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]);
});
});

View File

@@ -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(<C2TasksPanel simulationId={7} />);
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(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(2);
});
});
it('displays task command and mythic display id', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
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(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('MIMIC')).toBeInTheDocument();
});
});
it('shows IMPORT source badge for mapping_applied=false', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('IMPORT')).toBeInTheDocument();
});
});
it('shows completed_at timestamp for completed task', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('2026-06-10T10:00:05')).toBeInTheDocument();
});
});
it('shows em dash for null completed_at', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
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(<C2TasksPanel simulationId={7} />);
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(<C2TasksPanel simulationId={7} />);
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(<C2TasksPanel simulationId={7} />);
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(<C2TasksPanel simulationId={7} />);
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(<C2TasksPanel simulationId={7} />);
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(<C2TasksPanel simulationId={7} />);
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);
});
});

View File

@@ -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(
<>
<ImportC2HistoryModal
simulationId={7}
engagementId={42}
onClose={onClose}
/>
<ToastViewport />
</>,
);
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();
});
});