mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
fix(jira): surface dispatch failures (#11918)
This commit is contained in:
committed by
GitHub
parent
01c004a7af
commit
be78fe8374
@@ -0,0 +1 @@
|
||||
Jira dispatch task results now surface user-facing Jira failure messages
|
||||
@@ -14,6 +14,7 @@ from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
|
||||
from prowler.lib.outputs.csv.csv import CSV
|
||||
from prowler.lib.outputs.finding import Finding as FindingOutput
|
||||
from prowler.lib.outputs.html.html import HTML
|
||||
from prowler.lib.outputs.jira.exceptions.exceptions import JiraBaseException
|
||||
from prowler.lib.outputs.ocsf.ocsf import OCSF
|
||||
from prowler.providers.aws.aws_provider import AwsProvider
|
||||
from prowler.providers.aws.lib.s3.s3 import S3
|
||||
@@ -26,6 +27,8 @@ from tasks.utils import batched
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
JIRA_GENERIC_SEND_ERROR = "Failed to create Jira issue."
|
||||
|
||||
|
||||
def get_s3_client_from_integration(
|
||||
integration: Integration,
|
||||
@@ -483,6 +486,7 @@ def send_findings_to_jira(
|
||||
jira_integration = initialize_prowler_integration(integration)
|
||||
|
||||
num_tickets_created = 0
|
||||
error_messages = []
|
||||
for finding_id in finding_ids:
|
||||
with rls_transaction(tenant_id):
|
||||
finding_instance = (
|
||||
@@ -512,35 +516,54 @@ def send_findings_to_jira(
|
||||
recommendation = remediation.get("recommendation", {})
|
||||
remediation_code = remediation.get("code", {})
|
||||
|
||||
# Send the individual finding to Jira
|
||||
result = jira_integration.send_finding(
|
||||
check_id=finding_instance.check_id,
|
||||
check_title=check_metadata.get("checktitle", ""),
|
||||
severity=finding_instance.severity,
|
||||
status=finding_instance.status,
|
||||
status_extended=finding_instance.status_extended or "",
|
||||
provider=finding_instance.scan.provider.provider,
|
||||
region=region,
|
||||
resource_uid=resource_uid,
|
||||
resource_name=resource_name,
|
||||
risk=check_metadata.get("risk", ""),
|
||||
recommendation_text=recommendation.get("text", ""),
|
||||
recommendation_url=recommendation.get("url", ""),
|
||||
remediation_code_native_iac=remediation_code.get("nativeiac", ""),
|
||||
remediation_code_terraform=remediation_code.get("terraform", ""),
|
||||
remediation_code_cli=remediation_code.get("cli", ""),
|
||||
remediation_code_other=remediation_code.get("other", ""),
|
||||
resource_tags=resource_tags,
|
||||
compliance=finding_instance.compliance or {},
|
||||
project_key=project_key,
|
||||
issue_type=issue_type,
|
||||
)
|
||||
try:
|
||||
# Send the individual finding to Jira
|
||||
result = jira_integration.send_finding(
|
||||
check_id=finding_instance.check_id,
|
||||
check_title=check_metadata.get("checktitle", ""),
|
||||
severity=finding_instance.severity,
|
||||
status=finding_instance.status,
|
||||
status_extended=finding_instance.status_extended or "",
|
||||
provider=finding_instance.scan.provider.provider,
|
||||
region=region,
|
||||
resource_uid=resource_uid,
|
||||
resource_name=resource_name,
|
||||
risk=check_metadata.get("risk", ""),
|
||||
recommendation_text=recommendation.get("text", ""),
|
||||
recommendation_url=recommendation.get("url", ""),
|
||||
remediation_code_native_iac=remediation_code.get("nativeiac", ""),
|
||||
remediation_code_terraform=remediation_code.get("terraform", ""),
|
||||
remediation_code_cli=remediation_code.get("cli", ""),
|
||||
remediation_code_other=remediation_code.get("other", ""),
|
||||
resource_tags=resource_tags,
|
||||
compliance=finding_instance.compliance or {},
|
||||
project_key=project_key,
|
||||
issue_type=issue_type,
|
||||
)
|
||||
except JiraBaseException as error:
|
||||
error_message = error.message or JIRA_GENERIC_SEND_ERROR
|
||||
logger.exception(
|
||||
"Failed to send finding %s to Jira: %s", finding_id, error_message
|
||||
)
|
||||
error_messages.append(error_message)
|
||||
continue
|
||||
except Exception:
|
||||
logger.exception("Failed to send finding %s to Jira", finding_id)
|
||||
error_messages.append(JIRA_GENERIC_SEND_ERROR)
|
||||
continue
|
||||
|
||||
if result:
|
||||
num_tickets_created += 1
|
||||
else:
|
||||
logger.error(f"Failed to send finding {finding_id} to Jira")
|
||||
error_message = JIRA_GENERIC_SEND_ERROR
|
||||
logger.error(error_message)
|
||||
error_messages.append(error_message)
|
||||
|
||||
return {
|
||||
result = {
|
||||
"created_count": num_tickets_created,
|
||||
"failed_count": len(finding_ids) - num_tickets_created,
|
||||
}
|
||||
if error_messages:
|
||||
result["error"] = "; ".join(dict.fromkeys(error_messages))
|
||||
|
||||
return result
|
||||
|
||||
@@ -5,6 +5,10 @@ from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
from api.models import Integration
|
||||
from api.utils import prowler_integration_connection_test
|
||||
from django.db import OperationalError
|
||||
from prowler.lib.outputs.jira.exceptions.exceptions import (
|
||||
JiraRefreshTokenError,
|
||||
JiraRequiredCustomFieldsError,
|
||||
)
|
||||
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection
|
||||
from prowler.providers.common.models import Connection
|
||||
from tasks.jobs.integrations import (
|
||||
@@ -1830,10 +1834,213 @@ class TestJiraIntegration:
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert result == {"created_count": 2, "failed_count": 1}
|
||||
assert result == {
|
||||
"created_count": 2,
|
||||
"failed_count": 1,
|
||||
"error": "Failed to create Jira issue.",
|
||||
}
|
||||
|
||||
# Verify error was logged for the failed finding
|
||||
mock_logger.error.assert_called_with("Failed to send finding finding-2 to Jira")
|
||||
mock_logger.error.assert_called_with("Failed to create Jira issue.")
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.logger")
|
||||
def test_send_findings_to_jira_preserves_exception_message(
|
||||
self,
|
||||
mock_logger,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
mock_finding_model,
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""Test Jira send exceptions are returned for UI polling."""
|
||||
tenant_id = "tenant-123"
|
||||
integration_id = "integration-456"
|
||||
project_key = "PROJ"
|
||||
issue_type = "Task"
|
||||
finding_ids = ["finding-1"]
|
||||
error_message = "Jira project requires custom fields: Team is required"
|
||||
|
||||
mock_rls_transaction.return_value.__enter__ = MagicMock()
|
||||
mock_rls_transaction.return_value.__exit__ = MagicMock()
|
||||
|
||||
integration = MagicMock()
|
||||
mock_integration_model.objects.get.return_value = integration
|
||||
|
||||
mock_jira_integration = MagicMock()
|
||||
|
||||
mock_jira_integration.send_finding.side_effect = JiraRequiredCustomFieldsError(
|
||||
message=error_message
|
||||
)
|
||||
mock_initialize_integration.return_value = mock_jira_integration
|
||||
|
||||
finding = MagicMock()
|
||||
finding.id = "finding-1"
|
||||
finding.check_id = "check_001"
|
||||
finding.severity = "high"
|
||||
finding.status = "FAIL"
|
||||
finding.status_extended = "Resource is not compliant"
|
||||
finding.compliance = {}
|
||||
finding.resources.exists.return_value = False
|
||||
finding.resources.first.return_value = None
|
||||
finding.scan.provider.provider = "aws"
|
||||
finding.check_metadata = {
|
||||
"checktitle": "Check Title",
|
||||
"risk": "High risk",
|
||||
"remediation": {"recommendation": {}, "code": {}},
|
||||
}
|
||||
mock_select_related = mock_finding_model.all_objects.select_related.return_value
|
||||
mock_finding_query = mock_select_related.prefetch_related.return_value
|
||||
mock_finding_query.get.return_value = finding
|
||||
|
||||
result = send_findings_to_jira(
|
||||
tenant_id, integration_id, project_key, issue_type, finding_ids
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"created_count": 0,
|
||||
"failed_count": 1,
|
||||
"error": error_message,
|
||||
}
|
||||
mock_logger.exception.assert_called_with(
|
||||
"Failed to send finding %s to Jira: %s",
|
||||
"finding-1",
|
||||
error_message,
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.logger")
|
||||
def test_send_findings_to_jira_preserves_refresh_token_error_message(
|
||||
self,
|
||||
mock_logger,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
mock_finding_model,
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""Test Jira refresh token exceptions return their UI-friendly message."""
|
||||
tenant_id = "tenant-123"
|
||||
integration_id = "integration-456"
|
||||
project_key = "PROJ"
|
||||
issue_type = "Task"
|
||||
finding_ids = ["finding-1"]
|
||||
error_message = "Failed to refresh the access token"
|
||||
|
||||
mock_rls_transaction.return_value.__enter__ = MagicMock()
|
||||
mock_rls_transaction.return_value.__exit__ = MagicMock()
|
||||
|
||||
integration = MagicMock()
|
||||
mock_integration_model.objects.get.return_value = integration
|
||||
|
||||
mock_jira_integration = MagicMock()
|
||||
|
||||
mock_jira_integration.send_finding.side_effect = JiraRefreshTokenError(
|
||||
message=error_message
|
||||
)
|
||||
mock_initialize_integration.return_value = mock_jira_integration
|
||||
|
||||
finding = MagicMock()
|
||||
finding.id = "finding-1"
|
||||
finding.check_id = "check_001"
|
||||
finding.severity = "high"
|
||||
finding.status = "FAIL"
|
||||
finding.status_extended = "Resource is not compliant"
|
||||
finding.compliance = {}
|
||||
finding.resources.exists.return_value = False
|
||||
finding.resources.first.return_value = None
|
||||
finding.scan.provider.provider = "aws"
|
||||
finding.check_metadata = {
|
||||
"checktitle": "Check Title",
|
||||
"risk": "High risk",
|
||||
"remediation": {"recommendation": {}, "code": {}},
|
||||
}
|
||||
mock_select_related = mock_finding_model.all_objects.select_related.return_value
|
||||
mock_finding_query = mock_select_related.prefetch_related.return_value
|
||||
mock_finding_query.get.return_value = finding
|
||||
|
||||
result = send_findings_to_jira(
|
||||
tenant_id, integration_id, project_key, issue_type, finding_ids
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"created_count": 0,
|
||||
"failed_count": 1,
|
||||
"error": error_message,
|
||||
}
|
||||
mock_logger.exception.assert_called_with(
|
||||
"Failed to send finding %s to Jira: %s",
|
||||
"finding-1",
|
||||
error_message,
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.logger")
|
||||
def test_send_findings_to_jira_sanitizes_unexpected_exception_message(
|
||||
self,
|
||||
mock_logger,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
mock_finding_model,
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""Test unexpected Jira send exceptions do not leak raw details to UI."""
|
||||
tenant_id = "tenant-123"
|
||||
integration_id = "integration-456"
|
||||
project_key = "PROJ"
|
||||
issue_type = "Task"
|
||||
finding_ids = ["finding-1"]
|
||||
|
||||
mock_rls_transaction.return_value.__enter__ = MagicMock()
|
||||
mock_rls_transaction.return_value.__exit__ = MagicMock()
|
||||
|
||||
integration = MagicMock()
|
||||
mock_integration_model.objects.get.return_value = integration
|
||||
|
||||
mock_jira_integration = MagicMock()
|
||||
mock_jira_integration.send_finding.side_effect = Exception("token=secret-value")
|
||||
mock_initialize_integration.return_value = mock_jira_integration
|
||||
|
||||
finding = MagicMock()
|
||||
finding.id = "finding-1"
|
||||
finding.check_id = "check_001"
|
||||
finding.severity = "high"
|
||||
finding.status = "FAIL"
|
||||
finding.status_extended = "Resource is not compliant"
|
||||
finding.compliance = {}
|
||||
finding.resources.exists.return_value = False
|
||||
finding.resources.first.return_value = None
|
||||
finding.scan.provider.provider = "aws"
|
||||
finding.check_metadata = {
|
||||
"checktitle": "Check Title",
|
||||
"risk": "High risk",
|
||||
"remediation": {"recommendation": {}, "code": {}},
|
||||
}
|
||||
mock_select_related = mock_finding_model.all_objects.select_related.return_value
|
||||
mock_finding_query = mock_select_related.prefetch_related.return_value
|
||||
mock_finding_query.get.return_value = finding
|
||||
|
||||
result = send_findings_to_jira(
|
||||
tenant_id, integration_id, project_key, issue_type, finding_ids
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"created_count": 0,
|
||||
"failed_count": 1,
|
||||
"error": "Failed to create Jira issue.",
|
||||
}
|
||||
assert "secret-value" not in result["error"]
|
||||
mock_logger.exception.assert_called_with(
|
||||
"Failed to send finding %s to Jira", "finding-1"
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Jira issue creation failures now preserve safe structured response details from Jira
|
||||
@@ -51,6 +51,39 @@ class JiraConnection(Connection):
|
||||
issue_types: dict = None
|
||||
|
||||
|
||||
def _format_jira_issue_creation_error(response_json: object, status_code: int) -> str:
|
||||
"""Build a safe Jira issue creation error message from structured fields.
|
||||
|
||||
Args:
|
||||
response_json: Parsed Jira response body.
|
||||
status_code: HTTP status code returned by Jira.
|
||||
|
||||
Returns:
|
||||
Safe issue creation error message for user-facing propagation.
|
||||
"""
|
||||
message_parts = []
|
||||
|
||||
if not isinstance(response_json, dict):
|
||||
return f"Failed to create Jira issue: Jira returned status code {status_code}."
|
||||
|
||||
errors = response_json.get("errors")
|
||||
if isinstance(errors, dict):
|
||||
message_parts.extend(
|
||||
f"'{field}': '{message}'" for field, message in errors.items() if message
|
||||
)
|
||||
|
||||
error_messages = response_json.get("errorMessages")
|
||||
if isinstance(error_messages, list):
|
||||
message_parts.extend(str(message) for message in error_messages if message)
|
||||
elif isinstance(error_messages, str) and error_messages:
|
||||
message_parts.append(error_messages)
|
||||
|
||||
if message_parts:
|
||||
return f"Failed to create Jira issue: {'; '.join(message_parts)}"
|
||||
|
||||
return f"Failed to create Jira issue: Jira returned status code {status_code}."
|
||||
|
||||
|
||||
class MarkdownToADFConverter:
|
||||
"""Helper to convert Markdown strings into Atlassian Document Format blocks."""
|
||||
|
||||
@@ -2060,6 +2093,7 @@ class Jira:
|
||||
Raises:
|
||||
- JiraRefreshTokenError: Failed to refresh the access token
|
||||
- JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200
|
||||
- JiraNoTokenError: Failed to get an access token
|
||||
- JiraCreateIssueError: Failed to create an issue in Jira
|
||||
- JiraSendFindingsResponseError: Failed to send the finding to Jira
|
||||
- JiraRequiredCustomFieldsError: Jira project requires custom fields that are not supported
|
||||
@@ -2155,31 +2189,45 @@ class Jira:
|
||||
try:
|
||||
response_json = response.json()
|
||||
except (ValueError, requests.exceptions.JSONDecodeError):
|
||||
response_error = f"Failed to send finding: {response.status_code} - {response.text}"
|
||||
response_error = _format_jira_issue_creation_error(
|
||||
{}, response.status_code
|
||||
)
|
||||
logger.error(response_error)
|
||||
return False
|
||||
raise JiraSendFindingsResponseError(
|
||||
message=response_error, file=os.path.basename(__file__)
|
||||
)
|
||||
|
||||
# Check if the error is due to required custom fields
|
||||
if response.status_code == 400 and "errors" in response_json:
|
||||
if (
|
||||
response.status_code == 400
|
||||
and isinstance(response_json, dict)
|
||||
and "errors" in response_json
|
||||
):
|
||||
errors = response_json.get("errors", {})
|
||||
# Look for custom field errors (fields starting with "customfield_")
|
||||
custom_field_errors = {
|
||||
k: v for k, v in errors.items() if k.startswith("customfield_")
|
||||
}
|
||||
custom_field_errors = {}
|
||||
if isinstance(errors, dict):
|
||||
custom_field_errors = {
|
||||
k: v
|
||||
for k, v in errors.items()
|
||||
if k.startswith("customfield_")
|
||||
}
|
||||
if custom_field_errors:
|
||||
custom_fields_formatted = ", ".join(
|
||||
[f"'{k}': '{v}'" for k, v in custom_field_errors.items()]
|
||||
)
|
||||
logger.error(
|
||||
f"Jira project requires custom fields that are not supported: {custom_fields_formatted}"
|
||||
raise JiraRequiredCustomFieldsError(
|
||||
message=f"Jira project requires custom fields that are not supported: {custom_fields_formatted}",
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
return False
|
||||
|
||||
response_error = (
|
||||
f"Failed to send finding: {response.status_code} - {response_json}"
|
||||
response_error = _format_jira_issue_creation_error(
|
||||
response_json, response.status_code
|
||||
)
|
||||
logger.error(response_error)
|
||||
return False
|
||||
raise JiraSendFindingsResponseError(
|
||||
message=response_error, file=os.path.basename(__file__)
|
||||
)
|
||||
else:
|
||||
try:
|
||||
response_json = response.json()
|
||||
@@ -2191,13 +2239,17 @@ class Jira:
|
||||
return True
|
||||
except JiraRequiredCustomFieldsError as custom_fields_error:
|
||||
logger.error(f"Custom fields error: {custom_fields_error}")
|
||||
return False
|
||||
raise custom_fields_error
|
||||
except JiraSendFindingsResponseError as response_error:
|
||||
logger.error(f"Jira response error: {response_error}")
|
||||
raise response_error
|
||||
except JiraRefreshTokenError as refresh_error:
|
||||
logger.error(f"Token refresh error: {refresh_error}")
|
||||
return False
|
||||
raise refresh_error
|
||||
except JiraRefreshTokenResponseError as response_error:
|
||||
logger.error(f"Token response error: {response_error}")
|
||||
return False
|
||||
raise response_error
|
||||
except JiraNoTokenError as no_token_error:
|
||||
raise no_token_error
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send finding: {e}")
|
||||
return False
|
||||
|
||||
@@ -16,8 +16,11 @@ from prowler.lib.outputs.jira.exceptions.exceptions import (
|
||||
JiraGetProjectsError,
|
||||
JiraGetProjectsResponseError,
|
||||
JiraNoProjectsError,
|
||||
JiraNoTokenError,
|
||||
JiraRefreshTokenError,
|
||||
JiraRefreshTokenResponseError,
|
||||
JiraRequiredCustomFieldsError,
|
||||
JiraSendFindingsResponseError,
|
||||
JiraTestConnectionError,
|
||||
)
|
||||
from prowler.lib.outputs.jira.jira import Jira
|
||||
@@ -1692,7 +1695,7 @@ class TestJiraIntegration:
|
||||
mock_cloud_id,
|
||||
mock_get_access_token,
|
||||
):
|
||||
"""Test that send_finding returns False when the request fails."""
|
||||
"""Test that send_finding raises with Jira JSON error details."""
|
||||
# To disable vulture
|
||||
mock_cloud_id = mock_cloud_id
|
||||
mock_get_access_token = mock_get_access_token
|
||||
@@ -1702,19 +1705,66 @@ class TestJiraIntegration:
|
||||
# Mock failed response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.json.return_value = {"errors": {"summary": "Required field"}}
|
||||
mock_response.json.return_value = {
|
||||
"errors": {"Team": "Team is required."},
|
||||
"errorMessages": ["Field 'Team' cannot be set."],
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = self.jira_integration.send_finding(
|
||||
check_id="test-check",
|
||||
check_title="Test Finding",
|
||||
severity="High",
|
||||
status="FAIL",
|
||||
project_key="TEST",
|
||||
issue_type="Bug",
|
||||
)
|
||||
with pytest.raises(JiraSendFindingsResponseError) as error:
|
||||
self.jira_integration.send_finding(
|
||||
check_id="test-check",
|
||||
check_title="Test Finding",
|
||||
severity="High",
|
||||
status="FAIL",
|
||||
project_key="TEST",
|
||||
issue_type="Bug",
|
||||
)
|
||||
|
||||
assert result is False
|
||||
assert "Failed to create Jira issue" in str(error.value)
|
||||
assert "'Team': 'Team is required.'" in str(error.value)
|
||||
assert "Field 'Team' cannot be set." in str(error.value)
|
||||
mock_post.assert_called_once()
|
||||
|
||||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||||
@patch.object(
|
||||
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
|
||||
)
|
||||
@patch.object(Jira, "get_projects", return_value={"TEST": {"name": "Test Project"}})
|
||||
@patch.object(Jira, "get_available_issue_types", return_value=["Bug"])
|
||||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||||
def test_send_finding_response_error_without_json_body(
|
||||
self,
|
||||
mock_post,
|
||||
mock_get_issue_types,
|
||||
mock_get_projects,
|
||||
mock_cloud_id,
|
||||
mock_get_access_token,
|
||||
):
|
||||
"""Test send_finding raises with status-code context for non-JSON errors."""
|
||||
# To disable vulture
|
||||
mock_cloud_id = mock_cloud_id
|
||||
mock_get_access_token = mock_get_access_token
|
||||
mock_get_projects = mock_get_projects
|
||||
mock_get_issue_types = mock_get_issue_types
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 502
|
||||
mock_response.json.side_effect = ValueError("No JSON body")
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with pytest.raises(JiraSendFindingsResponseError) as error:
|
||||
self.jira_integration.send_finding(
|
||||
check_id="test-check",
|
||||
check_title="Test Finding",
|
||||
severity="High",
|
||||
status="FAIL",
|
||||
project_key="TEST",
|
||||
issue_type="Bug",
|
||||
)
|
||||
|
||||
assert "Failed to create Jira issue" in str(error.value)
|
||||
assert "Jira returned status code 502" in str(error.value)
|
||||
mock_post.assert_called_once()
|
||||
|
||||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||||
@@ -1732,7 +1782,7 @@ class TestJiraIntegration:
|
||||
mock_cloud_id,
|
||||
mock_get_access_token,
|
||||
):
|
||||
"""Test that send_finding returns False when custom fields cause an error."""
|
||||
"""Test that send_finding raises when custom fields cause an error."""
|
||||
# To disable vulture
|
||||
mock_cloud_id = mock_cloud_id
|
||||
mock_get_access_token = mock_get_access_token
|
||||
@@ -1750,18 +1800,89 @@ class TestJiraIntegration:
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = self.jira_integration.send_finding(
|
||||
check_id="test-check",
|
||||
check_title="Test Finding",
|
||||
severity="High",
|
||||
status="FAIL",
|
||||
project_key="TEST",
|
||||
issue_type="Bug",
|
||||
)
|
||||
with pytest.raises(JiraRequiredCustomFieldsError) as error:
|
||||
self.jira_integration.send_finding(
|
||||
check_id="test-check",
|
||||
check_title="Test Finding",
|
||||
severity="High",
|
||||
status="FAIL",
|
||||
project_key="TEST",
|
||||
issue_type="Bug",
|
||||
)
|
||||
|
||||
assert result is False
|
||||
assert "Jira project requires custom fields" in str(error.value)
|
||||
assert "customfield_10001" in str(error.value)
|
||||
mock_post.assert_called_once()
|
||||
|
||||
@patch.object(
|
||||
Jira,
|
||||
"get_access_token",
|
||||
side_effect=JiraRefreshTokenError(message="Failed to refresh the access token"),
|
||||
)
|
||||
def test_send_finding_reraises_refresh_token_error(self, mock_get_access_token):
|
||||
"""Test send_finding re-raises refresh token errors for API propagation."""
|
||||
# To disable vulture
|
||||
mock_get_access_token = mock_get_access_token
|
||||
|
||||
with pytest.raises(JiraRefreshTokenError) as error:
|
||||
self.jira_integration.send_finding(
|
||||
check_id="test-check",
|
||||
check_title="Test Finding",
|
||||
severity="High",
|
||||
status="FAIL",
|
||||
project_key="TEST",
|
||||
issue_type="Bug",
|
||||
)
|
||||
|
||||
assert error.value.message == "Failed to refresh the access token"
|
||||
|
||||
@patch.object(Jira, "get_access_token", return_value=None)
|
||||
def test_send_finding_reraises_no_token_error(self, mock_get_access_token):
|
||||
"""Test send_finding re-raises missing token errors for API propagation."""
|
||||
# To disable vulture
|
||||
mock_get_access_token = mock_get_access_token
|
||||
|
||||
with pytest.raises(JiraNoTokenError) as error:
|
||||
self.jira_integration.send_finding(
|
||||
check_id="test-check",
|
||||
check_title="Test Finding",
|
||||
severity="High",
|
||||
status="FAIL",
|
||||
project_key="TEST",
|
||||
issue_type="Bug",
|
||||
)
|
||||
|
||||
assert error.value.message == "No token was found"
|
||||
|
||||
@patch.object(
|
||||
Jira,
|
||||
"get_access_token",
|
||||
side_effect=JiraRefreshTokenResponseError(
|
||||
message="Failed to refresh the access token, response code did not match 200"
|
||||
),
|
||||
)
|
||||
def test_send_finding_reraises_refresh_token_response_error(
|
||||
self, mock_get_access_token
|
||||
):
|
||||
"""Test send_finding re-raises refresh token response errors for API propagation."""
|
||||
# To disable vulture
|
||||
mock_get_access_token = mock_get_access_token
|
||||
|
||||
with pytest.raises(JiraRefreshTokenResponseError) as error:
|
||||
self.jira_integration.send_finding(
|
||||
check_id="test-check",
|
||||
check_title="Test Finding",
|
||||
severity="High",
|
||||
status="FAIL",
|
||||
project_key="TEST",
|
||||
issue_type="Bug",
|
||||
)
|
||||
|
||||
assert (
|
||||
error.value.message
|
||||
== "Failed to refresh the access token, response code did not match 200"
|
||||
)
|
||||
|
||||
def test_get_headers_oauth_with_access_token(self):
|
||||
"""Test get_headers returns correct OAuth headers with access token."""
|
||||
self.jira_integration._using_basic_auth = False
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { pollTaskUntilSettledMock } = vi.hoisted(() => ({
|
||||
pollTaskUntilSettledMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/task/poll", () => ({
|
||||
pollTaskUntilSettled: pollTaskUntilSettledMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
import { pollJiraDispatchTask } from "./jira-dispatch";
|
||||
|
||||
describe("pollJiraDispatchTask", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return the backend error when a completed task has failed Jira dispatches", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: {
|
||||
created_count: 0,
|
||||
failed_count: 1,
|
||||
error: "Jira project requires custom fields: Team is required",
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-123");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Jira project requires custom fields: Team is required",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return a fallback error when a completed task has failures without an error", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: {
|
||||
created_count: 0,
|
||||
failed_count: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-123");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Failed to create Jira issue.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return a plural fallback error when a completed task has multiple failures without an error", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: {
|
||||
created_count: 0,
|
||||
failed_count: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-123");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Failed to create 3 Jira issues.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should surface task failure result errors", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "failed",
|
||||
result: {
|
||||
error: "Jira credentials are invalid.",
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-123");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Jira credentials are invalid.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return success when a completed task has no failures", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: {
|
||||
created_count: 1,
|
||||
failed_count: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-123");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: "Finding successfully sent to Jira!",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return a fallback error when no Jira issue was created", async () => {
|
||||
// Given
|
||||
pollTaskUntilSettledMock.mockResolvedValue({
|
||||
ok: true,
|
||||
state: "completed",
|
||||
result: {
|
||||
created_count: 0,
|
||||
failed_count: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await pollJiraDispatchTask("task-123");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Failed to create Jira issue.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -159,12 +159,18 @@ export const pollJiraDispatchTask = async (
|
||||
const jiraResult = result as JiraTaskResult | undefined;
|
||||
|
||||
if (state === "completed") {
|
||||
if (!jiraResult?.error) {
|
||||
const createdCount = jiraResult?.created_count ?? 0;
|
||||
const failedCount = jiraResult?.failed_count ?? 0;
|
||||
if (!jiraResult?.error && failedCount === 0 && createdCount > 0) {
|
||||
return { success: true, message: "Finding successfully sent to Jira!" };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: jiraResult?.error || "Failed to create Jira issue.",
|
||||
error:
|
||||
jiraResult?.error ||
|
||||
(failedCount > 1
|
||||
? `Failed to create ${failedCount} Jira issues.`
|
||||
: "Failed to create Jira issue."),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Jira dispatch polling now reports failed issue creation tasks instead of treating partial failures as successful
|
||||
@@ -64,6 +64,8 @@ export interface JiraDispatchResponse {
|
||||
message?: string;
|
||||
issue_url?: string;
|
||||
issue_key?: string;
|
||||
created_count?: number;
|
||||
failed_count?: number;
|
||||
} | null;
|
||||
task_args: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
|
||||
Reference in New Issue
Block a user