feat(frontend): SimulationFormPage 3-tab layout (Red Team / SOC / Tâche C2) (sprint 13) #15

Open
knacky wants to merge 3 commits from sprint/13-sim-tabs into main
9 changed files with 548 additions and 375 deletions
Showing only changes of commit 0378792dc4 - Show all commits

View File

@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased] ## [Unreleased]
### Changed — Sprint 13 (SimulationFormPage 3-tab layout)
**Frontend only** (253 vitest passing)
- `frontend/src/components/Tabs.tsx` — Added `disabled?: boolean` per-item: `aria-disabled`, `aria-controls`, native `disabled`, `.tab-underline-disabled` CSS, arrow-key skip logic.
- `frontend/src/styles/index.css` — New `.tab-underline-disabled` recipe (`text-graphite opacity-50 cursor-not-allowed`).
- `frontend/src/hooks/useHashTab.ts` — Added optional `validIds?: string[]` for hash fallback to defaultId when hash not in valid set.
- `frontend/src/pages/SimulationFormPage.tsx` — Full 3-tab refactor: Red Team / SOC / Tâche C2. RT fields in RT tab, SOC fields in SOC tab, `C2TasksPanel` in C2 tab. SOC tab disabled when `status ∉ {review_required, done}`. C2 tab disabled in create mode. Contextual sticky bar hides entirely on C2 tab. SOC-blocked `AlertBanner` removed; tab disabled state replaces it. `safeTab` guards against stale hash from previous navigation.
- `frontend/src/i18n/fr.json` — Added `simulation.form.tab.{redTeam,soc,c2}` keys.
- `DESIGN.md` — Documented disabled tab variant and `aria-controls` contract.
- `frontend/tests/SimulationFormPage.test.tsx` — Rewritten for tab-based layout: SOC-blocked-banner tests removed; tab disabled-state tests, SOC-only field tests, C2 tab panel test, count pill test added.
- `frontend/tests/components/Tabs.test.tsx` — Added 5 disabled-state tests.
### Changed — Sprint 12 (EngagementDetailPage 2-tab merge + full FR i18n) ### Changed — Sprint 12 (EngagementDetailPage 2-tab merge + full FR i18n)
**Frontend only** (245 vitest passing — baseline 233 + 12 new i18n smoke tests) **Frontend only** (245 vitest passing — baseline 233 + 12 new i18n smoke tests)

View File

