"""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_end_time = None t.detection_time = None t.containment_time = 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 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_sets_on_hold(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=False) mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "On Hold") 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_sets_in_progress(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_called_once_with(link.jira_issue_key, "In Progress") @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") @pytest.mark.parametrize( "new_state,expected_status", [ ("draft", "To-Do"), ("red_executing", "In Progress"), ("red_review", "RT 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_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() jira_service.push_bt_work_started(db, test, MagicMock(username="bob")) mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "In Progress") @patch("app.services.jira_service.has_admin_jira_configured", return_value=True) @patch("app.services.jira_service.get_admin_jira_client") def test_push_rt_started_sets_date_field(mock_get_client, mock_configured, db): mock_jira = MagicMock() mock_get_client.return_value = mock_jira test = _make_test() link = _make_link(test.id) db.add(link) db.commit() jira_service.push_rt_started(db, test) mock_jira.update_issue_field.assert_called_once() args, kwargs = mock_jira.update_issue_field.call_args assert args[0] == link.jira_issue_key assert jira_service.JIRA_FIELD_RT_START_DATE in kwargs["fields"] @patch("app.services.jira_service.has_admin_jira_configured", return_value=True) @patch("app.services.jira_service.get_admin_jira_client") def test_push_rt_submitted_includes_attack_success(mock_get_client, mock_configured, db): mock_jira = MagicMock() mock_get_client.return_value = mock_jira test = _make_test(attack_success=MagicMock(value="successful")) 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] == "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_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] == "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] == "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_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 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_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 assert user.jira_account_id is None