"""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 t.system_gaps = 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_validated_says_both_leads_when_they_agree(mock_get_client, mock_configured, db): mock_jira = MagicMock() mock_get_client.return_value = mock_jira test = _make_test(red_validation_status="approved", blue_validation_status="approved") link = _make_link(test.id) db.add(link) db.commit() jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "validated") comment = mock_jira.issue_add_comment.call_args[0][1] assert "validated* by both leads" in comment assert "manager" not in comment.lower() @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_validated_flags_manager_override_when_leads_disagree(mock_get_client, mock_configured, db): """Regression: a disputed test resolved by a manager keeps the leads' original disagreeing votes — the comment must not claim 'both leads' agreed, and must carry the manager's own decision/notes.""" mock_jira = MagicMock() mock_get_client.return_value = mock_jira test = _make_test(red_validation_status="approved", blue_validation_status="rejected") link = _make_link(test.id) db.add(link) db.commit() jira_service.push_test_event( db, test, MagicMock(username="sergio", jira_account_id=None), "validated", extra={"Manager Decision": "sergio — test rejected"}, ) comment = mock_jira.issue_add_comment.call_args[0][1] assert "manager's decision" in comment.lower() assert "both leads" not in comment assert "Manager Decision:* sergio — test rejected" 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_rejected_flags_manager_override_when_leads_disagree(mock_get_client, mock_configured, db): mock_jira = MagicMock() mock_get_client.return_value = mock_jira test = _make_test(red_validation_status="rejected", blue_validation_status="approved") link = _make_link(test.id) db.add(link) db.commit() jira_service.push_test_event( db, test, MagicMock(username="sergio", jira_account_id=None), "rejected", extra={"Manager Decision": "sergio — needs full rework"}, ) comment = mock_jira.issue_add_comment.call_args[0][1] assert "manager's decision" in comment.lower() assert "must be reworked" not in comment assert "Manager Decision:* sergio — needs full rework" 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_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_includes_system_gap_and_labels_it(mock_get_client, mock_configured, db): """A Blue Lead flagging a capability gap must surface it in Jira — both in the comment (so the reason is visible) and as a label (so gap-flagged tickets are filterable without opening each one).""" mock_jira = MagicMock() mock_jira.issue.return_value = {"fields": {"labels": []}} mock_get_client.return_value = mock_jira test = _make_test(system_gaps="Missing EDR agent on host X") link = _make_link(test.id) db.add(link) db.commit() jira_service.push_test_event(db, test, MagicMock(username="bluelead", jira_account_id=None), "in_review") comment = mock_jira.issue_add_comment.call_args[0][1] assert "Missing EDR agent on host X" in comment mock_jira.update_issue_field.assert_called_once_with( link.jira_issue_key, fields={"labels": ["system-gap"]}, ) @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_in_review_without_gap_does_not_add_label(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_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "in_review") mock_jira.issue.assert_not_called() 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_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()