bdc6579c10
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.
983 lines
39 KiB
Python
983 lines
39 KiB
Python
"""Jira service unit tests (FASE-1.2)."""
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from app.domain.exceptions import InvalidOperationError
|
|
from app.models.jira_link import JiraLink, JiraLinkEntityType
|
|
from app.services import jira_service
|
|
|
|
|
|
def test_get_jira_client_raises_when_disabled(monkeypatch):
|
|
monkeypatch.setattr("app.services.jira_service.settings.JIRA_ENABLED", False)
|
|
jira_service._jira_client = None
|
|
with pytest.raises(InvalidOperationError, match="not enabled"):
|
|
jira_service.get_jira_client()
|
|
|
|
|
|
@patch("app.services.jira_service.get_jira_client")
|
|
def test_sync_aegis_to_jira_adds_comment(mock_get_client, db):
|
|
mock_jira = MagicMock()
|
|
mock_get_client.return_value = mock_jira
|
|
|
|
link = JiraLink(
|
|
entity_type=JiraLinkEntityType.test,
|
|
entity_id=uuid4(),
|
|
jira_issue_key="SEC-99",
|
|
)
|
|
jira_service.sync_aegis_to_jira(db, link, {"state": "validated", "result": "pass"})
|
|
|
|
mock_jira.issue_add_comment.assert_called_once()
|
|
args = mock_jira.issue_add_comment.call_args[0]
|
|
assert args[0] == "SEC-99"
|
|
assert "Aegis Sync Update" in args[1]
|
|
assert link.last_synced_at is not None
|
|
|
|
|
|
def _make_link(entity_id, issue_key="SEC-100"):
|
|
return JiraLink(
|
|
entity_type=JiraLinkEntityType.test,
|
|
entity_id=entity_id,
|
|
jira_issue_key=issue_key,
|
|
)
|
|
|
|
|
|
def _make_test(**overrides):
|
|
from app.models.test import Test
|
|
t = MagicMock(spec=Test)
|
|
t.id = uuid4()
|
|
t.name = "Test"
|
|
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
|
|
t.procedure_text = None
|
|
t.tool_used = None
|
|
# _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
|
|
t.blue_summary = None
|
|
t.remediation_steps = None
|
|
t.red_validation_status = None
|
|
t.blue_validation_status = None
|
|
t.red_validation_notes = None
|
|
t.blue_validation_notes = None
|
|
t.red_tech_assignee = None
|
|
t.blue_tech_assignee = None
|
|
for k, v in overrides.items():
|
|
setattr(t, k, v)
|
|
return t
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_pause_event_does_not_change_jira_status(mock_get_client, mock_configured, db):
|
|
"""A timer pause is not the same as a real Hold — it must never flip the
|
|
Jira issue to 'On Hold'. Only push_hold_event (a genuine Hold action) is
|
|
allowed to change status. Regression: pausing used to set 'On Hold'."""
|
|
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_pause_event(db, test, MagicMock(username="alice"), resuming=False)
|
|
|
|
mock_jira.set_issue_status.assert_not_called()
|
|
mock_jira.issue_add_comment.assert_called_once()
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_pause_event_resume_does_not_change_jira_status(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_pause_event(db, test, MagicMock(username="alice"), resuming=True)
|
|
|
|
mock_jira.set_issue_status.assert_not_called()
|
|
mock_jira.issue_add_comment.assert_called_once()
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_hold_event_sets_blocked(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_hold_event(db, test, MagicMock(username="alice"), resuming=False, reason="waiting on approval")
|
|
|
|
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "Blocked")
|
|
|
|
|
|
@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_posts_comment_without_changing_status(mock_get_client, mock_configured, db):
|
|
"""Archiving a round before reopen must leave a permanent Jira comment
|
|
(custom fields get overwritten by the next round, comments don't) but
|
|
must not touch the issue's status — that's push_test_event's job."""
|
|
from app.domain.enums import AttackSuccessResult
|
|
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=1,
|
|
attack_success=AttackSuccessResult.successful, red_summary="Got a shell.",
|
|
procedure_text="ran mimikatz", tool_used="mimikatz",
|
|
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()
|
|
|
|
|
|
@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(
|
|
"new_state,expected_status",
|
|
[
|
|
("draft", "To Do"),
|
|
("red_executing", "In Progress"),
|
|
("red_review", "Red Team test review"),
|
|
("blue_evaluating", "Queued Blue Team"),
|
|
("blue_review", "Blue Team test review"),
|
|
("in_review", "Validation"),
|
|
("validated", "Done"),
|
|
("rejected", "Rejected"),
|
|
("disputed", "Dispute"),
|
|
],
|
|
)
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_test_event_maps_every_state_to_jira_status(
|
|
mock_get_client, mock_configured, db, new_state, expected_status
|
|
):
|
|
"""Every TestState the Purple Team Jira workflow needs to reflect must
|
|
have a corresponding status transition — not just red_executing."""
|
|
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_test_event(db, test, MagicMock(username="alice", jira_account_id=None), new_state)
|
|
|
|
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, expected_status)
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_test_event_red_review_comment_includes_round_data(mock_get_client, mock_configured, db):
|
|
"""Every submission — not just ones that later get reopened — must leave
|
|
a data-bearing comment in Jira. Previously this comment was a generic
|
|
one-liner with no procedure/tool/result, so a round that got approved
|
|
(never archived via reopen) left no record of what was actually done."""
|
|
mock_jira = MagicMock()
|
|
mock_get_client.return_value = mock_jira
|
|
test = _make_test(
|
|
red_round_number=2,
|
|
procedure_text="ran mimikatz again", tool_used="mimikatz",
|
|
attack_success=MagicMock(value="successful"), red_summary="Got a shell this time.",
|
|
)
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
|
|
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "red_review")
|
|
|
|
comment = mock_jira.issue_add_comment.call_args[0][1]
|
|
assert "Round 2" in comment
|
|
assert "ran mimikatz again" in comment
|
|
assert "mimikatz" in comment
|
|
assert "successful" in comment
|
|
assert "Got a shell this time." 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_test_event_blue_review_comment_includes_round_data(mock_get_client, mock_configured, db):
|
|
mock_jira = MagicMock()
|
|
mock_get_client.return_value = mock_jira
|
|
test = _make_test(
|
|
blue_round_number=1,
|
|
detection_result=MagicMock(value="detected"),
|
|
containment_result=MagicMock(value="contained"),
|
|
blue_summary="Caught it via EDR.",
|
|
)
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
|
|
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "blue_review")
|
|
|
|
comment = mock_jira.issue_add_comment.call_args[0][1]
|
|
assert "Round 1" in comment
|
|
assert "detected" in comment
|
|
assert "contained" in comment
|
|
assert "Caught it via EDR." 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_test_event_clears_assignee_on_in_review(mock_get_client, mock_configured, db):
|
|
"""Regression: the previous reviewer's assignment must not linger on the
|
|
ticket once dual-validation starts — both leads act independently, so
|
|
there's no single owner."""
|
|
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_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "in_review")
|
|
|
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id=None)
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_test_event_clears_assignee_on_fresh_blue_queue_entry(mock_get_client, mock_configured, db):
|
|
"""A test reaching blue_evaluating for the first time (red just approved)
|
|
has no blue_tech_assignee yet — queued, no owner, must not stay stuck on
|
|
the red_lead who approved it."""
|
|
mock_jira = MagicMock()
|
|
mock_get_client.return_value = mock_jira
|
|
test = _make_test() # blue_tech_assignee defaults to None
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
|
|
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "blue_evaluating")
|
|
|
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id=None)
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_test_event_reassigns_blue_operator_on_reopen(mock_get_client, mock_configured, db):
|
|
"""Regression: reopening blue_review (rework) sends the test back to
|
|
blue_evaluating, but the SAME operator is still assigned in Aegis
|
|
(blue_tech_assignee isn't cleared, only blue_work_started_at is) — Jira
|
|
must give them the ticket back, not leave it on the blue_lead who
|
|
reopened it or clear it as if nobody owned it."""
|
|
mock_jira = MagicMock()
|
|
mock_get_client.return_value = mock_jira
|
|
from app.models.user import User
|
|
blue_tech = User(
|
|
username="bluetech-reopen", email="bluetech-reopen@test.com",
|
|
hashed_password="x", role="blue_tech", jira_account_id="blue-tech-account",
|
|
)
|
|
db.add(blue_tech)
|
|
db.commit()
|
|
test = _make_test(blue_tech_assignee=blue_tech.id)
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
|
|
jira_service.push_test_event(
|
|
db, test, MagicMock(username="bluelead", jira_account_id=None), "blue_evaluating",
|
|
)
|
|
|
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="blue-tech-account")
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_test_event_red_executing_assigns_actor_by_default(mock_get_client, mock_configured, db):
|
|
"""The normal start-execution path: no explicit assignee param, so the
|
|
acting operator (actor) is who gets the ticket."""
|
|
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_test_event(
|
|
db, test, MagicMock(username="redtech", jira_account_id="red-tech-account"), "red_executing",
|
|
)
|
|
|
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="red-tech-account")
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_test_event_red_executing_reassigns_original_operator_on_reopen(mock_get_client, mock_configured, db):
|
|
"""Regression: reopening red_review sends the test back to
|
|
red_executing, but *actor* here is the red_lead who reopened it, not
|
|
the operator who should keep working it. The caller passes the
|
|
original operator as the explicit assignee, which must win over actor."""
|
|
mock_jira = MagicMock()
|
|
mock_get_client.return_value = mock_jira
|
|
test = _make_test()
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
original_operator = MagicMock(username="redtech", jira_account_id="red-tech-account")
|
|
reopening_lead = MagicMock(username="redlead", jira_account_id="red-lead-account")
|
|
|
|
jira_service.push_test_event(
|
|
db, test, reopening_lead, "red_executing", assignee=original_operator,
|
|
)
|
|
|
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="red-tech-account")
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_test_event_reviewer_assignment_resolves_account_id_on_the_fly(
|
|
mock_get_client, mock_configured, db,
|
|
):
|
|
"""Regression: a blue_lead assigned as reviewer had never logged in
|
|
(jira_account_id was still NULL), so the Jira reassignment silently
|
|
no-op'd and the ticket stayed on the operator forever — while red_lead
|
|
worked purely by coincidence of already having logged in once. Every
|
|
assignment call site must attempt a live lookup, not just
|
|
push_assignee_update."""
|
|
from app.models.user import User
|
|
|
|
mock_jira = MagicMock()
|
|
mock_jira.user_find_by_user_string.return_value = [
|
|
{"accountId": "blue-lead-account", "emailAddress": "bluelead@test.com"},
|
|
]
|
|
mock_get_client.return_value = mock_jira
|
|
test = _make_test()
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
|
|
blue_lead = User(
|
|
username="bluelead-never-logged-in", email="bluelead@test.com",
|
|
hashed_password="x", role="blue_lead", jira_account_id=None,
|
|
)
|
|
db.add(blue_lead)
|
|
db.commit()
|
|
|
|
jira_service.push_test_event(db, test, MagicMock(username="bluetech"), "blue_review", assignee=blue_lead)
|
|
|
|
assert blue_lead.jira_account_id == "blue-lead-account"
|
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="blue-lead-account")
|
|
|
|
|
|
@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_work_started_sets_in_progress(mock_get_client, mock_configured, db):
|
|
"""blue_evaluating covers both 'queued, unclaimed' and 'actively being
|
|
worked' — the state alone can't distinguish them, so start_blue_work
|
|
must explicitly push In Progress rather than relying on the generic
|
|
state->status dispatch (which would incorrectly re-push Queued Blue Team)."""
|
|
mock_jira = MagicMock()
|
|
mock_get_client.return_value = mock_jira
|
|
test = _make_test()
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
|
|
actor = MagicMock(username="bob", jira_account_id="bob-account-id")
|
|
jira_service.push_bt_work_started(db, test, actor)
|
|
|
|
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "In Progress")
|
|
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_bt_started_sets_date_field_on_round_one(mock_get_client, mock_configured, db):
|
|
mock_jira = MagicMock()
|
|
mock_get_client.return_value = mock_jira
|
|
test = _make_test(blue_round_number=1)
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
|
|
jira_service.push_bt_started(db, test)
|
|
|
|
mock_jira.update_issue_field.assert_called_once()
|
|
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
|
assert jira_service.JIRA_FIELD_BT_START_DATE in 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_bt_started_preserves_round_one_date_on_reopen(mock_get_client, mock_configured, db):
|
|
"""Regression: mirrors the RT Start Date bug — BT Start Date must keep
|
|
showing round 1's real pickup date even after the operator re-claims
|
|
the test via 'Start Evaluation' on a later round."""
|
|
mock_jira = MagicMock()
|
|
mock_get_client.return_value = mock_jira
|
|
test = _make_test(blue_round_number=2)
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
|
|
jira_service.push_bt_started(db, test)
|
|
|
|
mock_jira.update_issue_field.assert_not_called()
|
|
|
|
|
|
@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"),
|
|
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()
|
|
|
|
jira_service.push_rt_submitted(db, test)
|
|
|
|
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
|
assert fields[jira_service.JIRA_FIELD_RT_END_DATE]
|
|
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-01T09:00:00.000+0000"
|
|
assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-01T11:00:00.000+0000"
|
|
|
|
|
|
@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-01T09:00:00.000+0000"
|
|
assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-05T10:00:00.000+0000"
|
|
|
|
|
|
@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_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_get_client.return_value = mock_jira
|
|
detect = datetime(2026, 1, 1, 12, 0, 0)
|
|
contain = datetime(2026, 1, 1, 13, 30, 0)
|
|
test = _make_test(
|
|
detection_result=MagicMock(value="detected"),
|
|
detection_time=detect,
|
|
containment_time=contain,
|
|
)
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
|
|
jira_service.push_bt_submitted(db, test)
|
|
|
|
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_TIME_TO_DETECT] == "2026-01-01T12:00:00.000+0000"
|
|
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.get_admin_jira_client")
|
|
def test_push_bt_submitted_includes_attack_contained(mock_get_client, mock_configured, db):
|
|
mock_jira = MagicMock()
|
|
mock_get_client.return_value = mock_jira
|
|
test = _make_test(containment_result=MagicMock(value="partially_contained"))
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
|
|
jira_service.push_bt_submitted(db, test)
|
|
|
|
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
|
assert fields[jira_service.JIRA_FIELD_ATTACK_CONTAINED] == {"value": "Partial"}
|
|
|
|
|
|
def test_update_test_fields_noop_when_not_configured(db):
|
|
test = _make_test()
|
|
# has_admin_jira_configured defaults to False without mocking — should
|
|
# silently no-op rather than raise.
|
|
jira_service._update_test_fields(db, test, {"customfield_1": "x"})
|
|
|
|
|
|
@patch("app.services.jira_service.get_jira_client")
|
|
def test_search_jira_issues_maps_fields(mock_get_client):
|
|
mock_jira = MagicMock()
|
|
mock_get_client.return_value = mock_jira
|
|
mock_jira.jql.return_value = {
|
|
"issues": [
|
|
{
|
|
"key": "TST-1",
|
|
"fields": {
|
|
"summary": "Test issue",
|
|
"status": {"name": "In Progress"},
|
|
"assignee": {"displayName": "Alice"},
|
|
"priority": {"name": "High"},
|
|
},
|
|
}
|
|
]
|
|
}
|
|
|
|
results = jira_service.search_jira_issues("project = TEST", max_results=5)
|
|
|
|
assert len(results) == 1
|
|
assert results[0]["issue_key"] == "TST-1"
|
|
assert results[0]["summary"] == "Test issue"
|
|
assert results[0]["status"] == "In Progress"
|
|
mock_jira.jql.assert_called_once()
|
|
|
|
|
|
def _make_technique(**overrides):
|
|
from app.models.technique import Technique
|
|
tech = MagicMock(spec=Technique)
|
|
tech.mitre_id = "T1003.006"
|
|
tech.name = "DCSync"
|
|
tech.tactic = "credential-access"
|
|
tech.severity = "high"
|
|
for k, v in overrides.items():
|
|
setattr(tech, k, v)
|
|
return tech
|
|
|
|
|
|
def _make_test_for_issue(**overrides):
|
|
from app.models.test import Test
|
|
t = MagicMock(spec=Test)
|
|
t.id = uuid4()
|
|
t.name = "Run DSInternals Get-ADReplAccount"
|
|
t.technique_id = uuid4()
|
|
t.procedure_text = "Invoke-DSInternals"
|
|
t.description = "desc"
|
|
t.platform = "Windows"
|
|
t.tool_used = "DSInternals"
|
|
t.data_classification = "pii"
|
|
for k, v in overrides.items():
|
|
setattr(t, k, v)
|
|
return t
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
|
|
@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_auto_create_test_issue_retries_without_unscreened_data_sensitivity_field(
|
|
mock_get_client, mock_parent, mock_project_key, mock_configured, db
|
|
):
|
|
# Regression: Jira rejects the WHOLE issue when the data sensitivity
|
|
# field isn't on the project's create screen, which would silently
|
|
# block every test's ticket creation. Must retry once without that field
|
|
# instead of losing the ticket.
|
|
mock_jira = MagicMock()
|
|
mock_jira.issue_create.side_effect = [
|
|
Exception(
|
|
f"Field '{jira_service.JIRA_FIELD_DATA_SENSITIVITY}' cannot be set. "
|
|
"It is not on the appropriate screen, or unknown."
|
|
),
|
|
{"key": "SEC-42", "id": "10042"},
|
|
]
|
|
mock_get_client.return_value = mock_jira
|
|
from app.models.user import User
|
|
test = _make_test_for_issue()
|
|
technique = _make_technique()
|
|
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
|
|
db.add(actor)
|
|
db.commit()
|
|
|
|
issue_key = jira_service.auto_create_test_issue(db, test, actor, technique=technique)
|
|
|
|
assert issue_key == "SEC-42"
|
|
assert mock_jira.issue_create.call_count == 2
|
|
# Both calls share the same `fields` dict object (mutated in place), so
|
|
# only the final state is inspectable here — confirming the retry
|
|
# dropped the field is what matters.
|
|
final_call_fields = mock_jira.issue_create.call_args_list[1].kwargs["fields"]
|
|
assert jira_service.JIRA_FIELD_DATA_SENSITIVITY not in final_call_fields
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"data_classification,expected_jira_value",
|
|
[
|
|
("public", "Public"),
|
|
("general_use", "General Use"),
|
|
("internal_use_only", "Internal Use Only"),
|
|
("trusted_people", "Trusted People"),
|
|
("customer_content", "Customer Content"),
|
|
("pii", "PII"),
|
|
],
|
|
)
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
|
|
@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_auto_create_test_issue_maps_data_classification_to_real_jira_field(
|
|
mock_get_client, mock_parent, mock_project_key, mock_configured, db,
|
|
data_classification, expected_jira_value,
|
|
):
|
|
# Regression: the field ID was wrong (customfield_11233, which doesn't
|
|
# exist in the project) and the classification scheme didn't match
|
|
# Jira's actual 6-option "Data Sensitivity" field at all.
|
|
mock_jira = MagicMock()
|
|
mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"}
|
|
mock_get_client.return_value = mock_jira
|
|
from app.models.user import User
|
|
test = _make_test_for_issue(data_classification=data_classification)
|
|
technique = _make_technique()
|
|
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
|
|
db.add(actor)
|
|
db.commit()
|
|
|
|
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
|
|
|
|
fields = mock_jira.issue_create.call_args.kwargs["fields"]
|
|
assert fields["customfield_11814"] == {"value": expected_jira_value}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"platform,expected_label",
|
|
[
|
|
("Windows", "windows"),
|
|
("linux", "linux"),
|
|
("macOS", "macos"),
|
|
("Azure AD", "azure-ad"),
|
|
(None, None),
|
|
],
|
|
)
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
|
|
@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_auto_create_test_issue_adds_platform_label(
|
|
mock_get_client, mock_parent, mock_project_key, mock_configured, db,
|
|
platform, expected_label,
|
|
):
|
|
mock_jira = MagicMock()
|
|
mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"}
|
|
mock_get_client.return_value = mock_jira
|
|
from app.models.user import User
|
|
test = _make_test_for_issue(platform=platform)
|
|
technique = _make_technique()
|
|
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
|
|
db.add(actor)
|
|
db.commit()
|
|
|
|
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
|
|
|
|
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
|
|
assert ["aegis", "T1003-006"] == labels[:2]
|
|
if expected_label:
|
|
assert expected_label in labels
|
|
else:
|
|
# Just "aegis", the technique ID, and "standalone-test" (no
|
|
# parent_ticket_override passed here, same as any non-campaign test).
|
|
assert len(labels) == 3
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
|
|
@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_auto_create_test_issue_labels_standalone_tests(
|
|
mock_get_client, mock_parent, mock_project_key, mock_configured, db,
|
|
):
|
|
"""A test created with no campaign parent ticket is standalone — tag it
|
|
so standalone tests are identifiable in Jira without cross-referencing
|
|
Aegis."""
|
|
mock_jira = MagicMock()
|
|
mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"}
|
|
mock_get_client.return_value = mock_jira
|
|
from app.models.user import User
|
|
test = _make_test_for_issue()
|
|
technique = _make_technique()
|
|
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
|
|
db.add(actor)
|
|
db.commit()
|
|
|
|
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
|
|
|
|
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
|
|
assert "standalone-test" in labels
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_auto_create_test_issue_does_not_label_campaign_tests_as_standalone(
|
|
mock_get_client, mock_project_key, mock_configured, db,
|
|
):
|
|
"""A campaign test passes parent_ticket_override — it must NOT get
|
|
"standalone-test", since it's nested under the campaign's ticket."""
|
|
mock_jira = MagicMock()
|
|
mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"}
|
|
mock_get_client.return_value = mock_jira
|
|
from app.models.user import User
|
|
test = _make_test_for_issue()
|
|
technique = _make_technique()
|
|
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
|
|
db.add(actor)
|
|
db.commit()
|
|
|
|
jira_service.auto_create_test_issue(
|
|
db, test, actor, technique=technique, parent_ticket_override="CAMP-1",
|
|
)
|
|
|
|
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
|
|
assert "standalone-test" not in labels
|
|
|
|
|
|
def _make_user(**overrides):
|
|
from app.models.user import User
|
|
u = MagicMock(spec=User)
|
|
u.username = "gerardo-ruiz"
|
|
u.email = "gerardo.ruiz@kaseya.com"
|
|
u.jira_account_id = None
|
|
u.jira_email = None
|
|
for k, v in overrides.items():
|
|
setattr(u, k, v)
|
|
return u
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_lookup_user_jira_account_id_calls_with_valid_kwargs(mock_get_client, mock_configured, db):
|
|
# Regression test: atlassian-python-api's user_find_by_user_string() takes
|
|
# `limit`, not `maxResults` — passing maxResults raised a TypeError that
|
|
# was silently swallowed, so the lookup always failed even with a valid
|
|
# email and a working admin Jira connection.
|
|
mock_jira = MagicMock()
|
|
mock_jira.user_find_by_user_string.return_value = [
|
|
{"emailAddress": "gerardo.ruiz@kaseya.com", "accountId": "abc123"}
|
|
]
|
|
mock_get_client.return_value = mock_jira
|
|
user = _make_user()
|
|
|
|
updated = jira_service.lookup_user_jira_account_id(db, user)
|
|
|
|
mock_jira.user_find_by_user_string.assert_called_once_with(
|
|
query="gerardo.ruiz@kaseya.com", limit=10
|
|
)
|
|
assert updated is True
|
|
assert user.jira_account_id == "abc123"
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_lookup_user_jira_account_id_prefers_jira_email_override(mock_get_client, mock_configured, db):
|
|
"""Regression: a user's real Atlassian email can differ from their Aegis
|
|
login email (e.g. jesus-huertas@kaseya.com in Aegis vs
|
|
jesus.rodenas@kaseya.com in Jira) — jira_email must take priority."""
|
|
mock_jira = MagicMock()
|
|
mock_jira.user_find_by_user_string.return_value = [
|
|
{"emailAddress": "jesus.rodenas@kaseya.com", "accountId": "jesus-account-id"}
|
|
]
|
|
mock_get_client.return_value = mock_jira
|
|
user = _make_user(email="jesus.huertas@kaseya.com", jira_email="jesus.rodenas@kaseya.com")
|
|
|
|
updated = jira_service.lookup_user_jira_account_id(db, user)
|
|
|
|
mock_jira.user_find_by_user_string.assert_called_once_with(
|
|
query="jesus.rodenas@kaseya.com", limit=10
|
|
)
|
|
assert updated is True
|
|
assert user.jira_account_id == "jesus-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_lookup_user_jira_account_id_no_match(mock_get_client, mock_configured, db):
|
|
mock_jira = MagicMock()
|
|
mock_jira.user_find_by_user_string.return_value = []
|
|
mock_get_client.return_value = mock_jira
|
|
user = _make_user()
|
|
|
|
updated = jira_service.lookup_user_jira_account_id(db, user)
|
|
|
|
assert updated is False
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_assignee_update_assigns_jira_issue(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()
|
|
assignee = _make_user(jira_account_id="account-42")
|
|
|
|
jira_service.push_assignee_update(db, test, assignee)
|
|
|
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="account-42")
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_assignee_update_noop_without_jira_account_id(mock_get_client, mock_configured, db):
|
|
mock_jira = MagicMock()
|
|
mock_jira.user_find_by_user_string.return_value = [] # fallback lookup finds nobody either
|
|
mock_get_client.return_value = mock_jira
|
|
test = _make_test()
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
assignee = _make_user(jira_account_id=None)
|
|
|
|
jira_service.push_assignee_update(db, test, assignee)
|
|
|
|
mock_jira.assign_issue.assert_not_called()
|
|
|
|
|
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
|
@patch("app.services.jira_service.get_admin_jira_client")
|
|
def test_push_assignee_update_looks_up_missing_account_id(mock_get_client, mock_configured, db):
|
|
"""Regression: a lead can assign someone who has never logged in, so
|
|
jira_account_id isn't populated yet. Must try a fresh lookup instead of
|
|
silently giving up, so the assignment still syncs immediately."""
|
|
mock_jira = MagicMock()
|
|
mock_jira.user_find_by_user_string.return_value = [
|
|
{"emailAddress": "gerardo.ruiz@kaseya.com", "accountId": "freshly-found-id"}
|
|
]
|
|
mock_get_client.return_value = mock_jira
|
|
test = _make_test()
|
|
link = _make_link(test.id)
|
|
db.add(link)
|
|
db.commit()
|
|
assignee = _make_user(jira_account_id=None)
|
|
|
|
jira_service.push_assignee_update(db, test, assignee)
|
|
|
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="freshly-found-id")
|
|
assert assignee.jira_account_id == "freshly-found-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_assignee_update_noop_without_link(mock_get_client, mock_configured, db):
|
|
mock_jira = MagicMock()
|
|
mock_get_client.return_value = mock_jira
|
|
test = _make_test()
|
|
assignee = _make_user(jira_account_id="account-42")
|
|
|
|
jira_service.push_assignee_update(db, test, assignee)
|
|
|
|
mock_jira.assign_issue.assert_not_called()
|