4221d2858b
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
- execution_start_time/execution_end_time/detection_time/containment_time were captured from a datetime-local input with no local->UTC conversion, then stored/displayed as if already UTC — off by the browser's offset. Frontend now converts properly in both directions; backend schemas defensively normalize any tz-aware input to naive UTC as well. - push_rt_submitted was pushing datetime.utcnow() (whenever the submit button was clicked) into Jira's RT Start/End Date fields instead of the operator-entered execution window. Now pushes the *first* round's execution start (recovered from round_history across reopens) and the *current* round's execution end. Removed push_rt_started, which only ever pushed a meaningless click-timestamp. - Reopening a test now adds a "reopened" Jira label (read-modify-write so existing labels aren't clobbered), on top of the existing round-archived comment and status push.
801 lines
31 KiB
Python
801 lines
31 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
|
|
# _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_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-01"
|
|
assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-01"
|
|
|
|
|
|
@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-01"
|
|
assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-05"
|
|
|
|
|
|
@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:
|
|
assert len(labels) == 2
|
|
|
|
|
|
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()
|