Files
Aegis/backend/tests/test_jira_service.py
T
kitos 814cfa9671
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
feat(jira): label standalone tests, include round data in submission comments
- Tests created without a campaign parent ticket now get a "standalone-test"
  label, so they're identifiable in Jira without cross-referencing Aegis.
- The comment posted on every red/blue submission was a generic one-liner
  with no data — only a reopened round got a full data dump (via
  push_round_archived), so a round that was approved outright left no
  record in Jira of what was actually done. Now every submission comment
  includes the round number, procedure/tool/attack success (red) or
  detection/containment result (blue), and summary.
2026-07-14 08:37:40 +02:00

909 lines
36 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_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_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_hours(mock_get_client, mock_configured, db):
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
exec_end = datetime(2026, 1, 1, 10, 0, 0)
detect = datetime(2026, 1, 1, 12, 0, 0)
contain = datetime(2026, 1, 1, 13, 30, 0)
test = _make_test(
detection_result=MagicMock(value="detected"),
execution_end_time=exec_end,
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] == 2.0
assert fields[jira_service.JIRA_FIELD_TIME_TO_CONTAIN] == 1.5
@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()