@@ -225,7 +225,8 @@ Used inside pages that need content partitioning without a URL change (e.g. Enga
- **`.tab-underline-active`**: `text-primary border-primary` — 2px primary underline, primary text. - **`.tab-underline-active`**: `text-primary border-primary` — 2px primary underline, primary text.
- **Count pill** (optional, e.g. simulation count): `.tab-count-pill``rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono`. `rounded-pill` is the documented exception. - **Count pill** (optional, e.g. simulation count): `.tab-count-pill``rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono`. `rounded-pill` is the documented exception.
- **Active count pill**: `.tab-count-pill-active``bg-primary-soft text-primary`. - **Active count pill**: `.tab-count-pill-active``bg-primary-soft text-primary`.
- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` on each button. - **Disabled variant**: `.tab-underline-disabled``text-graphite opacity-50 cursor-not-allowed`. Applied when `disabled?: boolean` is set on a `TabItem`. Overrides `.tab-underline` hover. ARIA: `aria-disabled="true"` + native `disabled` attribute. Arrow-key navigation skips disabled items.
- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` + `aria-controls="tabpanel-{id}"` on each button.
### Data Tables ### Data Tables

View File

@@ -4,6 +4,7 @@ interface TabItem {
id: string; id: string;
label: string; label: string;
count?: number; count?: number;
disabled?: boolean;
} }
interface TabsProps { interface TabsProps {
@@ -13,13 +14,23 @@ interface TabsProps {
} }
export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element { export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
function nextEnabled(from: number, direction: 1 | -1): string {
const len = items.length;
let i = (from + direction + len) % len;
while (i !== from) {
if (!items[i].disabled) return items[i].id;
i = (i + direction + len) % len;
}
return items[from].id;
}
function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) { function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
if (e.key === 'ArrowRight') { if (e.key === 'ArrowRight') {
e.preventDefault(); e.preventDefault();
onChange(items[(index + 1) % items.length].id); onChange(nextEnabled(index, 1));
} else if (e.key === 'ArrowLeft') { } else if (e.key === 'ArrowLeft') {
e.preventDefault(); e.preventDefault();
onChange(items[(index - 1 + items.length) % items.length].id); onChange(nextEnabled(index, -1));
} }
} }
@@ -30,6 +41,7 @@ export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
> >
{items.map((item, index) => { {items.map((item, index) => {
const isActive = item.id === activeId; const isActive = item.id === activeId;
const isDisabled = item.disabled ?? false;
return ( return (
<button <button
key={item.id} key={item.id}
@@ -37,10 +49,12 @@ export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
role="tab" role="tab"
aria-selected={isActive} aria-selected={isActive}
aria-controls={`tabpanel-${item.id}`} aria-controls={`tabpanel-${item.id}`}
aria-disabled={isDisabled || undefined}
disabled={isDisabled}
type="button" type="button"
onClick={() => onChange(item.id)} onClick={() => { if (!isDisabled) onChange(item.id); }}
onKeyDown={(e) => handleKeyDown(e, index)} onKeyDown={(e) => handleKeyDown(e, index)}
className={`tab-underline${isActive ? ' tab-underline-active' : ''} pb-xs`} className={`tab-underline${isActive ? ' tab-underline-active' : ''}${isDisabled ? ' tab-underline-disabled' : ''} pb-xs`}
> >
{item.label} {item.label}
{item.count !== undefined ? ( {item.count !== undefined ? (

View File

@@ -1,20 +1,22 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
function readHash(defaultId: string): string { function readHash(defaultId: string, validIds?: string[]): string {
const hash = window.location.hash.slice(1); // strip leading '#' const hash = window.location.hash.slice(1); // strip leading '#'
return hash || defaultId; if (!hash) return defaultId;
if (validIds && !validIds.includes(hash)) return defaultId;
return hash;
} }
export function useHashTab(defaultId: string): [string, (id: string) => void] { export function useHashTab(defaultId: string, validIds?: string[]): [string, (id: string) => void] {
const [activeId, setActiveId] = useState<string>(() => readHash(defaultId)); const [activeId, setActiveId] = useState<string>(() => readHash(defaultId, validIds));
useEffect(() => { useEffect(() => {
function onHashChange() { function onHashChange() {
setActiveId(readHash(defaultId)); setActiveId(readHash(defaultId, validIds));
} }
window.addEventListener('hashchange', onHashChange); window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange); return () => window.removeEventListener('hashchange', onHashChange);
}, [defaultId]); }, [defaultId, validIds]);
const navigate = useCallback((id: string) => { const navigate = useCallback((id: string) => {
// replaceState: no history entry (Back button unaffected), no anchor-jump scroll // replaceState: no history entry (Back button unaffected), no anchor-jump scroll

View File

@@ -204,7 +204,12 @@
"transition": "Transition échouée", "transition": "Transition échouée",
"load": "Impossible de charger la simulation" "load": "Impossible de charger la simulation"
}, },
"loading": "Chargement de la simulation…" "loading": "Chargement de la simulation…",
"tab": {
"redTeam": "Red Team",
"soc": "SOC",
"c2": "Tâche C2"
}
}, },
"list": { "list": {
"col": { "col": {

View File

@@ -6,6 +6,7 @@ import { extractApiError } from '@/api/client';
import type { SimulationPatchInput } from '@/api/types'; import type { SimulationPatchInput } from '@/api/types';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useHashTab } from '@/hooks/useHashTab';
import { import {
useCreateSimulation, useCreateSimulation,
useDeleteSimulation, useDeleteSimulation,
@@ -25,6 +26,9 @@ import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
import { C2TasksPanel } from '@/components/C2TasksPanel'; import { C2TasksPanel } from '@/components/C2TasksPanel';
import { AlertBanner } from '@/components/AlertBanner'; import { AlertBanner } from '@/components/AlertBanner';
import { BackLink } from '@/components/BackLink'; import { BackLink } from '@/components/BackLink';
import { Tabs } from '@/components/Tabs';
type TabId = 'red-team' | 'soc' | 'c2';
interface RedteamFormState { interface RedteamFormState {
name: string; name: string;
@@ -79,9 +83,7 @@ export function SimulationFormPage(): JSX.Element {
const c2TasksQuery = useC2Tasks(!isNew ? simulationId : undefined, { const c2TasksQuery = useC2Tasks(!isNew ? simulationId : undefined, {
enabled: !isNew && canEditRT, enabled: !isNew && canEditRT,
}); });
const hasTasks = (c2TasksQuery.data?.tasks?.length ?? 0) > 0; const c2TaskCount = c2TasksQuery.data?.tasks?.length ?? 0;
// Show panel when: has C2 config (so Execute button is visible) OR already has tasks
const showTasksPanel = !isNew && canEditRT && (hasC2Config || hasTasks);
const detail = useSimulation(isNew ? undefined : simulationId); const detail = useSimulation(isNew ? undefined : simulationId);
const createMutation = useCreateSimulation(engagementId ?? 0); const createMutation = useCreateSimulation(engagementId ?? 0);
@@ -97,6 +99,9 @@ export function SimulationFormPage(): JSX.Element {
const [showC2Modal, setShowC2Modal] = useState(false); const [showC2Modal, setShowC2Modal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
// Must be called unconditionally before any early returns
const [activeTab, setActiveTab] = useHashTab('red-team');
useEffect(() => { useEffect(() => {
if (!isNew && detail.data) { if (!isNew && detail.data) {
const s = detail.data; const s = detail.data;
@@ -130,12 +135,8 @@ export function SimulationFormPage(): JSX.Element {
const simulation = detail.data; const simulation = detail.data;
const status = simulation?.status; const status = simulation?.status;
// US-18: Done = fully read-only, Reopen only
const isDone = status === 'done'; const isDone = status === 'done';
const socCanEdit = isSoc && (status === 'review_required' || status === 'done'); const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
const canSaveSoc = !isDone && (socCanEdit || canEditEngagements); const canSaveSoc = !isDone && (socCanEdit || canEditEngagements);
const rtDisabled = !canEditRT || isDone; const rtDisabled = !canEditRT || isDone;
const socDisabled = isDone || (!canEditEngagements && !socCanEdit); const socDisabled = isDone || (!canEditEngagements && !socCanEdit);
@@ -146,6 +147,16 @@ export function SimulationFormPage(): JSX.Element {
!isDone && (canEditEngagements || isSoc) && status === 'review_required'; !isDone && (canEditEngagements || isSoc) && status === 'review_required';
const showReopen = isDone && (isAdmin || isRedteam || isSoc); const showReopen = isDone && (isAdmin || isRedteam || isSoc);
// SOC tab is only enabled when status is review_required or done
const socTabDisabled = isNew || (status !== 'review_required' && status !== 'done');
const c2TabDisabled = isNew;
// Fall back to red-team if the stored hash points to a now-disabled tab
const safeTab =
(activeTab === 'soc' && socTabDisabled) || (activeTab === 'c2' && c2TabDisabled)
? 'red-team'
: activeTab;
const onSubmitNew = async (e: FormEvent) => { const onSubmitNew = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setNameError(null); setNameError(null);
@@ -236,9 +247,27 @@ export function SimulationFormPage(): JSX.Element {
} }
}; };
const submitting =
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
const tabItems = [
{ id: 'red-team' as TabId, label: t('simulation.form.tab.redTeam') },
{
id: 'soc' as TabId,
label: t('simulation.form.tab.soc'),
disabled: socTabDisabled,
},
{
id: 'c2' as TabId,
label: t('simulation.form.tab.c2'),
count: !isNew && c2TaskCount > 0 ? c2TaskCount : undefined,
disabled: c2TabDisabled,
},
];
// New simulation form // New simulation form
if (isNew) { if (isNew) {
const submitting = createMutation.isPending; const creating = createMutation.isPending;
return ( return (
<div className="flex flex-col gap-xl max-w-2xl"> <div className="flex flex-col gap-xl max-w-2xl">
<header> <header>
@@ -246,6 +275,8 @@ export function SimulationFormPage(): JSX.Element {
<h1 className="text-[32px] font-medium leading-none mt-sm">{t('simulation.form.title.new')}</h1> <h1 className="text-[32px] font-medium leading-none mt-sm">{t('simulation.form.title.new')}</h1>
</header> </header>
<Tabs items={tabItems} activeId="red-team" onChange={() => {}} />
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md"> <form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
<FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}> <FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
<TextInput <TextInput
@@ -257,26 +288,34 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">{t('simulation.form.field.mitre')}</span>
<MitreTechniquesField
value={[]}
tactics={[]}
simulationId={0}
engagementId={engagementId ?? 0}
disabled
/>
</div>
{submitError ? ( {submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div> <div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
) : null} ) : null}
</form>
<div className="flex items-center gap-md pt-sm"> <div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
<button type="submit" className="btn-primary" disabled={submitting} data-testid="create-sim-btn"> <button type="submit" form="" className="btn-primary" disabled={creating} data-testid="create-sim-btn" onClick={onSubmitNew}>
{submitting ? t('simulation.form.btn.creating') : t('simulation.form.btn.create')} {creating ? t('simulation.form.btn.creating') : t('simulation.form.btn.create')}
</button> </button>
<Link to={`/engagements/${engagementId}`} className="btn-outline-ink"> <Link to={`/engagements/${engagementId}`} className="btn-outline-ink">
{t('simulation.form.btn.cancel')} {t('simulation.form.btn.cancel')}
</Link> </Link>
</div> </div>
</form>
</div> </div>
); );
} }
const submitting =
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
return ( return (
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md"> <header className="flex items-start justify-between gap-md">
@@ -297,35 +336,28 @@ export function SimulationFormPage(): JSX.Element {
</div> </div>
</header> </header>
{/* Done banner */} {/* Done banner — above tabs, global status info */}
{isDone && ( {isDone && (
<AlertBanner variant="success"> <AlertBanner variant="success">
{t('simulation.form.banner.done')} {t('simulation.form.banner.done')}
</AlertBanner> </AlertBanner>
)} )}
{/* SOC banner */} <Tabs items={tabItems} activeId={safeTab} onChange={setActiveTab} />
{socBlocked && (
<div data-testid="soc-blocked-banner">
<AlertBanner variant="warn">
{t('simulation.form.banner.socNotReady')}
</AlertBanner>
</div>
)}
{/* 2-column grid: RT+tasks left, SOC right. Stacks vertically below lg. */} <div
<div className="grid gap-xl lg:grid-cols-2 items-start"> role="tabpanel"
{/* Left column: RT card + C2 tasks panel */} id={`tabpanel-${safeTab}`}
<div className="flex flex-col gap-xl"> aria-labelledby={`tab-${safeTab}`}
{/* Red Team card */} >
{/* ── Red Team tab ──────────────────────────────────────── */}
{safeTab === 'red-team' && (
<form <form
id="rt-form" id="rt-form"
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()} onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
noValidate noValidate
className="card-product flex flex-col gap-md" className="card-product flex flex-col gap-md"
> >
<h2 className="text-[20px] font-medium text-ink">{t('simulation.form.header.redTeam')}</h2>
<FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}> <FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
<TextInput <TextInput
id="sim-name" id="sim-name"
@@ -422,22 +454,16 @@ export function SimulationFormPage(): JSX.Element {
</div> </div>
)} )}
</form> </form>
{/* C2 tasks panel — under RT card, same left column */}
{showTasksPanel && simulationId && (
<C2TasksPanel simulationId={simulationId} />
)} )}
</div>{/* end left column */}
{/* SOC card */} {/* ── SOC tab ───────────────────────────────────────────── */}
{safeTab === 'soc' && (
<form <form
id="soc-form" id="soc-form"
onSubmit={canSaveSoc ? onSaveSOC : (e) => e.preventDefault()} onSubmit={canSaveSoc ? onSaveSOC : (e) => e.preventDefault()}
noValidate noValidate
className="card-product flex flex-col gap-md" className="card-product flex flex-col gap-md"
> >
<h2 className="text-[20px] font-medium text-ink">{t('simulation.form.header.soc')}</h2>
<FormField label={t('simulation.form.field.logSource')} htmlFor="sim-log-source"> <FormField label={t('simulation.form.field.logSource')} htmlFor="sim-log-source">
<TextInput <TextInput
id="sim-log-source" id="sim-log-source"
@@ -478,41 +504,29 @@ export function SimulationFormPage(): JSX.Element {
/> />
</FormField> </FormField>
</form> </form>
)}
{/* ── C2 tab ────────────────────────────────────────────── */}
{safeTab === 'c2' && simulationId && (
<C2TasksPanel simulationId={simulationId} />
)}
</div> </div>
{submitError ? ( {submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div> <div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
) : null} ) : null}
{/* Unified sticky action bar */} {/* Contextual sticky bar — not rendered at all for C2 tab */}
{safeTab !== 'c2' && (
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md"> <div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
{/* Done state: Reopen only */} {safeTab === 'red-team' && (
{showReopen && ( <>
<button
type="button"
className="btn-outline"
onClick={onReopen}
disabled={transitionMutation.isPending}
data-testid="reopen-btn"
>
<RotateCcw size={14} aria-hidden />
{t('simulation.form.btn.reopen')}
</button>
)}
{/* Normal state buttons */}
{!isDone && canEditRT && ( {!isDone && canEditRT && (
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}> <button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
<Save size={14} aria-hidden /> <Save size={14} aria-hidden />
{updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.save')} {updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.save')}
</button> </button>
)} )}
{!isDone && canSaveSoc && (
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
<Save size={14} aria-hidden />
{updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.saveSoc')}
</button>
)}
{showMarkReview && ( {showMarkReview && (
<button <button
type="button" type="button"
@@ -524,17 +538,6 @@ export function SimulationFormPage(): JSX.Element {
{t('simulation.form.btn.markReview')} {t('simulation.form.btn.markReview')}
</button> </button>
)} )}
{showClose && (
<button
type="button"
className="btn-outline"
onClick={onClose}
disabled={transitionMutation.isPending}
data-testid="close-btn"
>
{t('simulation.form.btn.close')}
</button>
)}
{!isDone && canEditEngagements && simulationId && ( {!isDone && canEditEngagements && simulationId && (
<button <button
type="button" type="button"
@@ -546,7 +549,44 @@ export function SimulationFormPage(): JSX.Element {
{t('simulation.form.btn.delete')} {t('simulation.form.btn.delete')}
</button> </button>
)} )}
</>
)}
{safeTab === 'soc' && (
<>
{!isDone && canSaveSoc && (
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
<Save size={14} aria-hidden />
{updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.saveSoc')}
</button>
)}
{showClose && (
<button
type="button"
className="btn-outline"
onClick={onClose}
disabled={transitionMutation.isPending}
data-testid="close-btn"
>
{t('simulation.form.btn.close')}
</button>
)}
{showReopen && (
<button
type="button"
className="btn-outline"
onClick={onReopen}
disabled={transitionMutation.isPending}
data-testid="reopen-btn"
>
<RotateCcw size={14} aria-hidden />
{t('simulation.form.btn.reopen')}
</button>
)}
</>
)}
</div> </div>
)}
{showDeleteConfirm && ( {showDeleteConfirm && (
<ConfirmDialog <ConfirmDialog

View File

@@ -162,6 +162,10 @@
.tab-count-pill-active { .tab-count-pill-active {
@apply bg-primary-soft text-primary; @apply bg-primary-soft text-primary;
} }
/* Disabled variant — opacity + cursor; overrides hover from .tab-underline */
.tab-underline-disabled {
@apply text-graphite opacity-50 cursor-not-allowed;
}
/* ─── Inline alert banners (L2) ─────────────────────────────────────── */ /* ─── Inline alert banners (L2) ─────────────────────────────────────── */
.alert-error { .alert-error {

View File

@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react'; import { screen, waitFor, fireEvent } from '@testing-library/react';
// Reset hash between tests so useHashTab doesn't bleed state
afterEach(() => { history.replaceState(null, '', '/'); });
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
@@ -43,7 +45,6 @@ vi.mock('@/hooks/useAuth', () => ({
}), }),
})); }));
// Wrap the page in a Route so useParams gets eid and sid
function EditPage() { function EditPage() {
return ( return (
<Routes> <Routes>
@@ -68,6 +69,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
mock = new MockAdapter(apiClient); mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM); mock.onGet('/simulations/7').reply(200, BASE_SIM);
mock.onGet('/engagements/42/c2-config').reply(404); mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
}); });
afterEach(() => { afterEach(() => {
@@ -126,12 +128,16 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
}); });
}); });
it('shows "Close" button when status is review_required', async () => { it('shows "Close" button on SOC tab when status is review_required', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' }); mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
// Switch to SOC tab to see Close button
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('close-btn')).toBeInTheDocument(); expect(screen.getByTestId('close-btn')).toBeInTheDocument();
}); });
@@ -148,41 +154,160 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
}); });
}); });
describe('SimulationFormPage — SOC role + pending (blocked)', () => { describe('SimulationFormPage — tabs', () => {
let mock: MockAdapter; let mock: MockAdapter;
beforeEach(() => { beforeEach(() => {
mockRole = 'soc'; mockRole = 'redteam';
mock = new MockAdapter(apiClient); mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM); mock.onGet('/simulations/7').reply(200, BASE_SIM);
// SOC role: useC2Config disabled (canEditRT=false), so no request expected — stub anyway
mock.onGet('/engagements/42/c2-config').reply(404); mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
}); });
afterEach(() => { afterEach(() => {
mock.restore(); mock.restore();
}); });
it('shows the SOC blocked banner', async () => { it('renders 3 tabs: Red Team, SOC, Tâche C2', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
expect(screen.getByRole('tab', { name: /Red Team/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /SOC/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Tâche C2/i })).toBeInTheDocument();
});
it('SOC tab is disabled when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
const socTab = screen.getByRole('tab', { name: /SOC/i });
expect(socTab).toBeDisabled();
});
it('SOC tab is enabled when status is review_required', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument(); const socTab = screen.getByRole('tab', { name: /SOC/i });
expect(socTab).not.toBeDisabled();
}); });
}); });
it('SOC inputs are disabled when status is pending', async () => { it('SOC tab is enabled when status is done', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'done' });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/^Source de log/i)).toBeDisabled(); const socTab = screen.getByRole('tab', { name: /SOC/i });
expect(socTab).not.toBeDisabled();
});
}); });
expect(screen.getByLabelText(/^Numéro d'incident/i)).toBeDisabled(); it('C2 tab is disabled in edit mode (no tasks shown until tab visible)', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
// C2 tab should exist and be enabled (it's edit mode, not create mode)
const c2Tab = screen.getByRole('tab', { name: /Tâche C2/i });
expect(c2Tab).not.toBeDisabled();
});
it('SOC fields are visible after switching to SOC tab', 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('tab', { name: /SOC/i })).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
await waitFor(() => {
expect(screen.getByLabelText(/^Source de log/i)).toBeInTheDocument();
});
expect(screen.getByLabelText(/^Numéro d'incident/i)).toBeInTheDocument();
});
it('sticky bar is not rendered on C2 tab', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /Tâche C2/i }));
await waitFor(() => expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument());
// Save button should not appear on C2 tab
expect(screen.queryByRole('button', { name: /Enregistrer/i })).toBeNull();
});
it('C2 tasks count pill shows when tasks exist', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, {
tasks: [
{
id: 1,
mythic_task_display_id: 10,
callback_display_id: 1,
command: 'whoami',
params: null,
status: 'completed',
completed: true,
output: 'SYSTEM',
mapping_applied: false,
source: 'import',
created_at: '2026-06-10T10:00:00',
completed_at: '2026-06-10T10:00:05',
},
],
});
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
// Count pill shows "1" on the C2 tab
expect(screen.getByText('1')).toBeInTheDocument();
});
});
});
describe('SimulationFormPage — SOC role + pending (tab disabled)', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'soc';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
});
afterEach(() => {
mock.restore();
});
it('SOC tab is disabled for SOC role when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).toBeDisabled());
}); });
it('Red Team inputs are disabled for SOC', async () => { it('Red Team inputs are disabled for SOC', async () => {
@@ -206,6 +331,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
mock = new MockAdapter(apiClient); mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' }); mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
mock.onGet('/engagements/42/c2-config').reply(404); mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
}); });
afterEach(() => { afterEach(() => {
@@ -217,10 +343,13 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
// Switch to SOC tab
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/^Source de log/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Source de log/i)).not.toBeDisabled();
}); });
expect(screen.getByLabelText(/^Numéro d'incident/i)).not.toBeDisabled(); expect(screen.getByLabelText(/^Numéro d'incident/i)).not.toBeDisabled();
}); });
@@ -234,23 +363,15 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
}); });
}); });
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(/^Source de log/i)).not.toBeDisabled();
});
expect(screen.queryByTestId('soc-blocked-banner')).toBeNull();
});
it('shows "Close" for SOC when review_required', async () => { it('shows "Close" for SOC when review_required', async () => {
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
// Switch to SOC tab
await waitFor(() => expect(screen.getByRole('tab', { name: /SOC/i })).not.toBeDisabled());
fireEvent.click(screen.getByRole('tab', { name: /SOC/i }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('close-btn')).toBeInTheDocument(); expect(screen.getByTestId('close-btn')).toBeInTheDocument();
}); });
@@ -276,6 +397,15 @@ describe('SimulationFormPage — new simulation', () => {
expect(screen.getByLabelText(/^Nom/i)).toBeInTheDocument(); expect(screen.getByLabelText(/^Nom/i)).toBeInTheDocument();
expect(screen.getByTestId('create-sim-btn')).toBeInTheDocument(); expect(screen.getByTestId('create-sim-btn')).toBeInTheDocument();
}); });
it('SOC and C2 tabs are disabled in create mode', () => {
renderWithProviders(<NewPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/new'] },
});
expect(screen.getByRole('tab', { name: /SOC/i })).toBeDisabled();
expect(screen.getByRole('tab', { name: /Tâche C2/i })).toBeDisabled();
});
}); });
describe('SimulationFormPage — Execute via C2 button visibility', () => { describe('SimulationFormPage — Execute via C2 button visibility', () => {
@@ -297,6 +427,7 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
url: 'https://mythic.lab:7443', url: 'https://mythic.lab:7443',
verify_tls: true, verify_tls: true,
}); });
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
@@ -307,6 +438,7 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
it('hides Execute via C2 button when no c2 config (404)', async () => { it('hides Execute via C2 button when no c2 config (404)', async () => {
mock.onGet('/engagements/42/c2-config').reply(404); mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
@@ -323,100 +455,21 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
url: 'https://mythic.lab:7443', url: 'https://mythic.lab:7443',
verify_tls: true, verify_tls: true,
}); });
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, { renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
}); });
// Wait for RT tab to load; done sim disables RT editing
await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).toBeDisabled());
// Execute via C2 not shown on done simulation
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
// Reopen is on the SOC tab — switch to confirm
const socTab = screen.getByRole('tab', { name: /SOC/i });
expect(socTab).not.toBeDisabled();
fireEvent.click(socTab);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('reopen-btn')).toBeInTheDocument(); expect(screen.getByTestId('reopen-btn')).toBeInTheDocument();
}); });
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
});
});
describe('SimulationFormPage — C2 tasks panel visibility', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
});
afterEach(() => {
mock.restore();
});
it('shows C2 tasks panel when c2 config exists (even with no tasks)', async () => {
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
});
});
it('hides C2 tasks panel when no c2 config and no tasks', async () => {
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
// Wait for page data to load then confirm no panel
await waitFor(() => {
expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled();
});
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
});
it('shows C2 tasks panel when tasks exist even without c2 config', async () => {
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, {
tasks: [
{
id: 1,
mythic_task_display_id: 10,
callback_display_id: 1,
command: 'whoami',
params: null,
status: 'completed',
completed: true,
output: 'SYSTEM',
mapping_applied: false,
source: 'import',
created_at: '2026-06-10T10:00:00',
completed_at: '2026-06-10T10:00:05',
},
],
});
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
});
});
it('SOC role never sees C2 tasks panel', async () => {
mockRole = 'soc';
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
}); });
it('shows Import C2 history button when c2 config exists', async () => { it('shows Import C2 history button when c2 config exists', async () => {

View File

@@ -82,3 +82,44 @@ describe('Tabs', () => {
} }
}); });
}); });
describe('Tabs — disabled state', () => {
const ITEMS_WITH_DISABLED = [
{ id: 'a', label: 'Alpha' },
{ id: 'b', label: 'Beta', disabled: true },
{ id: 'c', label: 'Gamma' },
];
it('click on disabled tab does not fire onChange', () => {
const onChange = vi.fn();
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="a" onChange={onChange} />);
fireEvent.click(screen.getByRole('tab', { name: /Beta/i }));
expect(onChange).not.toHaveBeenCalled();
});
it('has aria-disabled="true" on disabled tab', () => {
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="a" onChange={vi.fn()} />);
const btn = screen.getByRole('tab', { name: /Beta/i });
expect(btn).toHaveAttribute('aria-disabled', 'true');
});
it('applies tab-underline-disabled class on disabled tab', () => {
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="a" onChange={vi.fn()} />);
const btn = screen.getByRole('tab', { name: /Beta/i });
expect(btn).toHaveClass('tab-underline-disabled');
});
it('ArrowRight skips disabled tab (a → c, skipping b)', () => {
const onChange = vi.fn();
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="a" onChange={onChange} />);
fireEvent.keyDown(screen.getByRole('tab', { name: /Alpha/i }), { key: 'ArrowRight' });
expect(onChange).toHaveBeenCalledWith('c');
});
it('ArrowLeft skips disabled tab (c → a, skipping b)', () => {
const onChange = vi.fn();
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="c" onChange={onChange} />);
fireEvent.keyDown(screen.getByRole('tab', { name: /Gamma/i }), { key: 'ArrowLeft' });
expect(onChange).toHaveBeenCalledWith('a');
});
});