fix(m7): stamping executed_at no longer requires a prior state transition

User reported `HTTP 400 — executed_at can only be set when state is
executed/reviewed_by_blue` when typing the timestamp inline in the new
scenario table. The state-gate predates the simplified UX — it made
sense back when the workflow was "Mark executed button + override
toggle", but the user has since asked for a single freely-typeable
datetime input.

- update_mission_test_fields drops the state check. Stamping a non-null
  executed_at while state ∈ {pending, skipped, blocked} now auto-promotes
  the state to `executed` in the same write. The promotion is gated by
  the same mission.write_red_fields perm that executed_at already
  required — no privilege escalation.
- MissionTestPage.tsx drops the state-based UI gate on canEditExecutedAt;
  red perm alone now unlocks the input regardless of state.
- Replaced the old "rejection while pending" test with two new tests:
  pending→executed via inline stamp + blue 403, and skipped→executed via
  inline stamp.
- 139 pytest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-15 15:20:25 +02:00
parent 9fc78e0832
commit 40114d041b
4 changed files with 93 additions and 36 deletions

View File

@@ -569,18 +569,33 @@ def test_mark_executed_stamps_executed_at(
assert body["executed_at_overridden"] is False
def test_executed_at_override_requires_red_perm_and_state(
def test_red_setting_executed_at_on_pending_auto_transitions_to_executed(
client, admin_token, catalogue, red_user, blue_user
):
"""Post-amendement (2026-05-15): the red team should be able to stamp
executed_at inline without first POST-ing /transition. The service
auto-flips state pending→executed in the same write so the persisted
row stays internally consistent.
Blue still cannot touch executed_at (red-side field) — perm gating is
unchanged.
"""
mission = _make_mission(
client, admin_token, name="m7-override",
client, admin_token, name="m7-exec-at-pending",
scenario_id=catalogue["scenario"]["id"],
red_id=red_user["id"], blue_id=blue_user["id"],
)
tid = _first_test_id(mission)
# Override while still pending → invalid_request (no executed milestone yet).
bad = client.put(
# Sanity: starts as pending.
detail = client.get(
f"/api/v1/missions/{mission['id']}/tests/{tid}",
headers=_bearer(red_user["token"]),
)
assert detail.get_json()["state"] == "pending"
# Red stamps executed_at directly — must succeed and bump the state.
stamped = client.put(
f"/api/v1/missions/{mission['id']}/tests/{tid}",
headers=_bearer(red_user["token"]),
json={
@@ -588,39 +603,49 @@ def test_executed_at_override_requires_red_perm_and_state(
"executed_at_overridden": True,
},
)
assert bad.status_code == 400, bad.get_data(as_text=True)
assert stamped.status_code == 200, stamped.get_data(as_text=True)
body = stamped.get_json()
assert body["state"] == "executed"
assert body["executed_at"].startswith("2026-05-14T10:00:00")
assert body["executed_at_overridden"] is True
# Mark executed first so we're allowed to override.
client.post(
f"/api/v1/missions/{mission['id']}/tests/{tid}/transition",
headers=_bearer(red_user["token"]),
json={"target_state": "executed"},
)
# Blue cannot override (executed_at is a red field).
# Blue cannot touch executed_at — red-side field.
forbidden = client.put(
f"/api/v1/missions/{mission['id']}/tests/{tid}",
headers=_bearer(blue_user["token"]),
json={
"executed_at": "2026-05-14T10:00:00+00:00",
"executed_at_overridden": True,
},
json={"executed_at": "2026-05-14T11:00:00+00:00"},
)
assert forbidden.status_code == 403
# Red successfully overrides.
ok = client.put(
def test_red_setting_executed_at_from_skipped_state_auto_transitions(
client, admin_token, catalogue, red_user
):
"""Skipped → executed by stamping a timestamp: same implicit transition
so the operator who marked the test skipped by mistake can simply type
the actual execution time."""
mission = _make_mission(
client, admin_token, name="m7-exec-at-skipped",
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
)
tid = _first_test_id(mission)
# Move to skipped first.
skip = client.post(
f"/api/v1/missions/{mission['id']}/tests/{tid}/transition",
headers=_bearer(red_user["token"]),
json={"target_state": "skipped"},
)
assert skip.status_code == 200
assert skip.get_json()["state"] == "skipped"
# Stamp executed_at inline → should land in executed.
stamped = client.put(
f"/api/v1/missions/{mission['id']}/tests/{tid}",
headers=_bearer(red_user["token"]),
json={
"executed_at": "2026-05-14T10:00:00+00:00",
"executed_at_overridden": True,
},
json={"executed_at": "2026-05-14T12:00:00+00:00"},
)
assert ok.status_code == 200, ok.get_data(as_text=True)
body = ok.get_json()
assert body["executed_at_overridden"] is True
assert body["executed_at"].startswith("2026-05-14T10:00:00")
assert stamped.status_code == 200, stamped.get_data(as_text=True)
assert stamped.get_json()["state"] == "executed"
def test_state_machine_rejects_invalid_transitions(