feat(frontend): c2 config card + execute modal (sprint 8 phase 1)
- frontend/src/api/c2.ts: 6 typed API calls (getC2Config, putC2Config, deleteC2Config, testC2Config, listCallbacks, executeC2) following the frozen M1+M2 backend contracts - frontend/src/api/types.ts: C2Config, C2ConfigInput, C2TestResult, C2Callback, C2CallbacksResponse, C2Task, C2ExecuteInput, C2ExecuteResponse - frontend/src/hooks/useC2.ts: useC2Config, useUpdateC2Config, useDeleteC2Config, useTestC2Config, useC2Callbacks, useExecuteC2 - frontend/src/components/C2ConfigCard.tsx: engagement-scoped C2 config card (url + write-only token + verify-tls + save/delete/test-connection), 503 disabled state, ConfirmDialog on delete - frontend/src/components/ExecuteViaC2Modal.tsx: callback picker table (mono data cells), commands textarea pre-filled from rt.commands, Launch disabled until row selected + non-empty commands - frontend/src/pages/EngagementFormPage.tsx: embed C2ConfigCard in edit mode only, admin+redteam only (canEditEngagements gate) - frontend/src/pages/SimulationFormPage.tsx: Execute via C2 button in RT card, visible only when !isDone && canEditRT && hasC2Config; opens modal - Tests: 33 new tests across api/c2, components/C2ConfigCard, components/ExecuteViaC2Modal, EngagementFormPage, SimulationFormPage (172 total, 139 baseline + 33 new, all passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
60
frontend/src/api/c2.ts
Normal file
60
frontend/src/api/c2.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
C2Config,
|
||||
C2ConfigInput,
|
||||
C2TestResult,
|
||||
C2CallbacksResponse,
|
||||
C2ExecuteInput,
|
||||
C2ExecuteResponse,
|
||||
} from './types';
|
||||
|
||||
export async function getC2Config(engagementId: number): Promise<C2Config | null> {
|
||||
try {
|
||||
const { data } = await apiClient.get<C2Config>(`/engagements/${engagementId}/c2-config`);
|
||||
return data;
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { status?: number } };
|
||||
if (e?.response?.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function putC2Config(
|
||||
engagementId: number,
|
||||
input: C2ConfigInput,
|
||||
): Promise<C2Config> {
|
||||
const { data } = await apiClient.put<C2Config>(
|
||||
`/engagements/${engagementId}/c2-config`,
|
||||
input,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteC2Config(engagementId: number): Promise<void> {
|
||||
await apiClient.delete(`/engagements/${engagementId}/c2-config`);
|
||||
}
|
||||
|
||||
export async function testC2Config(engagementId: number): Promise<C2TestResult> {
|
||||
const { data } = await apiClient.post<C2TestResult>(
|
||||
`/engagements/${engagementId}/c2-config/test`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listCallbacks(engagementId: number): Promise<C2CallbacksResponse> {
|
||||
const { data } = await apiClient.get<C2CallbacksResponse>(
|
||||
`/engagements/${engagementId}/c2/callbacks`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function executeC2(
|
||||
simulationId: number,
|
||||
input: C2ExecuteInput,
|
||||
): Promise<C2ExecuteResponse> {
|
||||
const { data } = await apiClient.post<C2ExecuteResponse>(
|
||||
`/simulations/${simulationId}/c2/execute`,
|
||||
input,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -154,3 +154,52 @@ export interface SimulationPatchInput {
|
||||
soc_comment?: string | null;
|
||||
incident_number?: string | null;
|
||||
}
|
||||
|
||||
// C2 types
|
||||
|
||||
export interface C2Config {
|
||||
has_token: boolean;
|
||||
url: string;
|
||||
verify_tls: boolean;
|
||||
}
|
||||
|
||||
export interface C2ConfigInput {
|
||||
url: string;
|
||||
api_token?: string;
|
||||
verify_tls: boolean;
|
||||
}
|
||||
|
||||
export interface C2TestResult {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface C2Callback {
|
||||
display_id: number;
|
||||
active: boolean;
|
||||
host: string;
|
||||
user: string;
|
||||
domain: string;
|
||||
last_checkin: string;
|
||||
}
|
||||
|
||||
export interface C2CallbacksResponse {
|
||||
callbacks: C2Callback[];
|
||||
}
|
||||
|
||||
export interface C2Task {
|
||||
id: number;
|
||||
mythic_task_display_id: number;
|
||||
command: string;
|
||||
status: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface C2ExecuteInput {
|
||||
callback_display_id: number;
|
||||
commands: string[];
|
||||
}
|
||||
|
||||
export interface C2ExecuteResponse {
|
||||
tasks: C2Task[];
|
||||
}
|
||||
|
||||
240
frontend/src/components/C2ConfigCard.tsx
Normal file
240
frontend/src/components/C2ConfigCard.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useC2Config, useDeleteC2Config, useTestC2Config, useUpdateC2Config } from '@/hooks/useC2';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { FormField, TextInput } from './FormField';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
interface C2ConfigCardProps {
|
||||
engagementId: number;
|
||||
}
|
||||
|
||||
export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
|
||||
const { push } = useToast();
|
||||
|
||||
const configQuery = useC2Config(engagementId);
|
||||
const updateMutation = useUpdateC2Config(engagementId);
|
||||
const deleteMutation = useDeleteC2Config(engagementId);
|
||||
const testMutation = useTestC2Config(engagementId);
|
||||
|
||||
const config = configQuery.data;
|
||||
const is503 = configQuery.error
|
||||
? (configQuery.error as { response?: { status?: number } })?.response?.status === 503
|
||||
: false;
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
const [verifyTls, setVerifyTls] = useState(true);
|
||||
const [replaceToken, setReplaceToken] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
// Sync URL and verifyTls from loaded config (but not token — write-only at API level)
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setUrl(config.url);
|
||||
setVerifyTls(config.verify_tls);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const disabled = is503 || configQuery.isLoading;
|
||||
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (is503) return;
|
||||
setTestResult(null);
|
||||
|
||||
const input: { url: string; verify_tls: boolean; api_token?: string } = {
|
||||
url: url.trim(),
|
||||
verify_tls: verifyTls,
|
||||
};
|
||||
// Send token only if: no existing config, OR user explicitly chose to replace
|
||||
if (!config?.has_token || replaceToken) {
|
||||
if (token.trim()) input.api_token = token.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync(input);
|
||||
push('C2 configuration saved', 'success');
|
||||
setToken('');
|
||||
setReplaceToken(false);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not save C2 configuration'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
setShowDeleteConfirm(false);
|
||||
setTestResult(null);
|
||||
try {
|
||||
await deleteMutation.mutateAsync();
|
||||
push('C2 configuration removed', 'success');
|
||||
setUrl('');
|
||||
setToken('');
|
||||
setVerifyTls(true);
|
||||
setReplaceToken(false);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not remove C2 configuration'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onTest = async () => {
|
||||
setTestResult(null);
|
||||
try {
|
||||
const result = await testMutation.mutateAsync();
|
||||
setTestResult({
|
||||
ok: result.ok,
|
||||
message: result.ok ? 'Connected' : (result.error ?? 'Connection failed'),
|
||||
});
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, message: extractApiError(err, 'Test failed') });
|
||||
}
|
||||
};
|
||||
|
||||
const submitting = updateMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="c2-config-card"
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
<h2 className="text-[20px] font-medium text-ink">C2 configuration</h2>
|
||||
|
||||
{is503 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
|
||||
>
|
||||
C2 features are disabled (server has no encryption key configured).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configQuery.isLoading ? (
|
||||
<p className="text-[14px] text-graphite">Loading…</p>
|
||||
) : (
|
||||
<form onSubmit={onSave} noValidate className="flex flex-col gap-md">
|
||||
<FormField
|
||||
label="URL"
|
||||
htmlFor="c2-url"
|
||||
hint="HTTPS required (e.g. https://mythic.lab:7443)"
|
||||
>
|
||||
<TextInput
|
||||
id="c2-url"
|
||||
data-testid="c2-url-input"
|
||||
type="url"
|
||||
name="url"
|
||||
placeholder="https://mythic.lab:7443"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="API token" htmlFor="c2-token">
|
||||
{config?.has_token && !replaceToken ? (
|
||||
<div className="flex items-center gap-md">
|
||||
<TextInput
|
||||
id="c2-token"
|
||||
data-testid="c2-token-input"
|
||||
type="password"
|
||||
name="api_token"
|
||||
placeholder="••••••••"
|
||||
value=""
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-[14px] whitespace-nowrap"
|
||||
onClick={() => setReplaceToken(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
Replace token
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<TextInput
|
||||
id="c2-token"
|
||||
data-testid="c2-token-input"
|
||||
type="password"
|
||||
name="api_token"
|
||||
placeholder={config?.has_token ? 'Enter new token' : 'API token'}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center gap-sm">
|
||||
<input
|
||||
id="c2-verify-tls"
|
||||
data-testid="c2-verify-tls"
|
||||
type="checkbox"
|
||||
checked={verifyTls}
|
||||
onChange={(e) => setVerifyTls(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4 accent-primary"
|
||||
/>
|
||||
<label htmlFor="c2-verify-tls" className="text-[14px] text-ink">
|
||||
Verify TLS certificate
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-md flex-wrap">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="c2-save-btn"
|
||||
className="btn-primary"
|
||||
disabled={disabled || submitting}
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="c2-test-btn"
|
||||
className="btn-outline"
|
||||
onClick={onTest}
|
||||
disabled={disabled || testMutation.isPending || !config}
|
||||
>
|
||||
{testMutation.isPending ? 'Testing…' : 'Test connection'}
|
||||
</button>
|
||||
|
||||
{testResult !== null && (
|
||||
<span
|
||||
className={`text-[14px] ${testResult.ok ? 'text-success' : 'text-bloom-deep'}`}
|
||||
>
|
||||
{testResult.message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{config?.has_token && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="c2-delete-btn"
|
||||
className="btn-text-link text-bloom-deep ml-auto"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={disabled || submitting}
|
||||
>
|
||||
Delete configuration
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmDialog
|
||||
title="Delete C2 configuration"
|
||||
description="This will remove the C2 configuration for this engagement. The API token will be permanently deleted."
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
destructive
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
frontend/src/components/ExecuteViaC2Modal.tsx
Normal file
186
frontend/src/components/ExecuteViaC2Modal.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState } from 'react';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2';
|
||||
import type { C2Callback } from '@/api/types';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
interface ExecuteViaC2ModalProps {
|
||||
simulationId: number;
|
||||
engagementId: number;
|
||||
initialCommands: string;
|
||||
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,
|
||||
initialCommands,
|
||||
onClose,
|
||||
}: ExecuteViaC2ModalProps): JSX.Element {
|
||||
const { push } = useToast();
|
||||
|
||||
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
|
||||
const executeMutation = useExecuteC2(simulationId, engagementId);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [commands, setCommands] = useState(initialCommands);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const callbacks: C2Callback[] = callbacksQuery.data?.callbacks ?? [];
|
||||
|
||||
const commandLines = commands
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const canLaunch = selectedId !== null && commandLines.length > 0;
|
||||
|
||||
const onLaunch = async () => {
|
||||
if (!canLaunch) return;
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const result = await executeMutation.mutateAsync({
|
||||
callback_display_id: selectedId,
|
||||
commands: commandLines,
|
||||
});
|
||||
push(`${result.tasks.length} task(s) submitted`, 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not execute via C2'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="c2-modal-title"
|
||||
data-testid="c2-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-3xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
|
||||
<h2 id="c2-modal-title" className="text-[20px] font-medium text-ink">
|
||||
Execute via C2
|
||||
</h2>
|
||||
|
||||
{/* Callback table */}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commands */}
|
||||
<div className="flex flex-col gap-xs">
|
||||
<label htmlFor="c2-commands" className="text-[14px] font-medium text-ink">
|
||||
Commands
|
||||
</label>
|
||||
<textarea
|
||||
id="c2-commands"
|
||||
data-testid="c2-commands-textarea"
|
||||
value={commands}
|
||||
onChange={(e) => setCommands(e.target.value)}
|
||||
className="text-input min-h-[112px] py-sm font-mono text-[14px]"
|
||||
placeholder="One command per line"
|
||||
/>
|
||||
<span className="text-[12px] text-graphite">
|
||||
{commandLines.length} command{commandLines.length !== 1 ? 's' : ''} — one task per line
|
||||
</span>
|
||||
</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-launch-btn"
|
||||
className="btn-primary"
|
||||
onClick={onLaunch}
|
||||
disabled={!canLaunch || executeMutation.isPending}
|
||||
>
|
||||
{executeMutation.isPending ? 'Launching…' : 'Launch'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink"
|
||||
onClick={onClose}
|
||||
disabled={executeMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
frontend/src/hooks/useC2.ts
Normal file
81
frontend/src/hooks/useC2.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
deleteC2Config,
|
||||
executeC2,
|
||||
getC2Config,
|
||||
listCallbacks,
|
||||
putC2Config,
|
||||
testC2Config,
|
||||
} from '@/api/c2';
|
||||
import type { C2ConfigInput, C2ExecuteInput } from '@/api/types';
|
||||
|
||||
function c2ConfigKey(engagementId: number) {
|
||||
return ['c2-config', engagementId] as const;
|
||||
}
|
||||
|
||||
function c2CallbacksKey(engagementId: number) {
|
||||
return ['c2-callbacks', engagementId] as const;
|
||||
}
|
||||
|
||||
function simulationKey(id: number) {
|
||||
return ['simulations', id] as const;
|
||||
}
|
||||
|
||||
export function useC2Config(engagementId: number | undefined, options?: { enabled?: boolean }) {
|
||||
const enabled =
|
||||
typeof engagementId === 'number' &&
|
||||
!Number.isNaN(engagementId) &&
|
||||
(options?.enabled !== false);
|
||||
|
||||
return useQuery({
|
||||
queryKey: engagementId ? c2ConfigKey(engagementId) : ['c2-config', 'none'],
|
||||
queryFn: () => getC2Config(engagementId as number),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateC2Config(engagementId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: C2ConfigInput) => putC2Config(engagementId, input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: c2ConfigKey(engagementId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteC2Config(engagementId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => deleteC2Config(engagementId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: c2ConfigKey(engagementId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestC2Config(engagementId: number) {
|
||||
return useMutation({
|
||||
mutationFn: () => testC2Config(engagementId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useC2Callbacks(engagementId: number | undefined, options?: { enabled?: boolean }) {
|
||||
const enabled =
|
||||
typeof engagementId === 'number' &&
|
||||
!Number.isNaN(engagementId) &&
|
||||
(options?.enabled !== false);
|
||||
|
||||
return useQuery({
|
||||
queryKey: engagementId ? c2CallbacksKey(engagementId) : ['c2-callbacks', 'none'],
|
||||
queryFn: () => listCallbacks(engagementId as number),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useExecuteC2(simulationId: number, engagementId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: C2ExecuteInput) => executeC2(simulationId, input),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: simulationKey(simulationId) });
|
||||
qc.invalidateQueries({ queryKey: ['engagements', engagementId, 'simulations'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
useEngagement,
|
||||
usePatchEngagement,
|
||||
} from '@/hooks/useEngagements';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { C2ConfigCard } from '@/components/C2ConfigCard';
|
||||
|
||||
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
|
||||
{ value: 'planned', label: 'Planned' },
|
||||
@@ -50,6 +52,7 @@ export function EngagementFormPage(): JSX.Element {
|
||||
const numericId = id ? Number(id) : undefined;
|
||||
const navigate = useNavigate();
|
||||
const { push } = useToast();
|
||||
const { canEditEngagements } = useAuth();
|
||||
|
||||
const detail = useEngagement(editing ? numericId : undefined);
|
||||
const createMutation = useCreateEngagement();
|
||||
@@ -214,6 +217,10 @@ export function EngagementFormPage(): JSX.Element {
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{editing && numericId && canEditEngagements && (
|
||||
<C2ConfigCard engagementId={numericId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
useTransitionSimulation,
|
||||
useUpdateSimulation,
|
||||
} from '@/hooks/useSimulations';
|
||||
import { useC2Config } from '@/hooks/useC2';
|
||||
import { FormField, TextArea, TextInput } from '@/components/FormField';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
|
||||
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
|
||||
|
||||
interface RedteamFormState {
|
||||
name: string;
|
||||
@@ -61,6 +63,13 @@ export function SimulationFormPage(): JSX.Element {
|
||||
const { push } = useToast();
|
||||
const { isAdmin, isRedteam, isSoc, canEditEngagements } = useAuth();
|
||||
|
||||
const canEditRT = isAdmin || isRedteam;
|
||||
const c2ConfigQuery = useC2Config(
|
||||
!isNew && typeof engagementId === 'number' ? engagementId : undefined,
|
||||
{ enabled: !isNew && canEditRT },
|
||||
);
|
||||
const hasC2Config = c2ConfigQuery.data !== null && c2ConfigQuery.data !== undefined;
|
||||
|
||||
const detail = useSimulation(isNew ? undefined : simulationId);
|
||||
const createMutation = useCreateSimulation(engagementId ?? 0);
|
||||
const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0);
|
||||
@@ -72,6 +81,7 @@ export function SimulationFormPage(): JSX.Element {
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showC2Modal, setShowC2Modal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew && detail.data) {
|
||||
@@ -109,7 +119,6 @@ export function SimulationFormPage(): JSX.Element {
|
||||
// US-18: Done = fully read-only, Reopen only
|
||||
const isDone = status === 'done';
|
||||
|
||||
const canEditRT = isAdmin || isRedteam;
|
||||
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
|
||||
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
|
||||
|
||||
@@ -383,6 +392,19 @@ export function SimulationFormPage(): JSX.Element {
|
||||
rows={5}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{!isDone && canEditRT && hasC2Config && (
|
||||
<div className="pt-xs">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="c2-execute-btn"
|
||||
className="btn-outline"
|
||||
onClick={() => setShowC2Modal(true)}
|
||||
>
|
||||
Execute via C2
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* SOC card */}
|
||||
@@ -512,6 +534,15 @@ export function SimulationFormPage(): JSX.Element {
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showC2Modal && simulationId && typeof engagementId === 'number' && (
|
||||
<ExecuteViaC2Modal
|
||||
simulationId={simulationId}
|
||||
engagementId={engagementId}
|
||||
initialCommands={rt.commands}
|
||||
onClose={() => setShowC2Modal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
108
frontend/tests/EngagementFormPage.test.tsx
Normal file
108
frontend/tests/EngagementFormPage.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { EngagementFormPage } from '@/pages/EngagementFormPage';
|
||||
import { renderWithProviders } from './utils';
|
||||
import type { Engagement } from '@/api/types';
|
||||
|
||||
const ENGAGEMENT: Engagement = {
|
||||
id: 5,
|
||||
name: 'Test Engagement',
|
||||
description: null,
|
||||
start_date: '2026-06-01',
|
||||
end_date: null,
|
||||
status: 'active',
|
||||
created_at: '2026-06-01T08:00:00',
|
||||
created_by: { id: 1, username: 'alice' },
|
||||
};
|
||||
|
||||
type MockRole = 'admin' | 'redteam' | 'soc';
|
||||
let mockRole: MockRole = 'admin';
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'alice', role: mockRole, created_at: '2026-01-01' },
|
||||
status: 'authenticated',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAdmin: mockRole === 'admin',
|
||||
isRedteam: mockRole === 'redteam',
|
||||
isSoc: mockRole === 'soc',
|
||||
canEditEngagements: mockRole === 'admin' || mockRole === 'redteam',
|
||||
}),
|
||||
}));
|
||||
|
||||
function EditPage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/engagements/:id/edit" element={<EngagementFormPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function NewPage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/engagements/new" element={<EngagementFormPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
describe('EngagementFormPage — C2 config card visibility', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/engagements/5').reply(200, ENGAGEMENT);
|
||||
mock.onGet('/engagements/5/c2-config').reply(404);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows C2 config card in EDIT mode for admin', async () => {
|
||||
mockRole = 'admin';
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/5/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-config-card')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows C2 config card in EDIT mode for redteam', async () => {
|
||||
mockRole = 'redteam';
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/5/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-config-card')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT show C2 config card in EDIT mode for SOC', async () => {
|
||||
mockRole = 'soc';
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/5/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-config-card')).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT show C2 config card on the NEW engagement form', async () => {
|
||||
mockRole = 'admin';
|
||||
renderWithProviders(<NewPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/new'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /create engagement/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-config-card')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
||||
mockRole = 'redteam';
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -154,6 +155,8 @@ describe('SimulationFormPage — SOC role + pending (blocked)', () => {
|
||||
mockRole = 'soc';
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||
// SOC role: useC2Config disabled (canEditRT=false), so no request expected — stub anyway
|
||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -202,6 +205,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
||||
mockRole = 'soc';
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
|
||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -273,3 +277,58 @@ describe('SimulationFormPage — new simulation', () => {
|
||||
expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRole = 'redteam';
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows Execute via C2 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,
|
||||
});
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-execute-btn')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides Execute via C2 button when no c2 config (404)', async () => {
|
||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides Execute via C2 button when simulation is done', async () => {
|
||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'done' });
|
||||
mock.onGet('/engagements/42/c2-config').reply(200, {
|
||||
has_token: true,
|
||||
url: 'https://mythic.lab:7443',
|
||||
verify_tls: true,
|
||||
});
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reopen-btn')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
136
frontend/tests/api/c2.test.ts
Normal file
136
frontend/tests/api/c2.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import {
|
||||
deleteC2Config,
|
||||
executeC2,
|
||||
getC2Config,
|
||||
listCallbacks,
|
||||
putC2Config,
|
||||
testC2Config,
|
||||
} from '@/api/c2';
|
||||
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('getC2Config', () => {
|
||||
it('returns config on 200', async () => {
|
||||
mock.onGet('/engagements/1/c2-config').reply(200, {
|
||||
has_token: true,
|
||||
url: 'https://mythic.lab:7443',
|
||||
verify_tls: true,
|
||||
});
|
||||
const result = await getC2Config(1);
|
||||
expect(result).toEqual({ has_token: true, url: 'https://mythic.lab:7443', verify_tls: true });
|
||||
});
|
||||
|
||||
it('returns null on 404', async () => {
|
||||
mock.onGet('/engagements/1/c2-config').reply(404);
|
||||
const result = await getC2Config(1);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('throws on other errors', async () => {
|
||||
mock.onGet('/engagements/1/c2-config').reply(503);
|
||||
await expect(getC2Config(1)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('putC2Config', () => {
|
||||
it('sends PUT to correct URL with body', async () => {
|
||||
mock.onPut('/engagements/2/c2-config').reply(200, {
|
||||
has_token: true,
|
||||
url: 'https://mythic.lab:7443',
|
||||
verify_tls: false,
|
||||
});
|
||||
const result = await putC2Config(2, {
|
||||
url: 'https://mythic.lab:7443',
|
||||
api_token: 'secret',
|
||||
verify_tls: false,
|
||||
});
|
||||
expect(result.has_token).toBe(true);
|
||||
expect(result.verify_tls).toBe(false);
|
||||
const req = mock.history['put'][0];
|
||||
expect(req.url).toBe('/engagements/2/c2-config');
|
||||
const body = JSON.parse(req.data as string);
|
||||
expect(body.api_token).toBe('secret');
|
||||
expect(body.verify_tls).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteC2Config', () => {
|
||||
it('sends DELETE to correct URL', async () => {
|
||||
mock.onDelete('/engagements/3/c2-config').reply(204);
|
||||
await expect(deleteC2Config(3)).resolves.toBeUndefined();
|
||||
expect(mock.history['delete'][0].url).toBe('/engagements/3/c2-config');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testC2Config', () => {
|
||||
it('sends POST and returns test result', async () => {
|
||||
mock.onPost('/engagements/1/c2-config/test').reply(200, { ok: true, error: null });
|
||||
const result = await testC2Config(1);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.error).toBeNull();
|
||||
expect(mock.history['post'][0].url).toBe('/engagements/1/c2-config/test');
|
||||
});
|
||||
|
||||
it('returns error message when connection fails', async () => {
|
||||
mock
|
||||
.onPost('/engagements/1/c2-config/test')
|
||||
.reply(200, { ok: false, error: 'Connection refused' });
|
||||
const result = await testC2Config(1);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe('Connection refused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listCallbacks', () => {
|
||||
it('sends GET and returns callbacks', async () => {
|
||||
mock.onGet('/engagements/1/c2/callbacks').reply(200, {
|
||||
callbacks: [
|
||||
{
|
||||
display_id: 1,
|
||||
active: true,
|
||||
host: 'WIN-TARGET',
|
||||
user: 'administrator',
|
||||
domain: 'lab.local',
|
||||
last_checkin: '2026-06-10T10:00:00',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await listCallbacks(1);
|
||||
expect(result.callbacks).toHaveLength(1);
|
||||
expect(result.callbacks[0].display_id).toBe(1);
|
||||
expect(result.callbacks[0].host).toBe('WIN-TARGET');
|
||||
expect(mock.history['get'][0].url).toBe('/engagements/1/c2/callbacks');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeC2', () => {
|
||||
it('sends POST with callback_display_id and commands', async () => {
|
||||
mock.onPost('/simulations/5/c2/execute').reply(200, {
|
||||
tasks: [
|
||||
{ id: 1, mythic_task_display_id: 42, command: 'whoami', status: 'submitted', completed: false },
|
||||
],
|
||||
});
|
||||
const result = await executeC2(5, {
|
||||
callback_display_id: 1,
|
||||
commands: ['whoami'],
|
||||
});
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
expect(result.tasks[0].command).toBe('whoami');
|
||||
const req = mock.history['post'][0];
|
||||
expect(req.url).toBe('/simulations/5/c2/execute');
|
||||
const body = JSON.parse(req.data as string);
|
||||
expect(body.callback_display_id).toBe(1);
|
||||
expect(body.commands).toEqual(['whoami']);
|
||||
});
|
||||
});
|
||||
135
frontend/tests/components/C2ConfigCard.test.tsx
Normal file
135
frontend/tests/components/C2ConfigCard.test.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
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 { C2ConfigCard } from '@/components/C2ConfigCard';
|
||||
import { renderWithProviders } from '../utils';
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
|
||||
status: 'authenticated',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAdmin: true,
|
||||
isRedteam: false,
|
||||
isSoc: false,
|
||||
canEditEngagements: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('C2ConfigCard — no config (404)', () => {
|
||||
it('renders the card with empty fields when no config exists', async () => {
|
||||
mock.onGet('/engagements/1/c2-config').reply(404);
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
// Wait for loading to finish — query resolves to null on 404
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-url-input')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('c2-token-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('c2-verify-tls')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('c2-save-btn')).toBeInTheDocument();
|
||||
// Delete button only shown when has_token
|
||||
expect(screen.queryByTestId('c2-delete-btn')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('C2ConfigCard — with config (has_token=true)', () => {
|
||||
beforeEach(() => {
|
||||
mock.onGet('/engagements/1/c2-config').reply(200, {
|
||||
has_token: true,
|
||||
url: 'https://mythic.lab:7443',
|
||||
verify_tls: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Replace token affordance when has_token=true', async () => {
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Replace token')).toBeInTheDocument();
|
||||
});
|
||||
// Token input shows placeholder bullets (readOnly)
|
||||
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
|
||||
expect(tokenInput.readOnly).toBe(true);
|
||||
expect(tokenInput.placeholder).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('shows Delete configuration button when has_token=true', async () => {
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-delete-btn')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking Replace token makes input editable', async () => {
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Replace token')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByText('Replace token'));
|
||||
await waitFor(() => {
|
||||
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
|
||||
expect(tokenInput.readOnly).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('Test connection button is enabled when config exists', async () => {
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Connected on successful test', async () => {
|
||||
mock.onPost('/engagements/1/c2-config/test').reply(200, { ok: true, error: null });
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('c2-test-btn'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error message on failed test', async () => {
|
||||
mock
|
||||
.onPost('/engagements/1/c2-config/test')
|
||||
.reply(200, { ok: false, error: 'Connection refused' });
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('c2-test-btn'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection refused')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('C2ConfigCard — 503 disabled state', () => {
|
||||
it('shows 503 banner and disables all inputs', async () => {
|
||||
mock.onGet('/engagements/1/c2-config').reply(503);
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/C2 features are disabled/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('c2-save-btn')).toBeDisabled();
|
||||
expect(screen.getByTestId('c2-url-input')).toBeDisabled();
|
||||
expect(screen.getByTestId('c2-token-input')).toBeDisabled();
|
||||
expect(screen.getByTestId('c2-verify-tls')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
160
frontend/tests/components/ExecuteViaC2Modal.test.tsx
Normal file
160
frontend/tests/components/ExecuteViaC2Modal.test.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
|
||||
import { renderWithProviders } from '../utils';
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'alice', role: 'redteam', created_at: '2026-01-01' },
|
||||
status: 'authenticated',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAdmin: false,
|
||||
isRedteam: true,
|
||||
isSoc: false,
|
||||
canEditEngagements: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const CALLBACKS = [
|
||||
{
|
||||
display_id: 1,
|
||||
active: true,
|
||||
host: 'WIN-TARGET',
|
||||
user: 'administrator',
|
||||
domain: 'lab.local',
|
||||
last_checkin: '2026-06-10T10:00:00',
|
||||
},
|
||||
{
|
||||
display_id: 2,
|
||||
active: false,
|
||||
host: 'WIN-DC01',
|
||||
user: 'SYSTEM',
|
||||
domain: 'lab.local',
|
||||
last_checkin: '2026-06-10T09:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/engagements/42/c2/callbacks').reply(200, { callbacks: CALLBACKS });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderModal(initialCommands = 'whoami\nipconfig') {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(
|
||||
<ExecuteViaC2Modal
|
||||
simulationId={7}
|
||||
engagementId={42}
|
||||
initialCommands={initialCommands}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return { onClose };
|
||||
}
|
||||
|
||||
describe('ExecuteViaC2Modal', () => {
|
||||
it('renders modal with title and callback table', async () => {
|
||||
renderModal();
|
||||
expect(screen.getByTestId('c2-modal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Execute via C2')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders callback rows with mono data', async () => {
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WIN-TARGET')).toBeInTheDocument();
|
||||
expect(screen.getByText('WIN-DC01')).toBeInTheDocument();
|
||||
expect(screen.getByText('administrator')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Launch button is disabled before selecting a callback', async () => {
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
|
||||
});
|
||||
expect(screen.getByTestId('c2-launch-btn')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Launch button is disabled when commands are empty', async () => {
|
||||
renderModal('');
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
|
||||
expect(screen.getByTestId('c2-launch-btn')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Launch button enabled after selecting row and having commands', async () => {
|
||||
renderModal('whoami');
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
|
||||
expect(screen.getByTestId('c2-launch-btn')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls executeC2 with correct body and closes modal on success', async () => {
|
||||
mock.onPost('/simulations/7/c2/execute').reply(200, {
|
||||
tasks: [
|
||||
{ id: 1, mythic_task_display_id: 10, command: 'whoami', status: 'submitted', completed: false },
|
||||
{ id: 2, mythic_task_display_id: 11, command: 'ipconfig', status: 'submitted', completed: false },
|
||||
],
|
||||
});
|
||||
const { onClose } = renderModal('whoami\nipconfig');
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
|
||||
fireEvent.click(screen.getByTestId('c2-launch-btn'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
const req = mock.history['post'][0];
|
||||
const body = JSON.parse(req.data as string);
|
||||
expect(body.callback_display_id).toBe(1);
|
||||
expect(body.commands).toEqual(['whoami', 'ipconfig']);
|
||||
});
|
||||
|
||||
it('shows inline error and keeps modal open on executeC2 failure', async () => {
|
||||
mock.onPost('/simulations/7/c2/execute').reply(500, { error: 'Mythic unreachable' });
|
||||
const { onClose } = renderModal('whoami');
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
|
||||
fireEvent.click(screen.getByTestId('c2-launch-btn'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mythic unreachable')).toBeInTheDocument();
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Cancel button calls onClose', async () => {
|
||||
const { onClose } = renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefills commands textarea from initialCommands', async () => {
|
||||
renderModal('net user\nwhoami /all');
|
||||
const textarea = screen.getByTestId('c2-commands-textarea') as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe('net user\nwhoami /all');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user