Files
Aegis/backend/tests/test_jira_service.py
T
kitos a46aa157f3
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
fix(assign): exclude admin from assignable operators; sync Jira on assign even for first-time users
Admin can no longer be picked as a red_tech/blue_tech assignee — it
administers the site, it doesn't operate tests. Applied to the
eligible-assignee list on both the picker UI and the assign endpoint's
validation, and dropped from the GET /users/operators pool.

push_assignee_update() now falls back to a fresh Jira account-id lookup
when the assignee hasn't logged in yet (so jira_account_id is still
empty), instead of silently no-op'ing — assignment now syncs to Jira
immediately regardless of whether the assignee has ever logged into
Aegis before.
2026-07-10 13:08:23 +02:00

538 lines
20 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_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] == {"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_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
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
@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()