feat(frontend): sprint 2 — simulations UI + MITRE picker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-26 11:13:14 +02:00
parent 006c4c2c5f
commit 765bb5a1a4
16 changed files with 1778 additions and 7 deletions

View File

@@ -7,6 +7,7 @@ import { EngagementsListPage } from '@/pages/EngagementsListPage';
import { EngagementFormPage } from '@/pages/EngagementFormPage'; import { EngagementFormPage } from '@/pages/EngagementFormPage';
import { EngagementDetailPage } from '@/pages/EngagementDetailPage'; import { EngagementDetailPage } from '@/pages/EngagementDetailPage';
import { UsersAdminPage } from '@/pages/UsersAdminPage'; import { UsersAdminPage } from '@/pages/UsersAdminPage';
import { SimulationFormPage } from '@/pages/SimulationFormPage';
/** /**
* Router. Auth + role gates handled by <ProtectedRoute />. * Router. Auth + role gates handled by <ProtectedRoute />.
@@ -29,8 +30,15 @@ export function App(): JSX.Element {
<Route element={<ProtectedRoute roles={['admin', 'redteam']} />}> <Route element={<ProtectedRoute roles={['admin', 'redteam']} />}>
<Route path="/engagements/new" element={<EngagementFormPage />} /> <Route path="/engagements/new" element={<EngagementFormPage />} />
<Route path="/engagements/:id/edit" element={<EngagementFormPage />} /> <Route path="/engagements/:id/edit" element={<EngagementFormPage />} />
<Route path="/engagements/:eid/simulations/new" element={<SimulationFormPage />} />
</Route> </Route>
{/* simulation edit — all authenticated roles, RBAC handled inside the page */}
<Route
path="/engagements/:eid/simulations/:sid/edit"
element={<SimulationFormPage />}
/>
{/* admin-only routes */} {/* admin-only routes */}
<Route element={<ProtectedRoute roles={['admin']} />}> <Route element={<ProtectedRoute roles={['admin']} />}>
<Route path="/admin/users" element={<UsersAdminPage />} /> <Route path="/admin/users" element={<UsersAdminPage />} />

View File

@@ -0,0 +1,9 @@
import { apiClient } from './client';
import type { MitreTechnique } from './types';
export async function searchMitreTechniques(query: string): Promise<MitreTechnique[]> {
const { data } = await apiClient.get<MitreTechnique[]>('/mitre/techniques', {
params: { q: query },
});
return data;
}

View File

@@ -0,0 +1,37 @@
import { apiClient } from './client';
import type { Simulation, SimulationCreateInput, SimulationPatchInput, SimulationStatus } from './types';
export async function listSimulations(engagementId: number): Promise<Simulation[]> {
const { data } = await apiClient.get<Simulation[]>(`/engagements/${engagementId}/simulations`);
return data;
}
export async function createSimulation(
engagementId: number,
input: SimulationCreateInput,
): Promise<Simulation> {
const { data } = await apiClient.post<Simulation>(`/engagements/${engagementId}/simulations`, input);
return data;
}
export async function getSimulation(id: number): Promise<Simulation> {
const { data } = await apiClient.get<Simulation>(`/simulations/${id}`);
return data;
}
export async function updateSimulation(id: number, patch: SimulationPatchInput): Promise<Simulation> {
const { data } = await apiClient.patch<Simulation>(`/simulations/${id}`, patch);
return data;
}
export async function deleteSimulation(id: number): Promise<void> {
await apiClient.delete(`/simulations/${id}`);
}
export async function transitionSimulation(
id: number,
to: Extract<SimulationStatus, 'review_required' | 'done'>,
): Promise<Simulation> {
const { data } = await apiClient.post<Simulation>(`/simulations/${id}/transition`, { to });
return data;
}

View File

