fix(m7): red write always lands on executed + state pill out of Commentaires
User reported two issues on the scenario table:
1. After filling only red_command + executed_at, the pill read
"Reviewed" instead of "Awaiting review". Cause: the auto-promotion
was gated on `state in {pending, skipped, blocked}`, so a test that
blue had already reviewed stayed in `reviewed_by_blue` after a red
edit. That contradicts the implicit lifecycle (red modifying a
reviewed test invalidates the review).
2. The state pill lived inside the Commentaires column, polluting a
cell meant for blue-team comments.
Fixes
- backend/app/services/mission_tests.py: any red-side write now lands
the state on `executed` unconditionally (including from
`reviewed_by_blue`). Same-PUT red+blue still flows
executed → reviewed_by_blue.
- frontend/src/pages/MissionScenarioTable.tsx: state pill moves from
CommentairesCell to the Test column (top of the row, above the
snapshot name + MITRE chips). Commentaires now holds only the
detection_level pill + blue_comment_md.
Tests
- New pytest: `test_red_write_on_reviewed_reverts_to_executed` —
reviewed_by_blue + red edit ⇒ executed. 143 pytest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -611,13 +611,17 @@ def update_mission_test_fields(
|
|||||||
|
|
||||||
# Implicit lifecycle (post-amendement 2026-05-15 bis): the explicit
|
# Implicit lifecycle (post-amendement 2026-05-15 bis): the explicit
|
||||||
# workflow is gone from the UI. A write to ANY red field implies the
|
# workflow is gone from the UI. A write to ANY red field implies the
|
||||||
# test was executed (auto-stamp executed_at if the operator didn't
|
# test was executed AND the review (if any) is potentially stale —
|
||||||
# supply one); a write to ANY blue field on an executed test implies
|
# so the state always lands on `executed`, even from `reviewed_by_blue`
|
||||||
# the blue team reviewed it. The /transition endpoint stays for
|
# (red just changed something, blue needs to re-review). A write to
|
||||||
# back-fill but is no longer the primary path.
|
# ANY blue field on an executed test implies the blue team reviewed
|
||||||
|
# it. Note that a same-PUT red+blue (e.g. an admin filling both
|
||||||
|
# sides) flows `* → executed → reviewed_by_blue` and lands on
|
||||||
|
# `reviewed_by_blue`. The /transition endpoint stays for back-fill
|
||||||
|
# but is no longer the primary path.
|
||||||
red_touched = bool(touched & _RED_FIELDS)
|
red_touched = bool(touched & _RED_FIELDS)
|
||||||
blue_touched = bool(touched & _BLUE_FIELDS)
|
blue_touched = bool(touched & _BLUE_FIELDS)
|
||||||
if red_touched and test.state in {"pending", "skipped", "blocked"}:
|
if red_touched:
|
||||||
if test.executed_at is None:
|
if test.executed_at is None:
|
||||||
test.executed_at = datetime.now(tz=timezone.utc)
|
test.executed_at = datetime.now(tz=timezone.utc)
|
||||||
test.executed_at_overridden = False
|
test.executed_at_overridden = False
|
||||||
|
|||||||
@@ -644,6 +644,41 @@ def test_red_writing_any_red_field_implicitly_executes_and_stamps(
|
|||||||
assert body["executed_at_overridden"] is False
|
assert body["executed_at_overridden"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_write_on_reviewed_reverts_to_executed(
|
||||||
|
client, admin_token, catalogue, red_user, blue_user
|
||||||
|
):
|
||||||
|
"""When red modifies a test that blue had already reviewed, the state
|
||||||
|
reverts to `executed` (awaiting re-review). The red edits invalidate
|
||||||
|
the previous review — user feedback 2026-05-15 ter."""
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-red-after-review",
|
||||||
|
scenario_id=catalogue["scenario"]["id"],
|
||||||
|
red_id=red_user["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
# Red executes.
|
||||||
|
client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={"red_command": "first attempt"},
|
||||||
|
)
|
||||||
|
# Blue reviews — state becomes reviewed_by_blue.
|
||||||
|
blue_done = client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(blue_user["token"]),
|
||||||
|
json={"blue_comment_md": "alert raised"},
|
||||||
|
)
|
||||||
|
assert blue_done.get_json()["state"] == "reviewed_by_blue"
|
||||||
|
# Red modifies their command — state should drop back to executed.
|
||||||
|
re_red = client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={"red_command": "second attempt"},
|
||||||
|
)
|
||||||
|
assert re_red.status_code == 200
|
||||||
|
assert re_red.get_json()["state"] == "executed"
|
||||||
|
|
||||||
|
|
||||||
def test_blue_writing_any_blue_field_promotes_executed_to_reviewed(
|
def test_blue_writing_any_blue_field_promotes_executed_to_reviewed(
|
||||||
client, admin_token, catalogue, red_user, blue_user
|
client, admin_token, catalogue, red_user, blue_user
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -168,17 +168,16 @@ function CommentairesCell({
|
|||||||
test: MissionTest;
|
test: MissionTest;
|
||||||
detectionLevels: DetectionLevel[];
|
detectionLevels: DetectionLevel[];
|
||||||
}) {
|
}) {
|
||||||
|
// Commentaires is blue-team content only — the workflow state lives in
|
||||||
|
// the Test column (cf. user feedback 2026-05-15 ter).
|
||||||
const lvl = detectionLevels.find((l) => l.id === test.detection_level_id);
|
const lvl = detectionLevels.find((l) => l.id === test.detection_level_id);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex gap-1 flex-wrap">
|
|
||||||
<Tag accent={MISSION_TEST_STATE_ACCENT[test.state]}>
|
|
||||||
{MISSION_TEST_STATE_LABEL[test.state]}
|
|
||||||
</Tag>
|
|
||||||
{lvl && (
|
{lvl && (
|
||||||
|
<div>
|
||||||
<Tag accent={(lvl.color_token as 'red') ?? 'cyan'}>{lvl.label_en}</Tag>
|
<Tag accent={(lvl.color_token as 'red') ?? 'cyan'}>{lvl.label_en}</Tag>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{test.blue_comment_md && (
|
{test.blue_comment_md && (
|
||||||
<p className="font-mono text-3xs text-text whitespace-pre-wrap">
|
<p className="font-mono text-3xs text-text whitespace-pre-wrap">
|
||||||
{truncate(test.blue_comment_md, 220)}
|
{truncate(test.blue_comment_md, 220)}
|
||||||
@@ -371,6 +370,11 @@ export function MissionScenarioTable({
|
|||||||
>
|
>
|
||||||
<td className="py-2 px-2 text-text-bright">
|
<td className="py-2 px-2 text-text-bright">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
<div>
|
||||||
|
<Tag accent={MISSION_TEST_STATE_ACCENT[t.state]}>
|
||||||
|
{MISSION_TEST_STATE_LABEL[t.state]}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
<span>{t.snapshot_name}</span>
|
<span>{t.snapshot_name}</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{t.mitre_tags.slice(0, 3).map((tag) => (
|
{t.mitre_tags.slice(0, 3).map((tag) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user