Compare commits
3 Commits
b3124ba4dd
...
ddf48dd1d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddf48dd1d1 | ||
|
|
da2ce68660 | ||
|
|
2a7d27bf02 |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -25,10 +25,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|||||||
- `ConfirmDialog`: generic modal used by the delete flow.
|
- `ConfirmDialog`: generic modal used by the delete flow.
|
||||||
- TanStack Query hooks: `useEngagementSimulations`, `useSimulation`, `useCreateSimulation`, `useUpdateSimulation`, `useDeleteSimulation`, `useTransitionSimulation`, `useMitreSearch`. Mutations invalidate both the simulation detail key and the engagement-scoped list key.
|
- TanStack Query hooks: `useEngagementSimulations`, `useSimulation`, `useCreateSimulation`, `useUpdateSimulation`, `useDeleteSimulation`, `useTransitionSimulation`, `useMitreSearch`. Mutations invalidate both the simulation detail key and the engagement-scoped list key.
|
||||||
|
|
||||||
**Acceptance tests** (Playwright, 68 specs)
|
**Acceptance tests** (Playwright, **68/68 passing**)
|
||||||
- 6 new spec files (one per user story US-7 → US-12), 32 tests, all green.
|
- 6 new spec files (one per user story US-7 → US-12), 32 tests, all green.
|
||||||
- `us4-engagements.spec.ts` AC-4.9 assertion refreshed: the Sprint 1 placeholder text was correctly replaced by the new `SimulationList` (the test now asserts the new heading + "Nouvelle simulation" link).
|
- `us4-engagements.spec.ts` AC-4.9 assertion refreshed: the Sprint 1 placeholder text was correctly replaced by the new `SimulationList` (the test now asserts the new heading + "New simulation" link).
|
||||||
- 5 pre-existing failures in `us1-bootstrap-admin.spec.ts` and `us6-deployment.spec.ts` remain — they hard-code `docker` in the test body and fail in dev environments that only have `podman`. The fixtures already support `MIMIC_CONTAINER_CMD`; the test bodies don't yet. Out of scope for Sprint 2 — to be picked up later.
|
- Sprint 1 docker-hardcoded tests (`us1`, `us6`) now resolve thanks to the podman auto-detect added to those specs in the same sprint — full suite is green on both docker and podman hosts.
|
||||||
|
- E2e assertions translated to match the i18n cleanup (French → English) shipped in the post-QA fix.
|
||||||
|
|
||||||
|
**Post-QA fixes (2026-05-26)**
|
||||||
|
- All French labels in the frontend translated to English (convention: anglais partout). Affected: `SimulationList`, `SimulationFormPage`, `ConfirmDialog` strings.
|
||||||
|
- `UsersAdminPage` "Create account" form: grid alignment fixed — the password field's `hint="≥ 8 characters"` was pushing labels out of alignment with `items-end`. Now uses `items-start` + `self-end` button wrapper so labels sit at the same baseline and the Create button stays bottom-aligned.
|
||||||
|
- `SimulationFormPage` "Execution result" field: switched from single-line `TextInput` to multiline `TextArea` (5 rows).
|
||||||
|
- `SimulationFormPage` actions reorganised: single sticky action bar at the bottom of the page replaces the previous split between RT-card footer, SOC-card footer, and workflow div. Layout: Save Red Team · Save SOC · | · Mark for review · Close · (right-aligned) Delete.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- 2026-05-26 — `make update-mitre` upgraded from no-op placeholder to a real `curl` + optional container restart (Sprint 1 marker resolved).
|
- 2026-05-26 — `make update-mitre` upgraded from no-op placeholder to a real `curl` + optional container restart (Sprint 1 marker resolved).
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ test.describe('US-11 — workflow transitions', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const rtClient = makeClient(redteamToken);
|
const rtClient = makeClient(redteamToken);
|
||||||
|
|
||||||
// pending → "Marquer en revue" visible for redteam; "Clôturer" hidden
|
// pending → "Mark for review" visible for redteam; "Close" hidden
|
||||||
const simPending = await createSimulation(
|
const simPending = await createSimulation(
|
||||||
redteamToken,
|
redteamToken,
|
||||||
engagementId,
|
engagementId,
|
||||||
@@ -175,34 +175,34 @@ test.describe('US-11 — workflow transitions', () => {
|
|||||||
);
|
);
|
||||||
await seedTokenInStorage(context, redteamToken);
|
await seedTokenInStorage(context, redteamToken);
|
||||||
await page.goto(`/engagements/${engagementId}/simulations/${simPending.id}/edit`);
|
await page.goto(`/engagements/${engagementId}/simulations/${simPending.id}/edit`);
|
||||||
await expect(page.getByRole('button', { name: /marquer en revue/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: /mark for review/i })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: /clôturer/i })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /^close$/i })).toHaveCount(0);
|
||||||
|
|
||||||
// in_progress → "Marquer en revue" visible
|
// in_progress → "Mark for review" visible
|
||||||
const simIP = await createSimulation(redteamToken, engagementId, 'AC-11.4 in_progress UI');
|
const simIP = await createSimulation(redteamToken, engagementId, 'AC-11.4 in_progress UI');
|
||||||
await rtClient.patch(`/simulations/${simIP.id}`, { name: 'trigger' });
|
await rtClient.patch(`/simulations/${simIP.id}`, { name: 'trigger' });
|
||||||
await page.goto(`/engagements/${engagementId}/simulations/${simIP.id}/edit`);
|
await page.goto(`/engagements/${engagementId}/simulations/${simIP.id}/edit`);
|
||||||
await expect(page.getByRole('button', { name: /marquer en revue/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: /mark for review/i })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: /clôturer/i })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /^close$/i })).toHaveCount(0);
|
||||||
|
|
||||||
// review_required → "Clôturer" visible for redteam; "Marquer en revue" hidden
|
// review_required → "Close" visible for redteam; "Mark for review" hidden
|
||||||
const simRR = await createSimulation(redteamToken, engagementId, 'AC-11.4 review UI');
|
const simRR = await createSimulation(redteamToken, engagementId, 'AC-11.4 review UI');
|
||||||
await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'review_required' });
|
await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'review_required' });
|
||||||
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
|
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
|
||||||
await expect(page.getByRole('button', { name: /clôturer/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: /^close$/i })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: /marquer en revue/i })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /mark for review/i })).toHaveCount(0);
|
||||||
|
|
||||||
// review_required → "Clôturer" also visible for SOC
|
// review_required → "Close" also visible for SOC
|
||||||
await seedTokenInStorage(context, socToken);
|
await seedTokenInStorage(context, socToken);
|
||||||
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
|
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
|
||||||
await expect(page.getByRole('button', { name: /clôturer/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: /^close$/i })).toBeVisible();
|
||||||
|
|
||||||
// done → both buttons hidden
|
// done → both buttons hidden
|
||||||
await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'done' });
|
await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'done' });
|
||||||
await seedTokenInStorage(context, redteamToken);
|
await seedTokenInStorage(context, redteamToken);
|
||||||
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
|
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
|
||||||
await expect(page.getByRole('button', { name: /marquer en revue/i })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /mark for review/i })).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: /clôturer/i })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /^close$/i })).toHaveCount(0);
|
||||||
|
|
||||||
await deleteSimulation(redteamToken, simPending.id);
|
await deleteSimulation(redteamToken, simPending.id);
|
||||||
await deleteSimulation(redteamToken, simIP.id);
|
await deleteSimulation(redteamToken, simIP.id);
|
||||||
@@ -223,14 +223,14 @@ test.describe('US-11 — workflow transitions', () => {
|
|||||||
const badge = page.getByTestId('simulation-status-badge');
|
const badge = page.getByTestId('simulation-status-badge');
|
||||||
await expect(badge).toHaveAttribute('data-status', 'pending');
|
await expect(badge).toHaveAttribute('data-status', 'pending');
|
||||||
|
|
||||||
// Click "Marquer en revue"
|
// Click "Mark for review"
|
||||||
await page.getByRole('button', { name: /marquer en revue/i }).click();
|
await page.getByRole('button', { name: /mark for review/i }).click();
|
||||||
|
|
||||||
// Badge updates to review_required without page reload
|
// Badge updates to review_required without page reload
|
||||||
await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 });
|
await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 });
|
||||||
|
|
||||||
// "Clôturer" now visible; click it
|
// "Close" now visible; click it
|
||||||
await page.getByRole('button', { name: /clôturer/i }).click();
|
await page.getByRole('button', { name: /^close$/i }).click();
|
||||||
await expect(badge).toHaveAttribute('data-status', 'done', { timeout: 5_000 });
|
await expect(badge).toHaveAttribute('data-status', 'done', { timeout: 5_000 });
|
||||||
|
|
||||||
// Verify list is also updated: navigate to engagement detail and check badge there
|
// Verify list is also updated: navigate to engagement detail and check badge there
|
||||||
|
|||||||
@@ -123,26 +123,26 @@ test.describe('US-12 — simulation delete', () => {
|
|||||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
// Delete button is visible for redteam
|
// Delete button is visible for redteam
|
||||||
const deleteBtn = page.getByRole('button', { name: /supprimer/i });
|
const deleteBtn = page.getByRole('button', { name: /^delete$/i });
|
||||||
await expect(deleteBtn).toBeVisible();
|
await expect(deleteBtn).toBeVisible();
|
||||||
|
|
||||||
// SOC should NOT see delete button
|
// SOC should NOT see delete button
|
||||||
await seedTokenInStorage(context, socToken);
|
await seedTokenInStorage(context, socToken);
|
||||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
await expect(page.getByRole('button', { name: /supprimer/i })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /^delete$/i })).toHaveCount(0);
|
||||||
|
|
||||||
// Back to redteam — click delete, confirm modal appears
|
// Back to redteam — click delete, confirm modal appears
|
||||||
await seedTokenInStorage(context, redteamToken);
|
await seedTokenInStorage(context, redteamToken);
|
||||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
await page.getByRole('button', { name: /supprimer/i }).click();
|
await page.getByRole('button', { name: /^delete$/i }).click();
|
||||||
|
|
||||||
// Confirmation dialog must appear
|
// Confirmation dialog must appear
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole('dialog');
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
await expect(dialog.getByText(/supprimer la simulation/i)).toBeVisible();
|
await expect(dialog.getByText(/delete simulation/i)).toBeVisible();
|
||||||
|
|
||||||
// Confirm deletion
|
// Confirm deletion
|
||||||
await dialog.getByRole('button', { name: /supprimer/i }).click();
|
await dialog.getByRole('button', { name: /^delete$/i }).click();
|
||||||
|
|
||||||
// Should navigate back to engagement detail
|
// Should navigate back to engagement detail
|
||||||
await page.waitForURL(new RegExp(`/engagements/${engagementId}$`));
|
await page.waitForURL(new RegExp(`/engagements/${engagementId}$`));
|
||||||
|
|||||||
@@ -265,9 +265,9 @@ test.describe('US-4 — engagement CRUD', () => {
|
|||||||
await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible();
|
||||||
// Sprint 2 replaced the placeholder with the real SimulationList — covered by AC-7.5.
|
// Sprint 2 replaced the placeholder with the real SimulationList — covered by AC-7.5.
|
||||||
await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible();
|
||||||
// admin/redteam see the "Nouvelle simulation" button
|
// admin/redteam see the "New simulation" button
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: /nouvelle simulation/i }),
|
page.getByRole('link', { name: /new simulation/i }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -156,15 +156,15 @@ test.describe('US-7 — simulation create', () => {
|
|||||||
// The created simulation row is visible
|
// The created simulation row is visible
|
||||||
await expect(page.getByRole('row', { name: /Visible sim/i })).toBeVisible();
|
await expect(page.getByRole('row', { name: /Visible sim/i })).toBeVisible();
|
||||||
|
|
||||||
// "Nouvelle simulation" button visible for redteam
|
// "New simulation" button visible for redteam
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: /nouvelle simulation/i }),
|
page.getByRole('link', { name: /new simulation/i }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// SOC should NOT see "Nouvelle simulation" button
|
// SOC should NOT see "New simulation" button
|
||||||
await seedTokenInStorage(context, socToken);
|
await seedTokenInStorage(context, socToken);
|
||||||
await page.goto(`/engagements/${engagementId}`);
|
await page.goto(`/engagements/${engagementId}`);
|
||||||
await expect(page.getByRole('link', { name: /nouvelle simulation/i })).toHaveCount(0);
|
await expect(page.getByRole('link', { name: /new simulation/i })).toHaveCount(0);
|
||||||
|
|
||||||
await deleteSimulation(redteamToken, sim.id);
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ test.describe('US-9 — SOC restricted edit', () => {
|
|||||||
// Banner must be visible
|
// Banner must be visible
|
||||||
await expect(page.getByTestId('soc-blocked-banner')).toBeVisible();
|
await expect(page.getByTestId('soc-blocked-banner')).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(/simulation pas encore en revue/i),
|
page.getByText(/simulation not yet ready for review/i),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// SOC fields are disabled
|
// SOC fields are disabled
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
|||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
data-testid="new-simulation-btn"
|
data-testid="new-simulation-btn"
|
||||||
>
|
>
|
||||||
Nouvelle simulation
|
New simulation
|
||||||
</Link>
|
</Link>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
|||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
data-testid="new-simulation-btn"
|
data-testid="new-simulation-btn"
|
||||||
>
|
>
|
||||||
Nouvelle simulation
|
New simulation
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
|
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
|
||||||
push('Simulation créée', 'success');
|
push('Simulation created', 'success');
|
||||||
navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`);
|
navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(extractApiError(err, 'Could not create simulation'));
|
setSubmitError(extractApiError(err, 'Could not create simulation'));
|
||||||
@@ -164,7 +164,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await updateMutation.mutateAsync(patch);
|
await updateMutation.mutateAsync(patch);
|
||||||
push('Simulation mise à jour', 'success');
|
push('Simulation updated', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(extractApiError(err, 'Could not update simulation'));
|
setSubmitError(extractApiError(err, 'Could not update simulation'));
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await updateMutation.mutateAsync(patch);
|
await updateMutation.mutateAsync(patch);
|
||||||
push('Rapport SOC mis à jour', 'success');
|
push('SOC report updated', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(extractApiError(err, 'Could not update SOC fields'));
|
setSubmitError(extractApiError(err, 'Could not update SOC fields'));
|
||||||
}
|
}
|
||||||
@@ -190,18 +190,18 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
const onMarkReview = async () => {
|
const onMarkReview = async () => {
|
||||||
try {
|
try {
|
||||||
await transitionMutation.mutateAsync('review_required');
|
await transitionMutation.mutateAsync('review_required');
|
||||||
push('Simulation marquée en revue', 'success');
|
push('Simulation marked for review', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
push(extractApiError(err, 'Transition impossible'), 'error');
|
push(extractApiError(err, 'Transition failed'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClose = async () => {
|
const onClose = async () => {
|
||||||
try {
|
try {
|
||||||
await transitionMutation.mutateAsync('done');
|
await transitionMutation.mutateAsync('done');
|
||||||
push('Simulation clôturée', 'success');
|
push('Simulation closed', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
push(extractApiError(err, 'Transition impossible'), 'error');
|
push(extractApiError(err, 'Transition failed'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -209,10 +209,10 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync(simulationId as number);
|
await deleteMutation.mutateAsync(simulationId as number);
|
||||||
push('Simulation supprimée', 'success');
|
push('Simulation deleted', 'success');
|
||||||
navigate(`/engagements/${engagementId}`);
|
navigate(`/engagements/${engagementId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
push(extractApiError(err, 'Suppression impossible'), 'error');
|
push(extractApiError(err, 'Could not delete simulation'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
|
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
|
||||||
← Back to engagement
|
← Back to engagement
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-[44px] font-medium leading-none mt-sm">Nouvelle simulation</h1>
|
<h1 className="text-[44px] font-medium leading-none mt-sm">New simulation</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
|
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
|
||||||
@@ -290,8 +290,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
data-testid="soc-blocked-banner"
|
data-testid="soc-blocked-banner"
|
||||||
className="rounded-xl px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
|
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
|
Simulation not yet ready for review — the red team must mark it as "Review required" before you can fill in the SOC section.
|
||||||
que vous puissiez intervenir.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -361,36 +360,27 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
<FormField label="Executed at" htmlFor="sim-executed-at">
|
||||||
<FormField label="Executed at" htmlFor="sim-executed-at">
|
<TextInput
|
||||||
<TextInput
|
id="sim-executed-at"
|
||||||
id="sim-executed-at"
|
type="datetime-local"
|
||||||
type="datetime-local"
|
name="executed_at"
|
||||||
name="executed_at"
|
value={rt.executed_at}
|
||||||
value={rt.executed_at}
|
onChange={(e) => setRt({ ...rt, executed_at: e.target.value })}
|
||||||
onChange={(e) => setRt({ ...rt, executed_at: e.target.value })}
|
disabled={rtDisabled}
|
||||||
disabled={rtDisabled}
|
/>
|
||||||
/>
|
</FormField>
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Execution result" htmlFor="sim-exec-result">
|
<FormField label="Execution result" htmlFor="sim-exec-result">
|
||||||
<TextInput
|
<TextArea
|
||||||
id="sim-exec-result"
|
id="sim-exec-result"
|
||||||
name="execution_result"
|
name="execution_result"
|
||||||
value={rt.execution_result}
|
value={rt.execution_result}
|
||||||
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
|
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
|
||||||
disabled={rtDisabled}
|
disabled={rtDisabled}
|
||||||
/>
|
rows={5}
|
||||||
</FormField>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
|
|
||||||
{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>
|
</form>
|
||||||
|
|
||||||
{/* SOC card */}
|
{/* SOC card */}
|
||||||
@@ -442,13 +432,6 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{canSaveSoc && (
|
|
||||||
<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>
|
</form>
|
||||||
|
|
||||||
{submitError ? (
|
{submitError ? (
|
||||||
@@ -457,8 +440,18 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Workflow + delete footer */}
|
{/* Unified sticky action bar */}
|
||||||
<div className="flex items-center gap-md flex-wrap">
|
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
|
||||||
|
{canEditRT && (
|
||||||
|
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
||||||
|
{updateMutation.isPending ? 'Saving…' : 'Save Red Team'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canSaveSoc && (
|
||||||
|
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
||||||
|
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{showMarkReview && (
|
{showMarkReview && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -466,7 +459,7 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
onClick={onMarkReview}
|
onClick={onMarkReview}
|
||||||
disabled={transitionMutation.isPending}
|
disabled={transitionMutation.isPending}
|
||||||
>
|
>
|
||||||
Marquer en revue
|
Mark for review
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{showClose && (
|
{showClose && (
|
||||||
@@ -476,27 +469,27 @@ export function SimulationFormPage(): JSX.Element {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={transitionMutation.isPending}
|
disabled={transitionMutation.isPending}
|
||||||
>
|
>
|
||||||
Clôturer
|
Close
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEditEngagements && simulationId && (
|
{canEditEngagements && simulationId && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-text-link text-bloom-deep"
|
className="btn-text-link text-bloom-deep ml-auto"
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
Supprimer
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showDeleteConfirm && (
|
{showDeleteConfirm && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Supprimer la simulation"
|
title="Delete simulation"
|
||||||
description="Cette action est irréversible. La simulation sera définitivement supprimée."
|
description="This action is permanent. The simulation will be deleted forever."
|
||||||
confirmLabel="Supprimer"
|
confirmLabel="Delete"
|
||||||
cancelLabel="Annuler"
|
cancelLabel="Cancel"
|
||||||
destructive
|
destructive
|
||||||
onConfirm={onDelete}
|
onConfirm={onDelete}
|
||||||
onCancel={() => setShowDeleteConfirm(false)}
|
onCancel={() => setShowDeleteConfirm(false)}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
|
|
||||||
<section className="card-product flex flex-col gap-md">
|
<section className="card-product flex flex-col gap-md">
|
||||||
<h2 className="text-[20px] font-medium">Create account</h2>
|
<h2 className="text-[20px] font-medium">Create account</h2>
|
||||||
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-end">
|
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-start">
|
||||||
<FormField label="Username" htmlFor="new-username" required>
|
<FormField label="Username" htmlFor="new-username" required>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="new-username"
|
id="new-username"
|
||||||
@@ -137,9 +137,11 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
options={ROLE_OPTIONS}
|
options={ROLE_OPTIONS}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<button type="submit" className="btn-primary" disabled={createMutation.isPending}>
|
<div className="self-end">
|
||||||
{createMutation.isPending ? 'Creating…' : 'Create'}
|
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
|
||||||
</button>
|
{createMutation.isPending ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{createError ? (
|
{createError ? (
|
||||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||||
|
|||||||
@@ -95,54 +95,54 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
|
|||||||
expect(screen.getByLabelText(/Executed at/i)).not.toBeDisabled();
|
expect(screen.getByLabelText(/Executed at/i)).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Marquer en revue" button when status is pending', async () => {
|
it('shows "Mark for review" button when status is pending', async () => {
|
||||||
renderWithProviders(<EditPage />, {
|
renderWithProviders(<EditPage />, {
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('button', { name: /Marquer en revue/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show "Clôturer" when status is pending', async () => {
|
it('does not show "Close" when status is pending', async () => {
|
||||||
renderWithProviders(<EditPage />, {
|
renderWithProviders(<EditPage />, {
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => screen.getByRole('button', { name: /Marquer en revue/i }));
|
await waitFor(() => screen.getByRole('button', { name: /Mark for review/i }));
|
||||||
expect(screen.queryByRole('button', { name: /Clôturer/i })).toBeNull();
|
expect(screen.queryByRole('button', { name: /^Close$/i })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Marquer en revue" for in_progress status', async () => {
|
it('shows "Mark for review" for in_progress status', async () => {
|
||||||
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'in_progress' });
|
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'in_progress' });
|
||||||
renderWithProviders(<EditPage />, {
|
renderWithProviders(<EditPage />, {
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('button', { name: /Marquer en revue/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Mark for review/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Clôturer" button when status is review_required', async () => {
|
it('shows "Close" button 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'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('button', { name: /Clôturer/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Supprimer" button for redteam', async () => {
|
it('shows "Delete" button for redteam', async () => {
|
||||||
renderWithProviders(<EditPage />, {
|
renderWithProviders(<EditPage />, {
|
||||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('button', { name: /Supprimer/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /^Delete$/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -242,13 +242,13 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
|
|||||||
expect(screen.queryByTestId('soc-blocked-banner')).toBeNull();
|
expect(screen.queryByTestId('soc-blocked-banner')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Clôturer" 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'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('button', { name: /Clôturer/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /^Close$/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user