feat(frontend): sprint 2 — simulations UI + MITRE picker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 />} />
|
||||||
|
|||||||
9
frontend/src/api/mitre.ts
Normal file
9
frontend/src/api/mitre.ts
Normal 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;
|
||||||
|
}
|
||||||
37
frontend/src/api/simulations.ts
Normal file
37
frontend/src/api/simulations.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
48
frontend/src/components/ConfirmDialog.tsx
Normal file
48
frontend/src/components/ConfirmDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
frontend/src/components/MitreTechniquePicker.tsx
Normal file
191
frontend/src/components/MitreTechniquePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
frontend/src/components/SimulationList.tsx
Normal file
113
frontend/src/components/SimulationList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
frontend/src/components/SimulationStatusBadge.tsx
Normal file
28
frontend/src/components/SimulationStatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/src/hooks/useMitre.ts
Normal file
11
frontend/src/hooks/useMitre.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
76
frontend/src/hooks/useSimulations.ts
Normal file
76
frontend/src/hooks/useSimulations.ts
Normal 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) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
506
frontend/src/pages/SimulationFormPage.tsx
Normal file
506
frontend/src/pages/SimulationFormPage.tsx
Normal 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 "Review required" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
frontend/tests/MitreTechniquePicker.test.tsx
Normal file
243
frontend/tests/MitreTechniquePicker.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
275
frontend/tests/SimulationFormPage.test.tsx
Normal file
275
frontend/tests/SimulationFormPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
138
frontend/tests/SimulationList.test.tsx
Normal file
138
frontend/tests/SimulationList.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
frontend/tests/SimulationStatusBadge.test.tsx
Normal file
44
frontend/tests/SimulationStatusBadge.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user