feat(c2): integrate Mythic command and control (sprint 8) #11
@@ -1,11 +1,15 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
import type {
|
import type {
|
||||||
|
C2CallbackHistoryResponse,
|
||||||
C2Config,
|
C2Config,
|
||||||
C2ConfigInput,
|
C2ConfigInput,
|
||||||
C2TestResult,
|
C2TestResult,
|
||||||
C2CallbacksResponse,
|
C2CallbacksResponse,
|
||||||
C2ExecuteInput,
|
C2ExecuteInput,
|
||||||
C2ExecuteResponse,
|
C2ExecuteResponse,
|
||||||
|
C2ImportInput,
|
||||||
|
C2ImportResponse,
|
||||||
|
C2TasksResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export async function getC2Config(engagementId: number): Promise<C2Config | null> {
|
export async function getC2Config(engagementId: number): Promise<C2Config | null> {
|
||||||
@@ -58,3 +62,33 @@ export async function executeC2(
|
|||||||
);
|
);
|
||||||
return data;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -187,7 +187,8 @@ export interface C2CallbacksResponse {
|
|||||||
callbacks: C2Callback[];
|
callbacks: C2Callback[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface C2Task {
|
// Thin shape returned by the execute endpoint
|
||||||
|
export interface C2ExecuteTask {
|
||||||
id: number;
|
id: number;
|
||||||
mythic_task_display_id: number;
|
mythic_task_display_id: number;
|
||||||
command: string;
|
command: string;
|
||||||
@@ -201,5 +202,52 @@ export interface C2ExecuteInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface C2ExecuteResponse {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
87
frontend/src/components/C2CallbackPicker.tsx
Normal file
87
frontend/src/components/C2CallbackPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
frontend/src/components/C2TaskStatusBadge.tsx
Normal file
27
frontend/src/components/C2TaskStatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
frontend/src/components/C2TasksPanel.tsx
Normal file
126
frontend/src/components/C2TasksPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { extractApiError } from '@/api/client';
|
import { extractApiError } from '@/api/client';
|
||||||
import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2';
|
import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2';
|
||||||
import type { C2Callback } from '@/api/types';
|
import { C2CallbackPicker } from './C2CallbackPicker';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
||||||
interface ExecuteViaC2ModalProps {
|
interface ExecuteViaC2ModalProps {
|
||||||
@@ -11,11 +11,6 @@ interface ExecuteViaC2ModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCheckin(ts: string): string {
|
|
||||||
// Show ISO timestamp as-is — it's a data cell (font-mono)
|
|
||||||
return ts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExecuteViaC2Modal({
|
export function ExecuteViaC2Modal({
|
||||||
simulationId,
|
simulationId,
|
||||||
engagementId,
|
engagementId,
|
||||||
@@ -31,7 +26,7 @@ export function ExecuteViaC2Modal({
|
|||||||
const [commands, setCommands] = useState(initialCommands);
|
const [commands, setCommands] = useState(initialCommands);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
const callbacks: C2Callback[] = callbacksQuery.data?.callbacks ?? [];
|
const callbacks = callbacksQuery.data?.callbacks ?? [];
|
||||||
|
|
||||||
const commandLines = commands
|
const commandLines = commands
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -70,70 +65,18 @@ export function ExecuteViaC2Modal({
|
|||||||
Execute via C2
|
Execute via C2
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Callback table */}
|
{/* Callback picker */}
|
||||||
<div className="flex flex-col gap-xs">
|
<div className="flex flex-col gap-xs">
|
||||||
<span className="text-[14px] font-medium text-ink">Select callback</span>
|
<span className="text-[14px] font-medium text-ink">Select callback</span>
|
||||||
|
<C2CallbackPicker
|
||||||
{callbacksQuery.isLoading && (
|
callbacks={callbacks}
|
||||||
<p className="text-[14px] text-graphite">Loading callbacks…</p>
|
isLoading={callbacksQuery.isLoading}
|
||||||
)}
|
isError={callbacksQuery.isError}
|
||||||
|
error={callbacksQuery.error}
|
||||||
{callbacksQuery.isError && (
|
selectedId={selectedId}
|
||||||
<p className="text-[14px] text-bloom-deep">
|
onSelect={setSelectedId}
|
||||||
Could not load callbacks: {extractApiError(callbacksQuery.error, 'Unknown error')}
|
rowTestId="c2-callback-row"
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Commands */}
|
{/* Commands */}
|
||||||
|
|||||||
252
frontend/src/components/ImportC2HistoryModal.tsx
Normal file
252
frontend/src/components/ImportC2HistoryModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,11 +3,19 @@ import {
|
|||||||
deleteC2Config,
|
deleteC2Config,
|
||||||
executeC2,
|
executeC2,
|
||||||
getC2Config,
|
getC2Config,
|
||||||
|
getC2Tasks,
|
||||||
|
importC2,
|
||||||
|
listCallbackHistory,
|
||||||
listCallbacks,
|
listCallbacks,
|
||||||
putC2Config,
|
putC2Config,
|
||||||
testC2Config,
|
testC2Config,
|
||||||
} from '@/api/c2';
|
} from '@/api/c2';
|
||||||
import type { C2ConfigInput, C2ExecuteInput } from '@/api/types';
|
import type {
|
||||||
|
C2ConfigInput,
|
||||||
|
C2ExecuteInput,
|
||||||
|
C2ImportInput,
|
||||||
|
C2TasksResponse,
|
||||||
|
} from '@/api/types';
|
||||||
|
|
||||||
function c2ConfigKey(engagementId: number) {
|
function c2ConfigKey(engagementId: number) {
|
||||||
return ['c2-config', engagementId] as const;
|
return ['c2-config', engagementId] as const;
|
||||||
@@ -17,6 +25,14 @@ function c2CallbacksKey(engagementId: number) {
|
|||||||
return ['c2-callbacks', engagementId] as const;
|
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) {
|
function simulationKey(id: number) {
|
||||||
return ['simulations', id] as const;
|
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) {
|
export function useExecuteC2(simulationId: number, engagementId: number) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -76,6 +108,45 @@ export function useExecuteC2(simulationId: number, engagementId: number) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: simulationKey(simulationId) });
|
qc.invalidateQueries({ queryKey: simulationKey(simulationId) });
|
||||||
qc.invalidateQueries({ queryKey: ['engagements', engagementId, 'simulations'] });
|
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) });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
useTransitionSimulation,
|
useTransitionSimulation,
|
||||||
useUpdateSimulation,
|
useUpdateSimulation,
|
||||||
} from '@/hooks/useSimulations';
|
} from '@/hooks/useSimulations';
|
||||||
import { useC2Config } from '@/hooks/useC2';
|
import { useC2Config, useC2Tasks } from '@/hooks/useC2';
|
||||||
import { FormField, TextArea, TextInput } from '@/components/FormField';
|
import { FormField, TextArea, TextInput } from '@/components/FormField';
|
||||||
import { LoadingState } from '@/components/LoadingState';
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
import { ErrorState } from '@/components/ErrorState';
|
import { ErrorState } from '@/components/ErrorState';
|
||||||
@@ -20,6 +20,8 @@ import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
|
|||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
|
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
|
||||||
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
|
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
|
||||||
|
import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
|
||||||
|
import { C2TasksPanel } from '@/components/C2TasksPanel';
|
||||||
|
|
||||||
interface RedteamFormState {
|
interface RedteamFormState {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -70,6 +72,13 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
);
|
);
|
||||||
const hasC2Config = c2ConfigQuery.data !== null && c2ConfigQuery.data !== undefined;
|
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 detail = useSimulation(isNew ? undefined : simulationId);
|
||||||
const createMutation = useCreateSimulation(engagementId ?? 0);
|
const createMutation = useCreateSimulation(engagementId ?? 0);
|
||||||
const updateMutation = useUpdateSimulation(simulationId ?? 0, 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 [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [showC2Modal, setShowC2Modal] = useState(false);
|
const [showC2Modal, setShowC2Modal] = useState(false);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isNew && detail.data) {
|
if (!isNew && detail.data) {
|
||||||
@@ -307,8 +317,10 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
</div>
|
</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">
|
<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 */}
|
{/* Red Team card */}
|
||||||
<form
|
<form
|
||||||
id="rt-form"
|
id="rt-form"
|
||||||
@@ -394,7 +406,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{!isDone && canEditRT && hasC2Config && (
|
{!isDone && canEditRT && hasC2Config && (
|
||||||
<div className="pt-xs">
|
<div className="pt-xs flex items-center gap-md flex-wrap">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="c2-execute-btn"
|
data-testid="c2-execute-btn"
|
||||||
@@ -403,10 +415,24 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
>
|
>
|
||||||
Execute via C2
|
Execute via C2
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="c2-import-trigger-btn"
|
||||||
|
className="btn-outline"
|
||||||
|
onClick={() => setShowImportModal(true)}
|
||||||
|
>
|
||||||
|
Import C2 history
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* C2 tasks panel — under RT card, same left column */}
|
||||||
|
{showTasksPanel && simulationId && (
|
||||||
|
<C2TasksPanel simulationId={simulationId} />
|
||||||
|
)}
|
||||||
|
</div>{/* end left column */}
|
||||||
|
|
||||||
{/* SOC card */}
|
{/* SOC card */}
|
||||||
<form
|
<form
|
||||||
id="soc-form"
|
id="soc-form"
|
||||||
@@ -543,6 +569,14 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
onClose={() => setShowC2Modal(false)}
|
onClose={() => setShowC2Modal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showImportModal && simulationId && typeof engagementId === 'number' && (
|
||||||
|
<ImportC2HistoryModal
|
||||||
|
simulationId={simulationId}
|
||||||
|
engagementId={engagementId}
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,3 +332,104 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
|||||||
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
deleteC2Config,
|
deleteC2Config,
|
||||||
executeC2,
|
executeC2,
|
||||||
getC2Config,
|
getC2Config,
|
||||||
|
getC2Tasks,
|
||||||
|
importC2,
|
||||||
|
listCallbackHistory,
|
||||||
listCallbacks,
|
listCallbacks,
|
||||||
putC2Config,
|
putC2Config,
|
||||||
testC2Config,
|
testC2Config,
|
||||||
@@ -134,3 +137,63 @@ describe('executeC2', () => {
|
|||||||
expect(body.commands).toEqual(['whoami']);
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
196
frontend/tests/components/C2TasksPanel.test.tsx
Normal file
196
frontend/tests/components/C2TasksPanel.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
380
frontend/tests/components/ImportC2HistoryModal.test.tsx
Normal file
380
frontend/tests/components/ImportC2HistoryModal.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user