@@ -52,3 +52,51 @@ export interface UserPatchInput {
export interface ApiError { export interface ApiError {
error: string; error: string;
} }
export type SimulationStatus = 'pending' | 'in_progress' | 'review_required' | 'done';
export interface MitreTechnique {
id: string;
name: string;
tactics: string[];
}
export interface Simulation {
id: number;
engagement_id: number;
name: string;
mitre_technique_id: string | null;
mitre_technique_name: string | null;
description: string | null;
commands: string | null;
prerequisites: string | null;
executed_at: string | null;
execution_result: string | null;
log_source: string | null;
logs: string | null;
soc_comment: string | null;
incident_number: string | null;
status: SimulationStatus;
created_at: string;
updated_at: string | null;
created_by: { id: number; username: string };
}
export interface SimulationCreateInput {
name: string;
}
export interface SimulationPatchInput {
name?: string;
mitre_technique_id?: string | null;
mitre_technique_name?: string | null;
description?: string | null;
commands?: string | null;
prerequisites?: string | null;
executed_at?: string | null;
execution_result?: string | null;
log_source?: string | null;
logs?: string | null;
soc_comment?: string | null;
incident_number?: string | null;
}

View File

@@ -0,0 +1,48 @@
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
destructive?: boolean;
}
export function ConfirmDialog({
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
onConfirm,
onCancel,
destructive = false,
}: ConfirmDialogProps): JSX.Element {
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div className="absolute inset-0 bg-ink/40" onClick={onCancel} aria-hidden="true" />
<div className="relative card-product shadow-floating max-w-sm w-full mx-md flex flex-col gap-md">
<h2 id="confirm-dialog-title" className="text-[20px] font-medium text-ink">
{title}
</h2>
<p className="text-[16px] text-charcoal">{description}</p>
<div className="flex items-center gap-md pt-xs">
<button
type="button"
className={destructive ? 'btn-ink' : 'btn-primary'}
onClick={onConfirm}
>
{confirmLabel}
</button>
<button type="button" className="btn-outline-ink" onClick={onCancel}>
{cancelLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
} from 'react';
import { extractApiError } from '@/api/client';
import type { MitreTechnique } from '@/api/types';
import { useMitreSearch } from '@/hooks/useMitre';
interface MitreTechniquePickerProps {
techniqueId: string | null;
techniqueName: string | null;
onChange: (id: string | null, name: string | null) => void;
disabled?: boolean;
}
function formatOption(t: MitreTechnique): string {
const tacticList = t.tactics.length > 0 ? ` (${t.tactics[0]})` : '';
return `${t.id}${t.name}${tacticList}`;
}
const DEBOUNCE_MS = 200;
export function MitreTechniquePicker({
techniqueId,
techniqueName,
onChange,
disabled = false,
}: MitreTechniquePickerProps): JSX.Element {
const [inputValue, setInputValue] = useState(
techniqueId && techniqueName ? `${techniqueId}${techniqueName}` : '',
);
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Sync display when selection changes from outside (initial load)
useEffect(() => {
if (techniqueId && techniqueName) {
setInputValue(`${techniqueId}${techniqueName}`);
} else if (!techniqueId) {
setInputValue('');
}
}, [techniqueId, techniqueName]);
const { data: results, isFetching, isError, error } = useMitreSearch(query, open);
const items = results ?? [];
const handleInputChange = (value: string) => {
setInputValue(value);
// Clear the selection when user starts typing
onChange(null, null);
setOpen(true);
setActiveIndex(-1);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setQuery(value);
}, DEBOUNCE_MS);
};
const selectItem = (item: MitreTechnique) => {
setInputValue(formatOption(item));
onChange(item.id, item.name);
setOpen(false);
setActiveIndex(-1);
setQuery('');
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (!open || items.length === 0) {
if (e.key === 'Escape') setOpen(false);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, items.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && items[activeIndex]) {
selectItem(items[activeIndex]);
}
} else if (e.key === 'Escape') {
setOpen(false);
setActiveIndex(-1);
}
};
// Scroll active item into view
useEffect(() => {
if (activeIndex >= 0 && listRef.current) {
const el = listRef.current.children[activeIndex] as HTMLElement | undefined;
el?.scrollIntoView?.({ block: 'nearest' });
}
}, [activeIndex]);
// Close dropdown on click outside
useEffect(() => {
const onPointerDown = (e: PointerEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, []);
const listboxId = 'mitre-picker-listbox';
return (
<div ref={containerRef} className="relative">
<input
type="text"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 ? `mitre-option-${activeIndex}` : undefined}
aria-label="MITRE technique"
className="text-input"
value={inputValue}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={() => {
if (!techniqueId) setOpen(true);
}}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder="Search by ID or name (e.g. T1059)"
autoComplete="off"
/>
{open && (
<div className="absolute z-20 w-full mt-xxs bg-canvas border border-steel rounded-md shadow-floating overflow-hidden">
{isFetching && (
<div className="px-md py-sm text-[14px] text-graphite">Searching</div>
)}
{isError && !isFetching && (
<div className="px-md py-sm text-[14px] text-bloom-deep" role="alert">
{extractApiError(error, 'MITRE search unavailable')}
</div>
)}
{!isFetching && !isError && items.length === 0 && query.trim().length > 0 && (
<div className="px-md py-sm text-[14px] text-graphite">No results</div>
)}
{!isFetching && items.length > 0 && (
<ul
id={listboxId}
ref={listRef}
role="listbox"
aria-label="MITRE techniques"
className="max-h-64 overflow-y-auto"
>
{items.map((item, i) => (
<li
key={item.id}
id={`mitre-option-${i}`}
role="option"
aria-selected={i === activeIndex}
className={`px-md py-sm text-[14px] cursor-pointer select-none ${
i === activeIndex ? 'bg-primary-soft text-ink' : 'text-ink hover:bg-cloud'
}`}
onPointerDown={(e) => {
// Prevent input blur before we handle the click
e.preventDefault();
selectItem(item);
}}
>
<span className="font-medium">{item.id}</span>
<span className="text-charcoal"> {item.name}</span>
{item.tactics.length > 0 && (
<span className="text-graphite"> ({item.tactics[0]})</span>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { Link } from 'react-router-dom';
import { extractApiError } from '@/api/client';
import { useAuth } from '@/hooks/useAuth';
import { useEngagementSimulations } from '@/hooks/useSimulations';
import { LoadingState } from './LoadingState';
import { ErrorState } from './ErrorState';
import { EmptyState } from './EmptyState';
import { SimulationStatusBadge } from './SimulationStatusBadge';
interface SimulationListProps {
engagementId: number;
}
function formatDate(value: string | null): string {
if (!value) return '—';
return value.replace('T', ' ').slice(0, 16);
}
export function SimulationList({ engagementId }: SimulationListProps): JSX.Element {
const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId);
const { canEditEngagements } = useAuth();
if (isLoading) return <LoadingState label="Loading simulations…" />;
if (isError) {
return (
<ErrorState
message={extractApiError(error, 'Could not load simulations')}
onRetry={() => refetch()}
/>
);
}
if (!data || data.length === 0) {
return (
<EmptyState
title="No simulations yet"
description="Create the first simulation to start tracking red team tests."
action={
canEditEngagements ? (
<Link
to={`/engagements/${engagementId}/simulations/new`}
className="btn-primary"
data-testid="new-simulation-btn"
>
Nouvelle simulation
</Link>
) : undefined
}
/>
);
}
return (
<div className="flex flex-col gap-md">
<div className="flex items-center justify-between">
<h2 className="text-[24px] font-medium text-ink">Simulations</h2>
{canEditEngagements ? (
<Link
to={`/engagements/${engagementId}/simulations/new`}
className="btn-primary"
data-testid="new-simulation-btn"
>
Nouvelle simulation
</Link>
) : null}
</div>
<div className="card-product overflow-hidden p-0">
<table className="w-full text-left">
<thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
<th className="px-xl py-md">Name</th>
<th className="px-xl py-md">MITRE</th>
<th className="px-xl py-md">Status</th>
<th className="px-xl py-md">Executed at</th>
</tr>
</thead>
<tbody>
{data.map((sim) => (
<tr
key={sim.id}
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
onClick={() =>
(window.location.href = `/engagements/${engagementId}/simulations/${sim.id}/edit`)
}
>
<td className="px-xl py-md">
<Link
to={`/engagements/${engagementId}/simulations/${sim.id}/edit`}
className="text-ink font-medium hover:underline"
onClick={(e) => e.stopPropagation()}
>
{sim.name}
</Link>
</td>
<td className="px-xl py-md text-charcoal text-[14px]">
{sim.mitre_technique_id ?? '—'}
</td>
<td className="px-xl py-md">
<SimulationStatusBadge status={sim.status} />
</td>
<td className="px-xl py-md text-charcoal text-[14px]">
{formatDate(sim.executed_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import type { SimulationStatus } from '@/api/types';
const LABELS: Record<SimulationStatus, string> = {
pending: 'Pending',
in_progress: 'In progress',
review_required: 'Review required',
done: 'Done',
};
// pending=fog, in_progress=primary-soft, review_required=bloom-coral, done=storm-deep
const STYLES: Record<SimulationStatus, string> = {
pending: 'bg-fog text-charcoal border border-hairline',
in_progress: 'bg-primary-soft text-primary-deep',
review_required: 'bg-bloom-coral text-canvas',
done: 'bg-storm-deep text-canvas',
};
export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element {
return (
<span
className={`inline-flex items-center rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`}
data-testid="simulation-status-badge"
data-status={status}
>
{LABELS[status]}
</span>
);
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { searchMitreTechniques } from '@/api/mitre';
export function useMitreSearch(query: string, enabled: boolean) {
return useQuery({
queryKey: ['mitre', 'techniques', query],
queryFn: () => searchMitreTechniques(query),
enabled: enabled && query.trim().length > 0,
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -0,0 +1,76 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
createSimulation,
deleteSimulation,
getSimulation,
listSimulations,
transitionSimulation,
updateSimulation,
} from '@/api/simulations';
import type { SimulationCreateInput, SimulationPatchInput, SimulationStatus } from '@/api/types';
function simulationsKey(engagementId: number) {
return ['engagements', engagementId, 'simulations'] as const;
}
function simulationKey(id: number) {
return ['simulations', id] as const;
}
export function useEngagementSimulations(engagementId: number | undefined) {
return useQuery({
queryKey: engagementId ? simulationsKey(engagementId) : ['simulations', 'none'],
queryFn: () => listSimulations(engagementId as number),
enabled: typeof engagementId === 'number' && !Number.isNaN(engagementId),
});
}
export function useSimulation(id: number | undefined) {
return useQuery({
queryKey: id ? simulationKey(id) : ['simulations', 'none'],
queryFn: () => getSimulation(id as number),
enabled: typeof id === 'number' && !Number.isNaN(id),
});
}
export function useCreateSimulation(engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: SimulationCreateInput) => createSimulation(engagementId, input),
onSuccess: () => qc.invalidateQueries({ queryKey: simulationsKey(engagementId) }),
});
}
export function useUpdateSimulation(id: number, engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (patch: SimulationPatchInput) => updateSimulation(id, patch),
onSuccess: () => {
qc.invalidateQueries({ queryKey: simulationKey(id) });
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
},
});
}
export function useDeleteSimulation(engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteSimulation(id),
onSuccess: (_data, id) => {
qc.invalidateQueries({ queryKey: simulationKey(id) });
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
},
});
}
export function useTransitionSimulation(id: number, engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (to: Extract<SimulationStatus, 'review_required' | 'done'>) =>
transitionSimulation(id, to),
onSuccess: () => {
qc.invalidateQueries({ queryKey: simulationKey(id) });
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
},
});
}

View File

@@ -5,6 +5,7 @@ import { useEngagement } from '@/hooks/useEngagements';
import { LoadingState } from '@/components/LoadingState'; import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { StatusBadge } from '@/components/StatusBadge'; import { StatusBadge } from '@/components/StatusBadge';
import { SimulationList } from '@/components/SimulationList';
export function EngagementDetailPage(): JSX.Element { export function EngagementDetailPage(): JSX.Element {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -71,13 +72,8 @@ export function EngagementDetailPage(): JSX.Element {
</div> </div>
</section> </section>
{/* Sprint 2 placeholder per AC-4.9 */} <section>
<section className="bg-ink text-ink-on rounded-xl p-xxl"> <SimulationList engagementId={eng.id} />
<h2 className="text-[32px] font-medium leading-none">Simulations</h2>
<p className="text-[16px] mt-sm text-steel">
Simulations à venir au Sprint 2 tracking of red team tests and SOC detection coverage
will live here.
</p>
</section> </section>
</div> </div>
); );

View File

@@ -0,0 +1,506 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { extractApiError } from '@/api/client';
import type { SimulationPatchInput } from '@/api/types';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/useToast';
import {
useCreateSimulation,
useDeleteSimulation,
useSimulation,
useTransitionSimulation,
useUpdateSimulation,
} from '@/hooks/useSimulations';
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 { MitreTechniquePicker } from '@/components/MitreTechniquePicker';
interface RedteamFormState {
name: string;
mitre_technique_id: string | null;
mitre_technique_name: string | null;
description: string;
commands: string;
prerequisites: string;
executed_at: string;
execution_result: string;
}
interface SocFormState {
log_source: string;
logs: string;
soc_comment: string;
incident_number: string;
}
const EMPTY_RT: RedteamFormState = {
name: '',
mitre_technique_id: null,
mitre_technique_name: null,
description: '',
commands: '',
prerequisites: '',
executed_at: '',
execution_result: '',
};
const EMPTY_SOC: SocFormState = {
log_source: '',
logs: '',
soc_comment: '',
incident_number: '',
};
export function SimulationFormPage(): JSX.Element {
const { eid, sid } = useParams<{ eid: string; sid: string }>();
const engagementId = eid ? Number(eid) : undefined;
const simulationId = sid ? Number(sid) : undefined;
const isNew = !simulationId;
const navigate = useNavigate();
const { push } = useToast();
const { isAdmin, isRedteam, isSoc, canEditEngagements } = useAuth();
const detail = useSimulation(isNew ? undefined : simulationId);
const createMutation = useCreateSimulation(engagementId ?? 0);
const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0);
const deleteMutation = useDeleteSimulation(engagementId ?? 0);
const transitionMutation = useTransitionSimulation(simulationId ?? 0, engagementId ?? 0);
const [rt, setRt] = useState<RedteamFormState>(EMPTY_RT);
const [soc, setSoc] = useState<SocFormState>(EMPTY_SOC);
const [nameError, setNameError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
if (!isNew && detail.data) {
const s = detail.data;
setRt({
name: s.name,
mitre_technique_id: s.mitre_technique_id,
mitre_technique_name: s.mitre_technique_name,
description: s.description ?? '',
commands: s.commands ?? '',
prerequisites: s.prerequisites ?? '',
executed_at: s.executed_at ? s.executed_at.replace(' ', 'T').slice(0, 16) : '',
execution_result: s.execution_result ?? '',
});
setSoc({
log_source: s.log_source ?? '',
logs: s.logs ?? '',
soc_comment: s.soc_comment ?? '',
incident_number: s.incident_number ?? '',
});
}
}, [isNew, detail.data]);
if (!isNew && detail.isLoading) return <LoadingState label="Loading simulation…" />;
if (!isNew && detail.isError) {
return (
<ErrorState
message={extractApiError(detail.error, 'Could not load simulation')}
onRetry={() => detail.refetch()}
/>
);
}
const simulation = detail.data;
const status = simulation?.status;
// Role-based field locking
const canEditRT = isAdmin || isRedteam;
// SOC can only edit when status is review_required or done
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
const rtDisabled = !canEditRT;
const socDisabled = !canEditEngagements && !socCanEdit;
// Transition buttons visibility
const showMarkReview =
canEditEngagements && (status === 'pending' || status === 'in_progress');
const showClose =
(canEditEngagements || isSoc) && status === 'review_required';
const onSubmitNew = async (e: FormEvent) => {
e.preventDefault();
setNameError(null);
setSubmitError(null);
if (!rt.name.trim()) {
setNameError('Name is required');
return;
}
try {
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
push('Simulation créée', 'success');
navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`);
} catch (err) {
setSubmitError(extractApiError(err, 'Could not create simulation'));
}
};
const onSaveRT = async (e: FormEvent) => {
e.preventDefault();
setNameError(null);
setSubmitError(null);
if (!rt.name.trim()) {
setNameError('Name is required');
return;
}
const patch: SimulationPatchInput = {
name: rt.name.trim(),
mitre_technique_id: rt.mitre_technique_id ?? null,
mitre_technique_name: rt.mitre_technique_name ?? null,
description: rt.description.trim() || null,
commands: rt.commands.trim() || null,
prerequisites: rt.prerequisites.trim() || null,
executed_at: rt.executed_at || null,
execution_result: rt.execution_result.trim() || null,
};
try {
await updateMutation.mutateAsync(patch);
push('Simulation mise à jour', 'success');
} catch (err) {
setSubmitError(extractApiError(err, 'Could not update simulation'));
}
};
const onSaveSOC = async (e: FormEvent) => {
e.preventDefault();
setSubmitError(null);
const patch: SimulationPatchInput = {
log_source: soc.log_source.trim() || null,
logs: soc.logs.trim() || null,
soc_comment: soc.soc_comment.trim() || null,
incident_number: soc.incident_number.trim() || null,
};
try {
await updateMutation.mutateAsync(patch);
push('Rapport SOC mis à jour', 'success');
} catch (err) {
setSubmitError(extractApiError(err, 'Could not update SOC fields'));
}
};
const onMarkReview = async () => {
try {
await transitionMutation.mutateAsync('review_required');
push('Simulation marquée en revue', 'success');
} catch (err) {
push(extractApiError(err, 'Transition impossible'), 'error');
}
};
const onClose = async () => {
try {
await transitionMutation.mutateAsync('done');
push('Simulation clôturée', 'success');
} catch (err) {
push(extractApiError(err, 'Transition impossible'), 'error');
}
};
const onDelete = async () => {
setShowDeleteConfirm(false);
try {
await deleteMutation.mutateAsync(simulationId as number);
push('Simulation supprimée', 'success');
navigate(`/engagements/${engagementId}`);
} catch (err) {
push(extractApiError(err, 'Suppression impossible'), 'error');
}
};
// New simulation form (minimal)
if (isNew) {
const submitting = createMutation.isPending;
return (
<div className="flex flex-col gap-xl max-w-2xl">
<header>
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement
</Link>
<h1 className="text-[44px] font-medium leading-none mt-sm">Nouvelle simulation</h1>
</header>
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
<FormField label="Name" htmlFor="sim-name" required error={nameError}>
<TextInput
id="sim-name"
name="name"
value={rt.name}
onChange={(e) => setRt({ ...rt, name: e.target.value })}
required
/>
</FormField>
{submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</div>
) : null}
<div className="flex items-center gap-md pt-sm">
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Creating…' : 'Create simulation'}
</button>
<Link to={`/engagements/${engagementId}`} className="btn-outline-ink">
Cancel
</Link>
</div>
</form>
</div>
);
}
// Edit form
const submitting =
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
return (
<div className="flex flex-col gap-xl max-w-3xl">
<header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm">
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement
</Link>
<h1 className="text-[44px] font-medium leading-none">{rt.name || simulation?.name}</h1>
{status ? (
<div className="flex items-center gap-md">
<SimulationStatusBadge status={status} />
{simulation?.created_by && (
<span className="text-[14px] text-graphite">
Created by <span className="text-ink">{simulation.created_by.username}</span>
</span>
)}
</div>
) : null}
</div>
</header>
{/* SOC banner — shown when soc user visits pending/in_progress */}
{socBlocked && (
<div
role="alert"
data-testid="soc-blocked-banner"
className="rounded-xl px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
>
Simulation pas encore en revue la redteam doit la marquer comme &quot;Review required&quot; avant
que vous puissiez intervenir.
</div>
)}
{/* Red Team card */}
<form
id="rt-form"
onSubmit={canEditRT ? onSaveRT : (e) => e.preventDefault()}
noValidate
className="card-product flex flex-col gap-md"
>
<h2 className="text-[20px] font-medium text-ink">Red Team</h2>
<FormField label="Name" htmlFor="sim-name" required error={nameError}>
<TextInput
id="sim-name"
name="name"
value={rt.name}
onChange={(e) => setRt({ ...rt, name: e.target.value })}
disabled={rtDisabled}
required
/>
</FormField>
<FormField label="MITRE Technique" htmlFor="sim-mitre">
<MitreTechniquePicker
techniqueId={rt.mitre_technique_id}
techniqueName={rt.mitre_technique_name}
onChange={(id, name) =>
setRt({ ...rt, mitre_technique_id: id, mitre_technique_name: name })
}
disabled={rtDisabled}
/>
</FormField>
<FormField label="Description" htmlFor="sim-description">
<TextArea
id="sim-description"
name="description"
value={rt.description}
onChange={(e) => setRt({ ...rt, description: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField
label="Commands"
htmlFor="sim-commands"
hint="One command per line"
>
<TextArea
id="sim-commands"
name="commands"
value={rt.commands}
onChange={(e) => setRt({ ...rt, commands: e.target.value })}
disabled={rtDisabled}
className="min-h-[160px] font-mono text-[14px]"
/>
</FormField>
<FormField label="Prerequisites" htmlFor="sim-prerequisites">
<TextArea
id="sim-prerequisites"
name="prerequisites"
value={rt.prerequisites}
onChange={(e) => setRt({ ...rt, prerequisites: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
<FormField label="Executed at" htmlFor="sim-executed-at">
<TextInput
id="sim-executed-at"
type="datetime-local"
name="executed_at"
value={rt.executed_at}
onChange={(e) => setRt({ ...rt, executed_at: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField label="Execution result" htmlFor="sim-exec-result">
<TextInput
id="sim-exec-result"
name="execution_result"
value={rt.execution_result}
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
</div>
{canEditRT && (
<div className="flex items-center gap-md pt-sm border-t border-hairline">
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
{updateMutation.isPending ? 'Saving…' : 'Save Red Team'}
</button>
</div>
)}
</form>
{/* SOC card */}
<form
id="soc-form"
onSubmit={socCanEdit ? onSaveSOC : canEditEngagements ? onSaveSOC : (e) => e.preventDefault()}
noValidate
className="card-product flex flex-col gap-md"
>
<h2 className="text-[20px] font-medium text-ink">SOC</h2>
<FormField label="Log source" htmlFor="sim-log-source">
<TextInput
id="sim-log-source"
name="log_source"
value={soc.log_source}
onChange={(e) => setSoc({ ...soc, log_source: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="Logs" htmlFor="sim-logs">
<TextArea
id="sim-logs"
name="logs"
value={soc.logs}
onChange={(e) => setSoc({ ...soc, logs: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="SOC comment" htmlFor="sim-soc-comment">
<TextArea
id="sim-soc-comment"
name="soc_comment"
value={soc.soc_comment}
onChange={(e) => setSoc({ ...soc, soc_comment: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="Incident number" htmlFor="sim-incident">
<TextInput
id="sim-incident"
name="incident_number"
value={soc.incident_number}
onChange={(e) => setSoc({ ...soc, incident_number: e.target.value })}
disabled={socDisabled}
/>
</FormField>
{(socCanEdit || canEditEngagements) && (
<div className="flex items-center gap-md pt-sm border-t border-hairline">
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
</button>
</div>
)}
</form>
{submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</div>
) : null}
{/* Workflow + delete footer */}
<div className="flex items-center gap-md flex-wrap">
{showMarkReview && (
<button
type="button"
className="btn-outline"
onClick={onMarkReview}
disabled={transitionMutation.isPending}
>
Marquer en revue
</button>
)}
{showClose && (
<button
type="button"
className="btn-outline"
onClick={onClose}
disabled={transitionMutation.isPending}
>
Clôturer
</button>
)}
{canEditEngagements && simulationId && (
<button
type="button"
className="btn-text-link text-bloom-deep"
onClick={() => setShowDeleteConfirm(true)}
disabled={submitting}
>
Supprimer
</button>
)}
</div>
{showDeleteConfirm && (
<ConfirmDialog
title="Supprimer la simulation"
description="Cette action est irréversible. La simulation sera définitivement supprimée."
confirmLabel="Supprimer"
cancelLabel="Annuler"
destructive
onConfirm={onDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,243 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { MitreTechniquePicker } from '@/components/MitreTechniquePicker';
import { renderWithProviders } from './utils';
import type { MitreTechnique } from '@/api/types';
const TECHNIQUES: MitreTechnique[] = [
{ id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] },
{ id: 'T1059.001', name: 'PowerShell', tactics: ['execution'] },
{ id: 'T1021', name: 'Remote Services', tactics: ['lateral-movement'] },
];
describe('MitreTechniquePicker', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
mock.restore();
vi.useRealTimers();
});
it('renders input with placeholder', () => {
vi.useRealTimers();
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/Search by ID or name/i)).toBeInTheDocument();
});
it('shows preselected value when techniqueId and name provided', () => {
vi.useRealTimers();
renderWithProviders(
<MitreTechniquePicker
techniqueId="T1059"
techniqueName="Command and Scripting Interpreter"
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox') as HTMLInputElement;
expect(input.value).toContain('T1059');
expect(input.value).toContain('Command and Scripting Interpreter');
});
it('is disabled when disabled prop is true', () => {
vi.useRealTimers();
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
disabled
/>,
);
expect(screen.getByRole('combobox')).toBeDisabled();
});
it('debounces search: no request fires before 200ms', async () => {
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'T');
// Before debounce fires
expect(mock.history.get.length).toBe(0);
// Advance past debounce
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => expect(mock.history.get.length).toBeGreaterThan(0));
});
it('displays results in dropdown after debounce', async () => {
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'T1059');
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
const options = screen.getAllByRole('option');
expect(options.length).toBeGreaterThan(0);
expect(options[0].textContent).toContain('T1059');
});
it('selecting a result calls onChange with id and name', async () => {
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
const onChange = vi.fn();
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={onChange}
/>,
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'T1059');
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => screen.getByRole('listbox'));
const options = screen.getAllByRole('option');
await user.click(options[0]);
expect(onChange).toHaveBeenCalledWith('T1059', 'Command and Scripting Interpreter');
});
it('populates input display string after selection', async () => {
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox') as HTMLInputElement;
await user.click(input);
await user.type(input, 'T1059');
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => screen.getByRole('listbox'));
const options = screen.getAllByRole('option');
await user.click(options[0]);
expect(input.value).toContain('T1059');
expect(input.value).toContain('Command and Scripting Interpreter');
});
it('keyboard ArrowDown + Enter selects item', async () => {
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
const onChange = vi.fn();
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={onChange}
/>,
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'T105');
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => screen.getByRole('listbox'));
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(onChange).toHaveBeenCalled();
});
it('Escape closes the dropdown', async () => {
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'T1059');
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => screen.getByRole('listbox'));
await user.keyboard('{Escape}');
expect(screen.queryByRole('listbox')).toBeNull();
});
it('shows inline error when API returns 503', async () => {
mock.onGet('/mitre/techniques').reply(503, { error: 'mitre bundle not loaded' });
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'T1059');
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,275 @@
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 { SimulationFormPage } from '@/pages/SimulationFormPage';
import { renderWithProviders } from './utils';
import type { Simulation } from '@/api/types';
const BASE_SIM: Simulation = {
id: 7,
engagement_id: 42,
name: 'Recon test',
mitre_technique_id: null,
mitre_technique_name: null,
description: 'Some description',
commands: 'whoami\nipconfig',
prerequisites: null,
executed_at: null,
execution_result: null,
log_source: null,
logs: null,
soc_comment: null,
incident_number: null,
status: 'pending',
created_at: '2026-05-26T08:00:00',
updated_at: null,
created_by: { id: 1, username: 'alice' },
};
let mockRole: 'admin' | 'redteam' | 'soc' = 'redteam';
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',
}),
}));
// Wrap the page in a Route so useParams gets eid and sid
function EditPage() {
return (
<Routes>
<Route path="/engagements/:eid/simulations/:sid/edit" element={<SimulationFormPage />} />
</Routes>
);
}
function NewPage() {
return (
<Routes>
<Route path="/engagements/:eid/simulations/new" element={<SimulationFormPage />} />
</Routes>
);
}
describe('SimulationFormPage — redteam mode (edit existing)', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
});
afterEach(() => {
mock.restore();
});
it('renders loading state initially', () => {
mock.onGet('/simulations/7').reply(() => new Promise(() => {}));
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
expect(screen.getByTestId('loading-state')).toBeInTheDocument();
});
it('all Red Team fields are enabled for redteam', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
});
expect(screen.getByLabelText(/Description/i)).not.toBeDisabled();
expect(screen.getByLabelText(/Commands/i)).not.toBeDisabled();
expect(screen.getByLabelText(/Executed at/i)).not.toBeDisabled();
});
it('shows "Marquer en revue" button when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /Marquer en revue/i })).toBeInTheDocument();
});
});
it('does not show "Clôturer" when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => screen.getByRole('button', { name: /Marquer en revue/i }));
expect(screen.queryByRole('button', { name: /Clôturer/i })).toBeNull();
});
it('shows "Marquer en revue" for in_progress status', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'in_progress' });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /Marquer en revue/i })).toBeInTheDocument();
});
});
it('shows "Clôturer" button when status is review_required', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /Clôturer/i })).toBeInTheDocument();
});
});
it('shows "Supprimer" button for redteam', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /Supprimer/i })).toBeInTheDocument();
});
});
});
describe('SimulationFormPage — SOC role + pending (blocked)', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'soc';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
});
afterEach(() => {
mock.restore();
});
it('shows the SOC blocked banner', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument();
});
});
it('SOC inputs are disabled when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/Log source/i)).toBeDisabled();
});
expect(screen.getByLabelText(/Incident number/i)).toBeDisabled();
});
it('Red Team inputs are disabled for SOC', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).toBeDisabled();
});
expect(screen.getByLabelText(/Description/i)).toBeDisabled();
});
});
describe('SimulationFormPage — SOC role + review_required (can edit SOC fields)', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'soc';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
});
afterEach(() => {
mock.restore();
});
it('SOC inputs are enabled when status is review_required', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/Log source/i)).not.toBeDisabled();
});
expect(screen.getByLabelText(/Incident number/i)).not.toBeDisabled();
});
it('Red Team inputs remain disabled for SOC even when review_required', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).toBeDisabled();
});
});
it('does not show the blocked banner when status is review_required', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/Log source/i)).not.toBeDisabled();
});
expect(screen.queryByTestId('soc-blocked-banner')).toBeNull();
});
it('shows "Clôturer" for SOC when review_required', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /Clôturer/i })).toBeInTheDocument();
});
});
});
describe('SimulationFormPage — new simulation', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
it('renders the new simulation form with name field', () => {
renderWithProviders(<NewPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/new'] },
});
expect(screen.getByLabelText(/^Name/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { SimulationList } from '@/components/SimulationList';
import { renderWithProviders } from './utils';
import type { Simulation } from '@/api/types';
const SIMULATIONS: Simulation[] = [
{
id: 1,
engagement_id: 42,
name: 'Lateral movement test',
mitre_technique_id: 'T1021',
mitre_technique_name: 'Remote Services',
description: null,
commands: null,
prerequisites: null,
executed_at: '2026-06-01T10:00:00',
execution_result: null,
log_source: null,
logs: null,
soc_comment: null,
incident_number: null,
status: 'in_progress',
created_at: '2026-05-26T08:00:00',
updated_at: null,
created_by: { id: 1, username: 'alice' },
},
];
let mockCanEdit = true;
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: mockCanEdit ? 'admin' : 'soc', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: mockCanEdit,
isRedteam: false,
isSoc: !mockCanEdit,
canEditEngagements: mockCanEdit,
}),
}));
describe('SimulationList — admin/redteam', () => {
let mock: MockAdapter;
beforeEach(() => {
mockCanEdit = true;
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
it('shows loading state initially', () => {
mock.onGet('/engagements/42/simulations').reply(() => new Promise(() => {}));
renderWithProviders(<SimulationList engagementId={42} />);
expect(screen.getByTestId('loading-state')).toBeInTheDocument();
});
it('shows error state when request fails', async () => {
mock.onGet('/engagements/42/simulations').reply(500, { error: 'Server error' });
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByTestId('error-state')).toBeInTheDocument();
});
});
it('shows empty state when no simulations', async () => {
mock.onGet('/engagements/42/simulations').reply(200, []);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
});
it('shows "Nouvelle simulation" button for admin/redteam in empty state', async () => {
mock.onGet('/engagements/42/simulations').reply(200, []);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByTestId('new-simulation-btn')).toBeInTheDocument();
});
});
it('renders the simulation list with correct data', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
expect(screen.getByText('T1021')).toBeInTheDocument();
expect(screen.getByTestId('simulation-status-badge')).toHaveAttribute('data-status', 'in_progress');
});
it('shows "Nouvelle simulation" button in header when simulations exist', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
expect(screen.getByTestId('new-simulation-btn')).toBeInTheDocument();
});
});
describe('SimulationList — SOC role (no edit button)', () => {
let mock: MockAdapter;
beforeEach(() => {
mockCanEdit = false;
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
it('does not show "Nouvelle simulation" button for SOC in empty state', async () => {
mock.onGet('/engagements/42/simulations').reply(200, []);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
expect(screen.queryByTestId('new-simulation-btn')).toBeNull();
});
it('does not show "Nouvelle simulation" button for SOC when simulations exist', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
expect(screen.queryByTestId('new-simulation-btn')).toBeNull();
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
import type { SimulationStatus } from '@/api/types';
const CASES: { status: SimulationStatus; label: string }[] = [
{ status: 'pending', label: 'Pending' },
{ status: 'in_progress', label: 'In progress' },
{ status: 'review_required', label: 'Review required' },
{ status: 'done', label: 'Done' },
];
describe('SimulationStatusBadge', () => {
it.each(CASES)('renders $status with correct label and data attr', ({ status, label }) => {
render(<SimulationStatusBadge status={status} />);
const badge = screen.getByTestId('simulation-status-badge');
expect(badge).toHaveAttribute('data-status', status);
expect(badge.textContent).toBe(label);
});
it('applies fog surface for pending', () => {
render(<SimulationStatusBadge status="pending" />);
const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-fog');
});
it('applies primary-soft surface for in_progress', () => {
render(<SimulationStatusBadge status="in_progress" />);
const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-primary-soft');
});
it('applies bloom-coral surface for review_required', () => {
render(<SimulationStatusBadge status="review_required" />);
const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-bloom-coral');
});
it('applies storm-deep surface for done', () => {
render(<SimulationStatusBadge status="done" />);
const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-storm-deep');
});
});