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:
Knacky
2026-05-15 16:09:26 +02:00
parent 40114d041b
commit 28b8855e88
7 changed files with 141 additions and 70 deletions

View File

@@ -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',

View File

@@ -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) => (