Files
Aegis/backend/tests/test_jira_service.py
T
kitos 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
fix(jira): stop sending numeric hours to Time to Detect/Contain (datetime fields)
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.
2026-07-14 12:48:14 +02:00

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()