feat(m7-amend2): implicit lifecycle — writes drive state, no workflow UI
User: «Enlève également le workflow d'un test, quand on saisit des
informations côtés redteam cela signifie qu'il a été exécuté et donc
en attente d'une review blueteam.»
Backend (update_mission_test_fields)
- At the end of every PUT, inspect the touched-field set:
- any red write on state in {pending, skipped, blocked} → state=executed
+ auto-stamp executed_at=now() if absent
- any blue write on state=executed → state=reviewed_by_blue
- /transition endpoint kept for back-fill/admin use, not called from UI.
Frontend MissionTestPage
- Removed the transition-buttons header block and the `transition`
mutation. State pill stays as a passive indicator.
- New labels: "Not started" / "Awaiting review" / "Reviewed" describe
the implicit lifecycle, no longer exposing the state-machine concept.
E2E
- The SPA test that clicked `transition-executed` now verifies the
implicit promotion: typing red fields and saving flips the pill from
"Not started" → "Awaiting review", no button click required.
Spec
- §4 reword: "Cycle de vie implicite, piloté par les écritures" replaces
the old "Workflow par test instance" bullet.
Tests
- 3 new pytest: red_command-alone implicit execute + auto-stamp,
blue write promotes executed→reviewed, blue write on pending no-op.
- 142 pytest + 49 Playwright green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -315,9 +315,12 @@ export const EVIDENCE_ALLOWED_EXTENSIONS = [
|
||||
|
||||
export const EVIDENCE_MAX_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
// Post-amendement 2026-05-15 bis: the explicit workflow is gone from the
|
||||
// UI — the state column survives in the DB but the labels describe the
|
||||
// implicit lifecycle (driven by which side has written data).
|
||||
export const MISSION_TEST_STATE_LABEL: Record<MissionTestState, string> = {
|
||||
pending: 'Pending',
|
||||
executed: 'Executed',
|
||||
pending: 'Not started',
|
||||
executed: 'Awaiting review',
|
||||
reviewed_by_blue: 'Reviewed',
|
||||
skipped: 'Skipped',
|
||||
blocked: 'Blocked',
|
||||
|
||||
@@ -28,21 +28,13 @@ import { Card } from '@/components/ui/Card';
|
||||
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||||
import { Tag } from '@/components/ui/Tag';
|
||||
import { TextField } from '@/components/ui/TextField';
|
||||
import {
|
||||
ApiError,
|
||||
apiDelete,
|
||||
apiFetch,
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiPut,
|
||||
} from '@/lib/api';
|
||||
import { ApiError, apiDelete, apiFetch, apiGet, apiPut } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import {
|
||||
EVIDENCE_ALLOWED_EXTENSIONS,
|
||||
EVIDENCE_MAX_BYTES,
|
||||
MISSION_TEST_STATE_ACCENT,
|
||||
MISSION_TEST_STATE_LABEL,
|
||||
VALID_TEST_TRANSITIONS,
|
||||
missionKeys,
|
||||
missionTestKeys,
|
||||
type ActivityResponse,
|
||||
@@ -50,7 +42,6 @@ import {
|
||||
type DetectionLevelList,
|
||||
type MissionTestDetail,
|
||||
type MissionTestEvidence,
|
||||
type TestTransitionPayload,
|
||||
type UpdateMissionTestPayload,
|
||||
} from '@/lib/missions';
|
||||
|
||||
@@ -624,18 +615,6 @@ export function MissionTestPage() {
|
||||
|
||||
useActivityWatcher(missionId, testId, onActivityTouched);
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: (body: TestTransitionPayload) =>
|
||||
apiPost<MissionTestDetail>(
|
||||
`/missions/${missionId}/tests/${testId}/transition`,
|
||||
body,
|
||||
),
|
||||
onSuccess: (next) => {
|
||||
qc.setQueryData(missionTestKeys.detail(missionId, testId), next);
|
||||
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
|
||||
},
|
||||
});
|
||||
|
||||
const test = detail.data;
|
||||
|
||||
const perms = useMemo(() => {
|
||||
@@ -667,10 +646,6 @@ export function MissionTestPage() {
|
||||
return <Alert accent="red">Failed to load mission test.</Alert>;
|
||||
}
|
||||
|
||||
const allowedTransitions = VALID_TEST_TRANSITIONS[test.state] ?? [];
|
||||
const transitionErr =
|
||||
transition.error instanceof ApiError ? transition.error : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4" data-testid="mission-test-page">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
@@ -693,37 +668,18 @@ export function MissionTestPage() {
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
{/* Post-amendement 2026-05-15 bis: no transition buttons. The pill
|
||||
is a passive indicator of where the implicit lifecycle stands
|
||||
(driven by who has written data). */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span data-testid="state-pill">
|
||||
<Tag accent={MISSION_TEST_STATE_ACCENT[test.state]}>
|
||||
{MISSION_TEST_STATE_LABEL[test.state]}
|
||||
</Tag>
|
||||
</span>
|
||||
{allowedTransitions.map((target) => (
|
||||
<Button
|
||||
key={target}
|
||||
accent={MISSION_TEST_STATE_ACCENT[target]}
|
||||
variant="ghost"
|
||||
onClick={() => transition.mutate({ target_state: target })}
|
||||
disabled={transition.isPending}
|
||||
data-testid={`transition-${target}`}
|
||||
>
|
||||
→ {MISSION_TEST_STATE_LABEL[target]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{transitionErr && (
|
||||
<Alert accent="red">
|
||||
{transitionErr.payload &&
|
||||
typeof transitionErr.payload === 'object' &&
|
||||
'message' in (transitionErr.payload as Record<string, unknown>)
|
||||
? `${transitionErr.message} — ${(transitionErr.payload as { message?: string }).message}`
|
||||
: transitionErr.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{test.mitre_tags.map((tag) => (
|
||||
|
||||
Reference in New Issue
Block a user