feat(m7): blue review fields + spec amendment + reviewer follow-ups
User feedback after the M7 ship: blue team's Excel workflow had 5 extra
fields we didn't capture. Per-test page also doesn't match their
workflow — they need a tabular view, one table per scenario.
Spec
- tasks/spec.md amended (`revised: 2026-05-15`): §4 in-scope, §F6, §8
model bullet. §F6 now pins the column matrix, single-row-edit
semantics, Esc-cancel, blur-confirm, and reconciles detection_level
as a pill inside the Commentaires cell (no 8th column).
- tasks/todo.md M7 section grew an "Amendement 2026-05-15" sub-block
tracking backend ☑ and frontend ☐.
Backend
- Migration c2a8f4b1d6e9: 5 nullable columns on mission_tests
(blue_log_source, blue_siem_logs, blue_incident_at,
blue_incident_number, blue_incident_recipient_email).
- _BLUE_FIELDS extended; update_mission_test_fields propagates each
field; MissionTestDetailView + MissionTestView (the nested view in
GET /missions/{id}) surface every annotation field, plus
last_actor_*, updated_at, detection_level_key — O(1) batch lookup
for detection-level keys and last-actor users keeps it scalable.
- UpdateMissionTestPayload accepts each field with length caps
(120/200_000/120/255).
Reviewer follow-ups applied
- blue_incident_at + executed_at now reject naïve datetimes
(_ensure_aware_datetime) — Postgres would otherwise interpret
them in the session TZ, defeating the M7 verbatim-time contract.
- blue_incident_recipient_email goes through a permissive RFC-shape
regex (_validate_email_shape) so internal/lab TLDs like .local
/ .corp / .test pass — Pydantic EmailStr is too strict (lessons.md
M2 trap).
- Project-wide: switched `e.errors()` to
`e.errors(include_context=False, include_url=False)` because the
AfterValidator-raised ValueError lands in ctx and Flask can't
serialize it.
Tests
- 5 new pytest cases: blue user writes the 5 new fields, red user is
individually 403'd on each, round-trip via GET, naïve datetime
rejected, email shape validated (.local accepted, bad shape 400).
- 138 pytest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -385,6 +385,142 @@ def test_blue_user_cannot_write_red_fields(client, admin_token, catalogue, blue_
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_blue_user_writes_new_blue_review_fields(
|
||||
client, admin_token, catalogue, blue_user
|
||||
):
|
||||
"""Post-M7 feedback fields: log source, SIEM excerpt, cyber-incident
|
||||
sub-record. All are blue-side, gated by `mission.write_blue_fields`."""
|
||||
mission = _make_mission(
|
||||
client, admin_token, name="m7-blue-extras",
|
||||
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||
)
|
||||
tid = _first_test_id(mission)
|
||||
body = {
|
||||
"blue_log_source": "EDR · CrowdStrike Falcon",
|
||||
"blue_siem_logs": "2026-05-15 10:30:42 WIN-DC01 evt=4688 cmd=powershell -enc ...",
|
||||
"blue_incident_at": "2026-05-15T11:00:00+00:00",
|
||||
"blue_incident_number": "INC-2026-1234",
|
||||
"blue_incident_recipient_email": "soc-night@metamorph.local",
|
||||
}
|
||||
r = client.put(
|
||||
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||
headers=_bearer(blue_user["token"]),
|
||||
json=body,
|
||||
)
|
||||
assert r.status_code == 200, r.get_data(as_text=True)
|
||||
out = r.get_json()
|
||||
assert out["blue_log_source"] == body["blue_log_source"]
|
||||
assert out["blue_siem_logs"] == body["blue_siem_logs"]
|
||||
assert out["blue_incident_at"].startswith("2026-05-15T11:00:00")
|
||||
assert out["blue_incident_number"] == body["blue_incident_number"]
|
||||
assert out["blue_incident_recipient_email"] == body["blue_incident_recipient_email"]
|
||||
|
||||
|
||||
def test_red_user_cannot_write_new_blue_review_fields(
|
||||
client, admin_token, catalogue, red_user
|
||||
):
|
||||
"""Each of the five new fields is classified as blue-side; a red-only
|
||||
caller must receive 403 individually for each one."""
|
||||
mission = _make_mission(
|
||||
client, admin_token, name="m7-blue-extras-perm",
|
||||
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||
)
|
||||
tid = _first_test_id(mission)
|
||||
bad_bodies = [
|
||||
{"blue_log_source": "Firewall"},
|
||||
{"blue_siem_logs": "log line"},
|
||||
{"blue_incident_at": "2026-05-15T11:00:00+00:00"},
|
||||
{"blue_incident_number": "INC-1"},
|
||||
{"blue_incident_recipient_email": "x@y.test"},
|
||||
]
|
||||
for body in bad_bodies:
|
||||
r = client.put(
|
||||
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||
headers=_bearer(red_user["token"]),
|
||||
json=body,
|
||||
)
|
||||
assert r.status_code == 403, (body, r.get_data(as_text=True))
|
||||
|
||||
|
||||
def test_blue_incident_at_rejects_naive_datetime(
|
||||
client, admin_token, catalogue, blue_user
|
||||
):
|
||||
"""A naïve datetime (no TZ offset) is rejected with 400 — Postgres would
|
||||
otherwise interpret it in the session TZ, defeating the M7 verbatim-time
|
||||
contract. Same rule applies to executed_at (covered by a separate red
|
||||
test below)."""
|
||||
mission = _make_mission(
|
||||
client, admin_token, name="m7-naive-incident",
|
||||
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||
)
|
||||
tid = _first_test_id(mission)
|
||||
r = client.put(
|
||||
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||
headers=_bearer(blue_user["token"]),
|
||||
json={"blue_incident_at": "2026-05-15T11:00:00"}, # no offset
|
||||
)
|
||||
assert r.status_code == 400, r.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_blue_incident_recipient_email_validates_shape(
|
||||
client, admin_token, catalogue, blue_user
|
||||
):
|
||||
"""Bad-shape email is rejected; well-formed internal address (`.local`,
|
||||
`.corp`) is accepted — we deliberately don't use Pydantic EmailStr
|
||||
because email-validator rejects internal TLDs."""
|
||||
mission = _make_mission(
|
||||
client, admin_token, name="m7-email-shape",
|
||||
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||
)
|
||||
tid = _first_test_id(mission)
|
||||
bad = client.put(
|
||||
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||
headers=_bearer(blue_user["token"]),
|
||||
json={"blue_incident_recipient_email": "not-an-email"},
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
|
||||
ok = client.put(
|
||||
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||
headers=_bearer(blue_user["token"]),
|
||||
json={"blue_incident_recipient_email": "soc@internal.local"},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
assert ok.get_json()["blue_incident_recipient_email"] == "soc@internal.local"
|
||||
|
||||
|
||||
def test_blue_review_fields_survive_round_trip_via_get(
|
||||
client, admin_token, catalogue, blue_user
|
||||
):
|
||||
"""After a PUT the same values must come back on a fresh GET — guards
|
||||
against a future serializer drift that would silently drop one of the
|
||||
new columns from the response."""
|
||||
mission = _make_mission(
|
||||
client, admin_token, name="m7-blue-extras-rt",
|
||||
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||
)
|
||||
tid = _first_test_id(mission)
|
||||
body = {
|
||||
"blue_log_source": "Proxy",
|
||||
"blue_siem_logs": "raw\n indented\nthird",
|
||||
"blue_incident_number": "INC-rt",
|
||||
}
|
||||
put_r = client.put(
|
||||
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||
headers=_bearer(blue_user["token"]),
|
||||
json=body,
|
||||
)
|
||||
assert put_r.status_code == 200
|
||||
get_r = client.get(
|
||||
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||
headers=_bearer(blue_user["token"]),
|
||||
)
|
||||
assert get_r.status_code == 200
|
||||
after = get_r.get_json()
|
||||
for k, v in body.items():
|
||||
assert after[k] == v, k
|
||||
|
||||
|
||||
def test_blue_user_writes_blue_fields_and_picks_detection_level(
|
||||
client, admin_token, catalogue, blue_user
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user