fix(jira): store execution times as real UTC, fix RT date fields, label reopens
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
- execution_start_time/execution_end_time/detection_time/containment_time were captured from a datetime-local input with no local->UTC conversion, then stored/displayed as if already UTC — off by the browser's offset. Frontend now converts properly in both directions; backend schemas defensively normalize any tz-aware input to naive UTC as well. - push_rt_submitted was pushing datetime.utcnow() (whenever the submit button was clicked) into Jira's RT Start/End Date fields instead of the operator-entered execution window. Now pushes the *first* round's execution start (recovered from round_history across reopens) and the *current* round's execution end. Removed push_rt_started, which only ever pushed a meaningless click-timestamp. - Reopening a test now adds a "reopened" Jira label (read-modify-write so existing labels aren't clobbered), on top of the existing round-archived comment and status push.
This commit is contained in:
@@ -53,9 +53,12 @@ def _make_test(**overrides):
|
||||
t.attack_success = None
|
||||
t.detection_result = None
|
||||
t.containment_result = None
|
||||
t.execution_start_time = None
|
||||
t.execution_end_time = None
|
||||
t.detection_time = None
|
||||
t.containment_time = None
|
||||
t.red_round_number = 1
|
||||
t.blue_round_number = 1
|
||||
# _build_state_comment() appends these raw (not via an f-string) when
|
||||
# truthy, so an un-nulled MagicMock attribute breaks "\n".join(lines).
|
||||
t.red_summary = None
|
||||
@@ -143,16 +146,55 @@ def test_push_round_archived_posts_comment_without_changing_status(mock_get_clie
|
||||
review_notes="Evidence was incomplete, redo with screenshots",
|
||||
)
|
||||
mock_jira = MagicMock()
|
||||
mock_jira.issue.return_value = {"fields": {"labels": []}}
|
||||
mock_get_client.return_value = mock_jira
|
||||
|
||||
jira_service.push_round_archived(db, test, MagicMock(username="redlead"), round_data=round_data)
|
||||
|
||||
mock_jira.set_issue_status.assert_not_called()
|
||||
mock_jira.issue_add_comment.assert_called_once()
|
||||
comment = mock_jira.issue_add_comment.call_args[0][1]
|
||||
assert "Round 1" in comment
|
||||
assert "Got a shell." in comment
|
||||
assert "Evidence was incomplete" in comment
|
||||
|
||||
|
||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||
@patch("app.services.jira_service.get_admin_jira_client")
|
||||
def test_push_round_archived_adds_reopened_label_without_clobbering_others(mock_get_client, mock_configured, db):
|
||||
from app.models.test_round_history import TestRoundHistory
|
||||
|
||||
test = _make_test()
|
||||
link = _make_link(test.id)
|
||||
db.add(link)
|
||||
db.commit()
|
||||
|
||||
round_data = TestRoundHistory(test_id=test.id, team="blue", round_number=1)
|
||||
mock_jira = MagicMock()
|
||||
mock_jira.issue.return_value = {"fields": {"labels": ["security-review"]}}
|
||||
mock_get_client.return_value = mock_jira
|
||||
|
||||
jira_service.push_round_archived(db, test, MagicMock(username="bluelead"), round_data=round_data)
|
||||
|
||||
mock_jira.update_issue_field.assert_called_once_with(
|
||||
link.jira_issue_key, fields={"labels": ["security-review", "reopened"]},
|
||||
)
|
||||
|
||||
|
||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||
@patch("app.services.jira_service.get_admin_jira_client")
|
||||
def test_push_round_archived_does_not_duplicate_reopened_label(mock_get_client, mock_configured, db):
|
||||
from app.models.test_round_history import TestRoundHistory
|
||||
|
||||
test = _make_test()
|
||||
link = _make_link(test.id)
|
||||
db.add(link)
|
||||
db.commit()
|
||||
|
||||
round_data = TestRoundHistory(test_id=test.id, team="red", round_number=2)
|
||||
mock_jira = MagicMock()
|
||||
mock_jira.issue.return_value = {"fields": {"labels": ["reopened"]}}
|
||||
mock_get_client.return_value = mock_jira
|
||||
|
||||
jira_service.push_round_archived(db, test, MagicMock(username="redlead"), round_data=round_data)
|
||||
|
||||
mock_jira.update_issue_field.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -316,30 +358,16 @@ def test_push_bt_work_started_sets_in_progress(mock_get_client, mock_configured,
|
||||
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="bob-account-id")
|
||||
|
||||
|
||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||
@patch("app.services.jira_service.get_admin_jira_client")
|
||||
def test_push_rt_started_sets_date_field(mock_get_client, mock_configured, db):
|
||||
mock_jira = MagicMock()
|
||||
mock_get_client.return_value = mock_jira
|
||||
test = _make_test()
|
||||
link = _make_link(test.id)
|
||||
db.add(link)
|
||||
db.commit()
|
||||
|
||||
jira_service.push_rt_started(db, test)
|
||||
|
||||
mock_jira.update_issue_field.assert_called_once()
|
||||
args, kwargs = mock_jira.update_issue_field.call_args
|
||||
assert args[0] == link.jira_issue_key
|
||||
assert jira_service.JIRA_FIELD_RT_START_DATE in kwargs["fields"]
|
||||
|
||||
|
||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||
@patch("app.services.jira_service.get_admin_jira_client")
|
||||
def test_push_rt_submitted_includes_attack_success(mock_get_client, mock_configured, db):
|
||||
mock_jira = MagicMock()
|
||||
mock_get_client.return_value = mock_jira
|
||||
test = _make_test(attack_success=MagicMock(value="successful"))
|
||||
test = _make_test(
|
||||
attack_success=MagicMock(value="successful"),
|
||||
execution_start_time=datetime(2026, 2, 1, 8, 0, 0),
|
||||
execution_end_time=datetime(2026, 2, 1, 9, 0, 0),
|
||||
)
|
||||
link = _make_link(test.id)
|
||||
db.add(link)
|
||||
db.commit()
|
||||
@@ -351,6 +379,70 @@ def test_push_rt_submitted_includes_attack_success(mock_get_client, mock_configu
|
||||
assert fields[jira_service.JIRA_FIELD_ATTACK_SUCCESS] == {"value": "Yes"}
|
||||
|
||||
|
||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||
@patch("app.services.jira_service.get_admin_jira_client")
|
||||
def test_push_rt_submitted_uses_execution_times_not_click_time(mock_get_client, mock_configured, db):
|
||||
"""Regression: this used to push datetime.utcnow() (whenever the submit
|
||||
button was clicked) instead of the operator-entered execution window."""
|
||||
mock_jira = MagicMock()
|
||||
mock_get_client.return_value = mock_jira
|
||||
test = _make_test(
|
||||
execution_start_time=datetime(2026, 3, 1, 9, 0, 0),
|
||||
execution_end_time=datetime(2026, 3, 1, 11, 0, 0),
|
||||
)
|
||||
link = _make_link(test.id)
|
||||
db.add(link)
|
||||
db.commit()
|
||||
|
||||
jira_service.push_rt_submitted(db, test)
|
||||
|
||||
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
||||
assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01"
|
||||
assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-01"
|
||||
|
||||
|
||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||
@patch("app.services.jira_service.get_admin_jira_client")
|
||||
def test_push_rt_submitted_rt_start_date_survives_reopen(mock_get_client, mock_configured, db):
|
||||
"""RT Start Date must keep showing round 1's execution start even after
|
||||
a reopen resets the live execution_start_time for round 2 — otherwise
|
||||
Jira loses track of when the test was originally attempted."""
|
||||
from app.models.technique import Technique
|
||||
from app.models.test import Test as TestModel
|
||||
from app.models.test_round_history import TestRoundHistory
|
||||
|
||||
# A real, committed Test row is required here (unlike the other tests in
|
||||
# this file) because test_round_history.test_id has a real FK to tests.id.
|
||||
technique = Technique(mitre_id="T9997", name="RT Date Test Technique", tactic="execution", platforms=["linux"])
|
||||
db.add(technique)
|
||||
db.commit()
|
||||
real_test = TestModel(technique_id=technique.id, name="RT date fixture")
|
||||
db.add(real_test)
|
||||
db.commit()
|
||||
|
||||
mock_jira = MagicMock()
|
||||
mock_get_client.return_value = mock_jira
|
||||
test = _make_test(
|
||||
id=real_test.id,
|
||||
red_round_number=2,
|
||||
execution_start_time=datetime(2026, 3, 5, 8, 0, 0), # round 2's start
|
||||
execution_end_time=datetime(2026, 3, 5, 10, 0, 0),
|
||||
)
|
||||
link = _make_link(test.id)
|
||||
db.add(link)
|
||||
db.add(TestRoundHistory(
|
||||
test_id=test.id, team="red", round_number=1,
|
||||
execution_start_time=datetime(2026, 3, 1, 9, 0, 0), # round 1's start
|
||||
))
|
||||
db.commit()
|
||||
|
||||
jira_service.push_rt_submitted(db, test)
|
||||
|
||||
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
||||
assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01"
|
||||
assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-05"
|
||||
|
||||
|
||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||
@patch("app.services.jira_service.get_admin_jira_client")
|
||||
def test_push_bt_submitted_includes_detection_and_hours(mock_get_client, mock_configured, db):
|
||||
|
||||
Reference in New Issue
Block a user