fix(jira): stop sending numeric hours to Time to Detect/Contain (datetime fields)
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Confirmed via issue_editmeta and a real production failure log: Time to Detect / Time to Contain are Jira datetime fields despite the name, not numeric duration fields. Pushing a computed hour count made Jira reject the entire fields payload (validated atomically), silently sinking BT End Date and Attack Detected on the same call even though only these two fields were actually invalid. Now pushes the real detection_time/ containment_time timestamps. Removed the now-unused _compute_hours.
This commit is contained in:
@@ -1246,14 +1246,6 @@ def _jira_datetime(dt: datetime) -> str:
|
|||||||
return dt.strftime("%Y-%m-%dT%H:%M:%S.000+0000")
|
return dt.strftime("%Y-%m-%dT%H:%M:%S.000+0000")
|
||||||
|
|
||||||
|
|
||||||
def _compute_hours(start: Optional[datetime], end: Optional[datetime]) -> Optional[float]:
|
|
||||||
"""Return the elapsed time between two timestamps in hours, or None if unavailable."""
|
|
||||||
if not start or not end:
|
|
||||||
return None
|
|
||||||
delta_hours = (end - start).total_seconds() / 3600
|
|
||||||
return round(delta_hours, 2) if delta_hours >= 0 else None
|
|
||||||
|
|
||||||
|
|
||||||
def _update_test_fields(db: Session, test: Test, fields: dict) -> None:
|
def _update_test_fields(db: Session, test: Test, fields: dict) -> None:
|
||||||
"""Best-effort update of custom fields on the Jira issue linked to *test*.
|
"""Best-effort update of custom fields on the Jira issue linked to *test*.
|
||||||
|
|
||||||
@@ -1343,7 +1335,15 @@ def push_bt_started(db: Session, test: Test) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def push_bt_submitted(db: Session, test: Test) -> None:
|
def push_bt_submitted(db: Session, test: Test) -> None:
|
||||||
"""Set BT End Date, Attack Detected/Contained, and time-to-detect/contain — Blue submits for review."""
|
"""Set BT End Date, Attack Detected/Contained, and Time to Detect/Contain — Blue submits for review.
|
||||||
|
|
||||||
|
Time to Detect / Time to Contain are Jira **datetime** fields (confirmed
|
||||||
|
via issue_editmeta), not numeric duration fields — despite the name.
|
||||||
|
This used to push a computed hour count, which Jira rejected outright;
|
||||||
|
since Jira validates the whole fields payload atomically, that one bad
|
||||||
|
field silently sank BT End Date and Attack Detected too. Now pushes the
|
||||||
|
actual detection_time/containment_time timestamps.
|
||||||
|
"""
|
||||||
fields: dict = {JIRA_FIELD_BT_END_DATE: _jira_datetime(datetime.utcnow())}
|
fields: dict = {JIRA_FIELD_BT_END_DATE: _jira_datetime(datetime.utcnow())}
|
||||||
|
|
||||||
detection_result = _enum_value(test.detection_result)
|
detection_result = _enum_value(test.detection_result)
|
||||||
@@ -1354,13 +1354,11 @@ def push_bt_submitted(db: Session, test: Test) -> None:
|
|||||||
if containment_result in _CONTAINMENT_TO_JIRA:
|
if containment_result in _CONTAINMENT_TO_JIRA:
|
||||||
fields[JIRA_FIELD_ATTACK_CONTAINED] = _select_field(_CONTAINMENT_TO_JIRA[containment_result])
|
fields[JIRA_FIELD_ATTACK_CONTAINED] = _select_field(_CONTAINMENT_TO_JIRA[containment_result])
|
||||||
|
|
||||||
time_to_detect = _compute_hours(test.execution_end_time, test.detection_time)
|
if test.detection_time:
|
||||||
if time_to_detect is not None:
|
fields[JIRA_FIELD_TIME_TO_DETECT] = _jira_datetime(test.detection_time)
|
||||||
fields[JIRA_FIELD_TIME_TO_DETECT] = time_to_detect
|
|
||||||
|
|
||||||
time_to_contain = _compute_hours(test.detection_time, test.containment_time)
|
if test.containment_time:
|
||||||
if time_to_contain is not None:
|
fields[JIRA_FIELD_TIME_TO_CONTAIN] = _jira_datetime(test.containment_time)
|
||||||
fields[JIRA_FIELD_TIME_TO_CONTAIN] = time_to_contain
|
|
||||||
|
|
||||||
_update_test_fields(db, test, fields)
|
_update_test_fields(db, test, fields)
|
||||||
|
|
||||||
|
|||||||
@@ -570,15 +570,18 @@ def test_push_rt_submitted_rt_start_date_survives_reopen(mock_get_client, mock_c
|
|||||||
|
|
||||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
@patch("app.services.jira_service.get_admin_jira_client")
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
def test_push_bt_submitted_includes_detection_and_hours(mock_get_client, mock_configured, db):
|
def test_push_bt_submitted_includes_detection_and_timestamps(mock_get_client, mock_configured, db):
|
||||||
|
"""Regression: Time to Detect / Time to Contain are Jira *datetime*
|
||||||
|
fields, not numeric hour counts — pushing a computed duration made Jira
|
||||||
|
reject the whole fields payload (atomic validation), silently sinking
|
||||||
|
BT End Date and Attack Detected too even though only these two were
|
||||||
|
actually wrong."""
|
||||||
mock_jira = MagicMock()
|
mock_jira = MagicMock()
|
||||||
mock_get_client.return_value = mock_jira
|
mock_get_client.return_value = mock_jira
|
||||||
exec_end = datetime(2026, 1, 1, 10, 0, 0)
|
|
||||||
detect = datetime(2026, 1, 1, 12, 0, 0)
|
detect = datetime(2026, 1, 1, 12, 0, 0)
|
||||||
contain = datetime(2026, 1, 1, 13, 30, 0)
|
contain = datetime(2026, 1, 1, 13, 30, 0)
|
||||||
test = _make_test(
|
test = _make_test(
|
||||||
detection_result=MagicMock(value="detected"),
|
detection_result=MagicMock(value="detected"),
|
||||||
execution_end_time=exec_end,
|
|
||||||
detection_time=detect,
|
detection_time=detect,
|
||||||
containment_time=contain,
|
containment_time=contain,
|
||||||
)
|
)
|
||||||
@@ -590,8 +593,8 @@ def test_push_bt_submitted_includes_detection_and_hours(mock_get_client, mock_co
|
|||||||
|
|
||||||
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
||||||
assert fields[jira_service.JIRA_FIELD_ATTACK_DETECTED] == {"value": "Yes"}
|
assert fields[jira_service.JIRA_FIELD_ATTACK_DETECTED] == {"value": "Yes"}
|
||||||
assert fields[jira_service.JIRA_FIELD_TIME_TO_DETECT] == 2.0
|
assert fields[jira_service.JIRA_FIELD_TIME_TO_DETECT] == "2026-01-01T12:00:00.000+0000"
|
||||||
assert fields[jira_service.JIRA_FIELD_TIME_TO_CONTAIN] == 1.5
|
assert fields[jira_service.JIRA_FIELD_TIME_TO_CONTAIN] == "2026-01-01T13:30:00.000+0000"
|
||||||
|
|
||||||
|
|
||||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user