mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-19 18:04:17 +00:00
3202 lines
122 KiB
Python
3202 lines
122 KiB
Python
import base64
|
||
import hashlib
|
||
from dataclasses import FrozenInstanceError
|
||
from datetime import datetime, timedelta
|
||
from types import SimpleNamespace
|
||
from typing import List, Optional
|
||
from unittest.mock import MagicMock, PropertyMock, patch
|
||
from urllib.parse import parse_qs, urlparse
|
||
|
||
import pytest
|
||
import requests
|
||
from freezegun import freeze_time
|
||
|
||
from prowler.lib.outputs.jira.exceptions.exceptions import (
|
||
JiraAuthenticationError,
|
||
JiraBasicAuthError,
|
||
JiraCreateIssueError,
|
||
JiraGetAccessTokenError,
|
||
JiraGetAvailableIssueTypesError,
|
||
JiraGetCloudIDError,
|
||
JiraGetProjectsError,
|
||
JiraGetProjectsResponseError,
|
||
JiraNoProjectsError,
|
||
JiraRefreshTokenError,
|
||
JiraRefreshTokenResponseError,
|
||
JiraRequiredCustomFieldsError,
|
||
JiraTestConnectionError,
|
||
)
|
||
from prowler.lib.outputs.jira.jira import Jira, MarkdownToADFConverter
|
||
from prowler.lib.outputs.jira.models import (
|
||
JiraCreationOutcome,
|
||
JiraCreationResult,
|
||
JiraIssueLookupOutcome,
|
||
JiraIssueReference,
|
||
JiraIssueSearchMatch,
|
||
JiraIssueSearchOutcome,
|
||
JiraIssueSearchResult,
|
||
JiraIssueStatusResult,
|
||
)
|
||
|
||
TEST_DATETIME = "2023-01-01T12:01:01+00:00"
|
||
|
||
|
||
class TestMarkdownToADFConverter:
|
||
def setup_method(self):
|
||
self.converter = MarkdownToADFConverter()
|
||
|
||
def test_inline_code_nested_in_strong_has_only_code_mark(self):
|
||
result = self.converter.convert("**before `code` after**")
|
||
|
||
assert result[0]["content"] == [
|
||
{"type": "text", "text": "before ", "marks": [{"type": "strong"}]},
|
||
{"type": "text", "text": "code", "marks": [{"type": "code"}]},
|
||
{"type": "text", "text": " after", "marks": [{"type": "strong"}]},
|
||
]
|
||
|
||
def test_inline_code_nested_in_emphasis_has_only_code_mark(self):
|
||
result = self.converter.convert("*before `code` after*")
|
||
|
||
assert result[0]["content"] == [
|
||
{"type": "text", "text": "before ", "marks": [{"type": "em"}]},
|
||
{"type": "text", "text": "code", "marks": [{"type": "code"}]},
|
||
{"type": "text", "text": " after", "marks": [{"type": "em"}]},
|
||
]
|
||
|
||
def test_inline_code_in_link_preserves_link_and_code_marks(self):
|
||
result = self.converter.convert("[`code`](https://example.com)")
|
||
|
||
assert result[0]["content"][0]["marks"] == [
|
||
{"type": "link", "attrs": {"href": "https://example.com"}},
|
||
{"type": "code"},
|
||
]
|
||
|
||
def test_inline_code_in_emphasized_link_preserves_link_and_code_marks(self):
|
||
result = self.converter.convert("*[`code`](https://example.com)*")
|
||
|
||
assert result[0]["content"][0]["marks"] == [
|
||
{"type": "link", "attrs": {"href": "https://example.com"}},
|
||
{"type": "code"},
|
||
]
|
||
|
||
def test_standalone_inline_code_has_code_mark(self):
|
||
result = self.converter.convert("`code`")
|
||
|
||
assert result[0]["content"][0]["marks"] == [{"type": "code"}]
|
||
|
||
|
||
@pytest.mark.parametrize("missing_field", ["issue_key", "issue_id", "issue_url"])
|
||
def test_confirmed_creation_requires_complete_identity(missing_field):
|
||
identity = {
|
||
"issue_key": "SEC-1",
|
||
"issue_id": "10001",
|
||
"issue_url": "https://example.atlassian.net/browse/SEC-1",
|
||
}
|
||
identity[missing_field] = None
|
||
|
||
with pytest.raises(ValueError, match="requires an issue key, ID, and URL"):
|
||
JiraCreationResult(JiraCreationOutcome.CONFIRMED_SUCCESS, **identity)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("instance", "field"),
|
||
[
|
||
(JiraCreationResult(JiraCreationOutcome.UNCERTAIN), "error_code"),
|
||
(JiraIssueReference("10001", "SEC-1"), "issue_key"),
|
||
(
|
||
JiraIssueStatusResult(
|
||
JiraIssueReference("10001", "SEC-1"), JiraIssueLookupOutcome.OPEN
|
||
),
|
||
"status",
|
||
),
|
||
(JiraIssueSearchMatch("10001", "SEC-1", "https://example"), "issue_key"),
|
||
(JiraIssueSearchResult(JiraIssueSearchOutcome.SUCCESS), "matches"),
|
||
],
|
||
)
|
||
def test_jira_result_models_are_immutable(instance, field):
|
||
with pytest.raises(FrozenInstanceError):
|
||
setattr(instance, field, None)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("enum", "values"),
|
||
[
|
||
(
|
||
JiraCreationOutcome,
|
||
{
|
||
"confirmed_success",
|
||
"confirmed_rejection",
|
||
"retryable_failure",
|
||
"uncertain",
|
||
},
|
||
),
|
||
(
|
||
JiraIssueLookupOutcome,
|
||
{"open", "done", "moved", "missing", "forbidden", "unknown"},
|
||
),
|
||
(JiraIssueSearchOutcome, {"success", "retryable_failure", "unknown"}),
|
||
],
|
||
)
|
||
def test_jira_outcome_values_are_stable(enum, values):
|
||
assert {outcome.value for outcome in enum} == values
|
||
|
||
|
||
class TestJiraIntegration:
|
||
@pytest.fixture(autouse=True)
|
||
@patch.object(Jira, "get_auth", return_value=None)
|
||
def setup(self, mock_get_auth, monkeypatch):
|
||
monkeypatch.setattr("builtins.input", lambda _: "test_authorization_code")
|
||
|
||
# To disable vulture
|
||
mock_get_auth = mock_get_auth
|
||
|
||
self.redirect_uri = "https://example.com/callback"
|
||
self.client_id = "test_client_id"
|
||
self.client_secret = "test_client_secret"
|
||
self.state_param = "unique_state_value"
|
||
|
||
self.jira_integration = Jira(
|
||
redirect_uri=self.redirect_uri,
|
||
client_id=self.client_id,
|
||
client_secret=self.client_secret,
|
||
)
|
||
|
||
@pytest.fixture(autouse=True)
|
||
@patch.object(Jira, "get_basic_auth", return_value=None)
|
||
def setup_basic_auth(self, mock_get_basic_auth):
|
||
# To disable vulture
|
||
mock_get_basic_auth = mock_get_basic_auth
|
||
|
||
self.user_mail = "test_user_mail"
|
||
self.api_token = "test_api_token"
|
||
self.domain = "test-domain"
|
||
|
||
self.jira_integration_basic_auth = Jira(
|
||
user_mail=self.user_mail,
|
||
api_token=self.api_token,
|
||
domain=self.domain,
|
||
)
|
||
|
||
@pytest.fixture
|
||
def jira_response(self):
|
||
def build(status_code=200, payload=None, headers=None, json_error=None):
|
||
response = MagicMock(status_code=status_code, headers=headers or {})
|
||
if json_error:
|
||
response.json.side_effect = json_error
|
||
else:
|
||
response.json.return_value = payload
|
||
return response
|
||
|
||
return build
|
||
|
||
@pytest.fixture
|
||
def jira_issue(self):
|
||
def build(issue_id, key, status=None, category=None):
|
||
status_data = None
|
||
if status is not None:
|
||
status_data = {"name": status, "statusCategory": {"key": category}}
|
||
return {"id": issue_id, "key": key, "fields": {"status": status_data}}
|
||
|
||
return build
|
||
|
||
@pytest.fixture
|
||
def oauth_post(self):
|
||
with (
|
||
patch.object(Jira, "get_access_token", return_value="token"),
|
||
patch.object(
|
||
Jira, "cloud_id", new_callable=PropertyMock, return_value="cloud"
|
||
),
|
||
patch("prowler.lib.outputs.jira.jira.requests.post") as post,
|
||
):
|
||
yield post
|
||
|
||
@pytest.fixture
|
||
def send_finding_post(self, oauth_post):
|
||
self.jira_integration._site_url = "https://example.atlassian.net"
|
||
with (
|
||
patch.object(Jira, "get_projects", return_value={"TEST": {}}) as projects,
|
||
patch.object(
|
||
Jira, "get_available_issue_types", return_value=["Bug"]
|
||
) as issue_types,
|
||
):
|
||
yield SimpleNamespace(
|
||
post=oauth_post, projects=projects, issue_types=issue_types
|
||
)
|
||
|
||
@staticmethod
|
||
def _collect_text_from_cell(cell: dict) -> str:
|
||
pieces: List[str] = []
|
||
|
||
def walk(node: dict) -> None:
|
||
node_type = node.get("type")
|
||
if node_type == "text":
|
||
pieces.append(node.get("text", ""))
|
||
elif node_type == "hardBreak":
|
||
pieces.append(" ")
|
||
else:
|
||
for child in node.get("content", []):
|
||
walk(child)
|
||
if node_type in {"paragraph", "listItem"}:
|
||
pieces.append(" ")
|
||
|
||
for child in cell.get("content", []):
|
||
walk(child)
|
||
|
||
flattened = "".join(pieces)
|
||
return " ".join(flattened.split())
|
||
|
||
@staticmethod
|
||
def _find_link_mark(nodes: List[dict]) -> Optional[dict]:
|
||
for node in nodes:
|
||
if node.get("type") == "text":
|
||
for mark in node.get("marks", []):
|
||
if mark.get("type") == "link":
|
||
return mark
|
||
found = TestJiraIntegration._find_link_mark(node.get("content", []))
|
||
if found:
|
||
return found
|
||
return None
|
||
|
||
@staticmethod
|
||
def _find_link_mark_by_href(nodes: List[dict], href: str) -> Optional[dict]:
|
||
for node in nodes:
|
||
if node.get("type") == "text":
|
||
for mark in node.get("marks", []):
|
||
if (
|
||
mark.get("type") == "link"
|
||
and mark.get("attrs", {}).get("href") == href
|
||
):
|
||
return mark
|
||
found = TestJiraIntegration._find_link_mark_by_href(
|
||
node.get("content", []), href
|
||
)
|
||
if found:
|
||
return found
|
||
return None
|
||
|
||
@staticmethod
|
||
def _collect_link_texts_by_href(nodes: List[dict], href: str) -> List[str]:
|
||
link_texts: List[str] = []
|
||
|
||
for node in nodes:
|
||
if node.get("type") == "text" and any(
|
||
mark.get("type") == "link" and mark.get("attrs", {}).get("href") == href
|
||
for mark in node.get("marks", [])
|
||
):
|
||
link_texts.append(node.get("text", ""))
|
||
link_texts.extend(
|
||
TestJiraIntegration._collect_link_texts_by_href(
|
||
node.get("content", []), href
|
||
)
|
||
)
|
||
|
||
return link_texts
|
||
|
||
@staticmethod
|
||
def _find_table_row(rows: List[dict], header: str) -> dict:
|
||
for row in rows:
|
||
header_cell = row.get("content", [])[0]
|
||
header_text = TestJiraIntegration._collect_text_from_cell(header_cell)
|
||
if header_text == header:
|
||
return row
|
||
raise AssertionError(f"Row with header '{header}' not found")
|
||
|
||
@patch.object(Jira, "get_auth", return_value=None)
|
||
def test_auth_code_url(self, mock_get_auth):
|
||
"""Test to verify the authorization URL generation with correct query parameters"""
|
||
|
||
# To disable vulture
|
||
mock_get_auth = mock_get_auth
|
||
|
||
generated_url = self.jira_integration.auth_code_url()
|
||
|
||
expected_url = "https://auth.atlassian.com/authorize"
|
||
|
||
parsed_url = urlparse(generated_url)
|
||
query_params = parse_qs(parsed_url.query)
|
||
|
||
assert (
|
||
parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path
|
||
== expected_url
|
||
)
|
||
|
||
assert query_params["audience"][0] == "api.atlassian.com"
|
||
assert query_params["client_id"][0] == self.client_id
|
||
assert (
|
||
query_params["scope"][0] == "read:jira-user read:jira-work write:jira-work"
|
||
)
|
||
assert query_params["redirect_uri"][0] == self.redirect_uri
|
||
assert query_params["state"][0] is not None
|
||
assert query_params["response_type"][0] == "code"
|
||
assert query_params["prompt"][0] == "consent"
|
||
|
||
@patch.object(Jira, "get_cloud_id", return_value="test_cloud_id")
|
||
def test_get_auth_successful_basic_auth(self, mock_get_cloud_id):
|
||
"""Test successful token retrieval in get_basic_auth."""
|
||
# To disable vulture
|
||
mock_get_cloud_id = mock_get_cloud_id
|
||
|
||
self.jira_integration_basic_auth.get_basic_auth()
|
||
|
||
user_string = "test_user_mail:test_api_token"
|
||
user_string_base64 = base64.b64encode(user_string.encode("utf-8")).decode(
|
||
"utf-8"
|
||
)
|
||
|
||
assert self.jira_integration_basic_auth._access_token == user_string_base64
|
||
assert self.jira_integration_basic_auth._cloud_id == "test_cloud_id"
|
||
|
||
@patch.object(Jira, "get_cloud_id", side_effect=Exception("Connection error"))
|
||
def test_get_auth_error_basic_auth(self, mock_get_cloud_id):
|
||
"""Test successful token retrieval in get_basic_auth."""
|
||
# To disable vulture
|
||
mock_get_cloud_id = mock_get_cloud_id
|
||
|
||
with pytest.raises(JiraBasicAuthError):
|
||
self.jira_integration_basic_auth.get_basic_auth()
|
||
|
||
@freeze_time(TEST_DATETIME)
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
@patch.object(Jira, "get_cloud_id", return_value="test_cloud_id")
|
||
def test_get_auth_successful(self, mock_get_cloud_id, mock_post):
|
||
"""Test successful token retrieval in get_auth."""
|
||
# To disable vulture
|
||
mock_get_cloud_id = mock_get_cloud_id
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = {
|
||
"access_token": "test_access_token",
|
||
"refresh_token": "test_refresh_token",
|
||
"expires_in": 3600,
|
||
}
|
||
mock_post.return_value = mock_response
|
||
|
||
self.jira_integration.get_auth("test_auth_code")
|
||
|
||
assert self.jira_integration._access_token == "test_access_token"
|
||
assert self.jira_integration._refresh_token == "test_refresh_token"
|
||
assert (
|
||
self.jira_integration._expiration_date
|
||
== (datetime.now() + timedelta(seconds=3600)).isoformat()
|
||
)
|
||
assert self.jira_integration._cloud_id == "test_cloud_id"
|
||
|
||
@patch(
|
||
"prowler.lib.outputs.jira.jira.requests.post",
|
||
side_effect=Exception("Connection error"),
|
||
)
|
||
def test_get_auth_connection_error(self, mock_post):
|
||
"""Test get_auth raises JiraAuthenticationError on connection failure."""
|
||
# To disable vulture
|
||
mock_post = mock_post
|
||
|
||
with pytest.raises(JiraAuthenticationError):
|
||
self.jira_integration.get_auth("test_auth_code")
|
||
|
||
@patch("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_cloud_id_successful(self, mock_get):
|
||
"""Test successful retrieval of cloud ID."""
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = [{"id": "test_cloud_id"}]
|
||
mock_get.return_value = mock_response
|
||
|
||
cloud_id = self.jira_integration.get_cloud_id("test_access_token")
|
||
|
||
assert cloud_id == "test_cloud_id"
|
||
|
||
@patch("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_cloud_id_no_resources(self, mock_get):
|
||
"""Test get_cloud_id raises JiraGetCloudIDNoResourcesError when no resources are found, later JiraGetCloudIDError will be raised."""
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = []
|
||
mock_get.return_value = mock_response
|
||
|
||
with pytest.raises(JiraGetCloudIDError):
|
||
self.jira_integration.get_cloud_id("test_access_token")
|
||
|
||
@patch("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_cloud_id_response_error(self, mock_get):
|
||
"""Test get_cloud_id raises JiraGetCloudIDResponseError when response code is not 200, later JiraGetCloudIDError will be raised."""
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 404
|
||
mock_response.json.return_value = {"error": "Not Found"}
|
||
mock_get.return_value = mock_response
|
||
|
||
with pytest.raises(JiraGetCloudIDError):
|
||
self.jira_integration.get_cloud_id("test_access_token")
|
||
|
||
@patch(
|
||
"prowler.lib.outputs.jira.jira.requests.get",
|
||
side_effect=Exception("Connection error"),
|
||
)
|
||
def test_get_cloud_id_unexpected_error(self, mock_get):
|
||
"""Test get_cloud_id raises JiraGetCloudIDError on an unexpected exception."""
|
||
# To disable vulture
|
||
mock_get = mock_get
|
||
|
||
with pytest.raises(JiraGetCloudIDError):
|
||
self.jira_integration.get_cloud_id("test_access_token")
|
||
|
||
@patch.object(Jira, "refresh_access_token", return_value="new_access_token")
|
||
def test_get_access_token_refresh(self, mock_refresh_access_token):
|
||
"""Test get_access_token refreshes token when expired."""
|
||
|
||
self.jira_integration.auth_expiration = 0
|
||
access_token = self.jira_integration.get_access_token()
|
||
|
||
assert access_token == "new_access_token"
|
||
mock_refresh_access_token.assert_called_once()
|
||
|
||
@freeze_time(TEST_DATETIME)
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
@patch.object(Jira, "get_cloud_id", return_value="test_cloud_id")
|
||
def test_get_access_token_valid_token(self, mock_get_cloud_id, mock_post):
|
||
"""Test successful token retrieval in get_auth."""
|
||
# To disable vulture
|
||
mock_get_cloud_id = mock_get_cloud_id
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = {
|
||
"access_token": "valid_access_token",
|
||
"refresh_token": "test_refresh_token",
|
||
"expires_in": 3600,
|
||
}
|
||
mock_post.return_value = mock_response
|
||
|
||
self.jira_integration.get_auth("test_auth_code")
|
||
|
||
access_token = self.jira_integration.get_access_token()
|
||
assert access_token == "valid_access_token"
|
||
|
||
@patch.object(Jira, "refresh_access_token", side_effect=JiraRefreshTokenError)
|
||
def test_get_access_token_refresh_error(self, mock_refresh_access_token):
|
||
"""Test get_access_token raises JiraRefreshTokenError on token refresh failure."""
|
||
|
||
# To disable vulture
|
||
mock_refresh_access_token = mock_refresh_access_token
|
||
|
||
self.jira_integration.auth_expiration = (
|
||
datetime.now() + timedelta(seconds=0)
|
||
).isoformat()
|
||
|
||
with pytest.raises(JiraRefreshTokenError):
|
||
self.jira_integration.get_access_token()
|
||
|
||
@freeze_time(TEST_DATETIME)
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_refresh_access_token_successful(self, mock_post):
|
||
"""Test successful access token refresh in refresh_access_token."""
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
expires_in_value = 3600
|
||
mock_response.json.return_value = {
|
||
"access_token": "new_access_token",
|
||
"refresh_token": "new_refresh_token",
|
||
"expires_in": expires_in_value,
|
||
}
|
||
mock_post.return_value = mock_response
|
||
|
||
new_access_token = self.jira_integration.refresh_access_token()
|
||
|
||
assert new_access_token == "new_access_token"
|
||
assert self.jira_integration._access_token == "new_access_token"
|
||
assert self.jira_integration._refresh_token == "new_refresh_token"
|
||
assert (
|
||
self.jira_integration._expiration_date
|
||
== (datetime.now() + timedelta(seconds=expires_in_value)).isoformat()
|
||
)
|
||
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_refresh_access_token_response_error(self, mock_post):
|
||
"""Test refresh_access_token raises JiraRefreshTokenResponseError when response code is not 200, later JiraRefreshTokenError will be raised."""
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 400
|
||
mock_response.json.return_value = {"error": "invalid_request"}
|
||
mock_post.return_value = mock_response
|
||
|
||
with pytest.raises(JiraRefreshTokenError):
|
||
self.jira_integration.refresh_access_token()
|
||
|
||
@patch(
|
||
"prowler.lib.outputs.jira.jira.requests.post",
|
||
side_effect=Exception("Connection error"),
|
||
)
|
||
def test_refresh_access_token_unexpected_error(self, mock_post):
|
||
"""Test refresh_access_token raises JiraRefreshTokenError on unexpected exception."""
|
||
# To disable vulture
|
||
mock_post = mock_post
|
||
|
||
with pytest.raises(JiraRefreshTokenError):
|
||
self.jira_integration.refresh_access_token()
|
||
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
@patch.object(Jira, "get_cloud_id", return_value="test_cloud_id")
|
||
def test_get_auth_sends_timeout(self, mock_get_cloud_id, mock_post):
|
||
"""get_auth must pass a request timeout to avoid hanging on an unresponsive Jira."""
|
||
# To disable vulture
|
||
mock_get_cloud_id = mock_get_cloud_id
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = {
|
||
"access_token": "test_access_token",
|
||
"refresh_token": "test_refresh_token",
|
||
"expires_in": 3600,
|
||
}
|
||
mock_post.return_value = mock_response
|
||
|
||
self.jira_integration.get_auth("test_auth_code")
|
||
|
||
assert mock_post.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT
|
||
|
||
@patch("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_cloud_id_sends_timeout(self, mock_get):
|
||
"""get_cloud_id (OAuth path) must pass a request timeout."""
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = [{"id": "test_cloud_id"}]
|
||
mock_get.return_value = mock_response
|
||
|
||
self.jira_integration.get_cloud_id("test_access_token")
|
||
|
||
assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT
|
||
|
||
@patch("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_cloud_id_basic_auth_sends_timeout(self, mock_get):
|
||
"""get_cloud_id (basic-auth tenant_info path) must pass a request timeout."""
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = {"cloudId": "test_cloud_id"}
|
||
mock_get.return_value = mock_response
|
||
|
||
self.jira_integration_basic_auth.get_cloud_id(domain=self.domain)
|
||
|
||
assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT
|
||
|
||
@pytest.mark.parametrize(
|
||
"domain",
|
||
(
|
||
"169.254.169.254#",
|
||
"internal/service",
|
||
"internal?target",
|
||
"internal\\target",
|
||
"internal:8000",
|
||
"user@internal",
|
||
),
|
||
)
|
||
@patch("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_cloud_id_basic_auth_rejects_invalid_domain(self, mock_get, domain):
|
||
with pytest.raises(JiraGetCloudIDError):
|
||
self.jira_integration_basic_auth.get_cloud_id(domain=domain)
|
||
|
||
mock_get.assert_not_called()
|
||
|
||
@patch("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_cloud_id_basic_auth_disables_redirects(self, mock_get):
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = {"cloudId": "test_cloud_id"}
|
||
mock_get.return_value = mock_response
|
||
|
||
self.jira_integration_basic_auth.get_cloud_id(domain=self.domain)
|
||
|
||
assert mock_get.call_args.kwargs["allow_redirects"] is False
|
||
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_refresh_access_token_sends_timeout(self, mock_post):
|
||
"""refresh_access_token must pass a request timeout."""
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = {
|
||
"access_token": "new_access_token",
|
||
"refresh_token": "new_refresh_token",
|
||
"expires_in": 3600,
|
||
}
|
||
mock_post.return_value = mock_response
|
||
|
||
self.jira_integration.refresh_access_token()
|
||
|
||
assert mock_post.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_projects_sends_timeout(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""get_projects must pass a request timeout."""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = [{"key": "PROJ1", "name": "Project One"}]
|
||
mock_get.return_value = mock_response
|
||
|
||
self.jira_integration.get_projects()
|
||
|
||
assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT
|
||
|
||
@patch.object(Jira, "get_auth", return_value=None)
|
||
@patch.object(
|
||
Jira,
|
||
"get_projects",
|
||
return_value={"PROJ1": "Project One", "PROJ2": "Project Two"},
|
||
)
|
||
@patch.object(
|
||
Jira,
|
||
"get_available_issue_types",
|
||
side_effect=lambda pk: ["Task", "Bug"] if pk == "PROJ1" else ["Story"],
|
||
)
|
||
def test_test_connection_successful(
|
||
self, mock_get_issue_types, mock_get_projects, mock_get_auth
|
||
):
|
||
"""Test that a successful connection returns an active Connection object with projects."""
|
||
# To disable vulture
|
||
mock_get_projects = mock_get_projects
|
||
mock_get_auth = mock_get_auth
|
||
mock_get_issue_types = mock_get_issue_types
|
||
|
||
connection = Jira.test_connection(
|
||
redirect_uri=self.redirect_uri,
|
||
client_id=self.client_id,
|
||
client_secret=self.client_secret,
|
||
)
|
||
|
||
assert connection.is_connected
|
||
assert connection.error is None
|
||
assert connection.projects == {"PROJ1": "Project One", "PROJ2": "Project Two"}
|
||
assert connection.issue_types == {
|
||
"PROJ1": ["Task", "Bug"],
|
||
"PROJ2": ["Story"],
|
||
}
|
||
|
||
@patch.object(Jira, "get_basic_auth", return_value=None)
|
||
@patch.object(
|
||
Jira,
|
||
"get_projects",
|
||
return_value={"PROJ1": "Project One", "PROJ2": "Project Two"},
|
||
)
|
||
@patch.object(
|
||
Jira,
|
||
"get_available_issue_types",
|
||
side_effect=lambda pk: ["Task", "Bug"] if pk == "PROJ1" else ["Story"],
|
||
)
|
||
def test_test_connection_successful_basic_auth(
|
||
self, mock_get_issue_types, mock_get_projects, mock_get_basic_auth
|
||
):
|
||
"""Test that a successful connection returns an active Connection object with projects."""
|
||
# To disable vulture
|
||
mock_get_projects = mock_get_projects
|
||
mock_get_basic_auth = mock_get_basic_auth
|
||
mock_get_issue_types = mock_get_issue_types
|
||
|
||
connection = Jira.test_connection(
|
||
user_mail=self.user_mail,
|
||
api_token=self.api_token,
|
||
domain=self.domain,
|
||
)
|
||
|
||
assert connection.is_connected
|
||
assert connection.error is None
|
||
assert connection.projects == {"PROJ1": "Project One", "PROJ2": "Project Two"}
|
||
assert connection.issue_types == {
|
||
"PROJ1": ["Task", "Bug"],
|
||
"PROJ2": ["Story"],
|
||
}
|
||
|
||
@patch.object(
|
||
Jira,
|
||
"get_auth",
|
||
side_effect=JiraAuthenticationError("Failed to authenticate with Jira"),
|
||
)
|
||
def test_test_connection_failed(self, mock_get_auth):
|
||
"""Test that a failed connection raises JiraAuthenticationError."""
|
||
# To disable vulture
|
||
mock_get_auth = mock_get_auth
|
||
|
||
with pytest.raises(JiraAuthenticationError):
|
||
Jira.test_connection(
|
||
redirect_uri=self.redirect_uri,
|
||
client_id=self.client_id,
|
||
client_secret=self.client_secret,
|
||
)
|
||
|
||
@patch.object(
|
||
Jira,
|
||
"get_basic_auth",
|
||
side_effect=JiraBasicAuthError("Failed to authenticate with Jira"),
|
||
)
|
||
def test_test_connection_failed_basic_auth(self, mock_get_basic_auth):
|
||
"""Test that a failed connection raises JiraBasicAuthError."""
|
||
# To disable vulture
|
||
mock_get_basic_auth = mock_get_basic_auth
|
||
|
||
with pytest.raises(JiraBasicAuthError):
|
||
Jira.test_connection(
|
||
user_mail=self.user_mail,
|
||
api_token=self.api_token,
|
||
domain=self.domain,
|
||
)
|
||
|
||
@patch.object(Jira, "get_auth", return_value=None)
|
||
@patch.object(
|
||
Jira, "get_projects", side_effect=JiraNoProjectsError("No projects found")
|
||
)
|
||
def test_test_connection_no_projects_found(self, mock_get_projects, mock_get_auth):
|
||
"""Test that test_connection raises JiraNoProjectsError when no projects are found."""
|
||
# To disable vulture
|
||
mock_get_projects = mock_get_projects
|
||
mock_get_auth = mock_get_auth
|
||
|
||
with pytest.raises(JiraNoProjectsError):
|
||
Jira.test_connection(
|
||
redirect_uri=self.redirect_uri,
|
||
client_id=self.client_id,
|
||
client_secret=self.client_secret,
|
||
)
|
||
|
||
@patch.object(Jira, "get_auth", return_value=None)
|
||
@patch.object(
|
||
Jira,
|
||
"get_projects",
|
||
side_effect=JiraGetProjectsResponseError("Projects request failed"),
|
||
)
|
||
def test_test_connection_projects_request_error(
|
||
self, mock_get_projects, mock_get_auth
|
||
):
|
||
"""Test that test_connection raises JiraGetProjectsResponseError when projects request fails."""
|
||
# To disable vulture
|
||
mock_get_projects = mock_get_projects
|
||
mock_get_auth = mock_get_auth
|
||
|
||
with pytest.raises(JiraGetProjectsResponseError):
|
||
Jira.test_connection(
|
||
redirect_uri=self.redirect_uri,
|
||
client_id=self.client_id,
|
||
client_secret=self.client_secret,
|
||
)
|
||
|
||
@patch.object(Jira, "get_auth", return_value=None)
|
||
@patch.object(
|
||
Jira, "get_projects", side_effect=JiraNoProjectsError("No projects found")
|
||
)
|
||
def test_test_connection_no_projects_found_no_exception(
|
||
self, mock_get_projects, mock_get_auth
|
||
):
|
||
"""Test that test_connection returns error connection object when no projects found and raise_on_exception=False."""
|
||
# To disable vulture
|
||
mock_get_projects = mock_get_projects
|
||
mock_get_auth = mock_get_auth
|
||
|
||
connection = Jira.test_connection(
|
||
redirect_uri=self.redirect_uri,
|
||
client_id=self.client_id,
|
||
client_secret=self.client_secret,
|
||
raise_on_exception=False,
|
||
)
|
||
|
||
assert not connection.is_connected
|
||
assert isinstance(connection.error, JiraNoProjectsError)
|
||
|
||
@patch.object(Jira, "get_auth", return_value=None)
|
||
@patch.object(
|
||
Jira,
|
||
"get_projects",
|
||
side_effect=JiraGetProjectsResponseError("Projects request failed"),
|
||
)
|
||
def test_test_connection_projects_request_error_no_exception(
|
||
self, mock_get_projects, mock_get_auth
|
||
):
|
||
"""Test that test_connection returns error connection object when projects request fails and raise_on_exception=False."""
|
||
# To disable vulture
|
||
mock_get_projects = mock_get_projects
|
||
mock_get_auth = mock_get_auth
|
||
|
||
connection = Jira.test_connection(
|
||
redirect_uri=self.redirect_uri,
|
||
client_id=self.client_id,
|
||
client_secret=self.client_secret,
|
||
raise_on_exception=False,
|
||
)
|
||
|
||
assert not connection.is_connected
|
||
assert isinstance(connection.error, JiraGetProjectsResponseError)
|
||
|
||
@patch.object(Jira, "get_auth", return_value=None)
|
||
@patch.object(Jira, "get_projects", side_effect=Exception("Unexpected error"))
|
||
def test_test_connection_unexpected_error(self, mock_get_projects, mock_get_auth):
|
||
"""Test that test_connection raises JiraTestConnectionError on unexpected exceptions."""
|
||
# To disable vulture
|
||
mock_get_projects = mock_get_projects
|
||
mock_get_auth = mock_get_auth
|
||
|
||
with pytest.raises(JiraTestConnectionError):
|
||
Jira.test_connection(
|
||
redirect_uri=self.redirect_uri,
|
||
client_id=self.client_id,
|
||
client_secret=self.client_secret,
|
||
)
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_projects_successful(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test successful retrieval of projects from Jira."""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = [
|
||
{"key": "PROJ1", "name": "Project One"},
|
||
{"key": "PROJ2", "name": "Project Two"},
|
||
]
|
||
mock_get.return_value = mock_response
|
||
|
||
projects = self.jira_integration.get_projects()
|
||
expected_projects = {"PROJ1": "Project One", "PROJ2": "Project Two"}
|
||
assert projects == expected_projects
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_projects_no_projects_found(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test that get_projects raises JiraNoProjectsError when no projects are found."""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = []
|
||
mock_get.return_value = mock_response
|
||
|
||
with pytest.raises(JiraNoProjectsError):
|
||
self.jira_integration.get_projects()
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_projects_response_error(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test that get_projects raises JiraGetProjectsResponseError on non-200 response."""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 404
|
||
mock_response.json.return_value = {"error": "Not Found"}
|
||
mock_get.return_value = mock_response
|
||
|
||
with pytest.raises(JiraGetProjectsError):
|
||
self.jira_integration.get_projects()
|
||
|
||
@patch.object(
|
||
Jira,
|
||
"get_access_token",
|
||
side_effect=JiraRefreshTokenError("Failed to refresh the access token"),
|
||
)
|
||
def test_get_projects_refresh_token_error(self, mock_get_access_token):
|
||
"""Test that get_projects raises JiraRefreshTokenError when refreshing the token fails."""
|
||
# To disable vulture
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
with pytest.raises(JiraRefreshTokenError):
|
||
self.jira_integration.get_projects()
|
||
|
||
@pytest.mark.parametrize(
|
||
("method_name", "args", "error"),
|
||
[
|
||
("get_projects", (), JiraGetProjectsError),
|
||
("get_available_issue_types", ("TEST",), JiraGetAvailableIssueTypesError),
|
||
],
|
||
)
|
||
@patch.object(Jira, "get_access_token", return_value=None)
|
||
def test_catalog_methods_raise_without_access_token(
|
||
self, mock_get_access_token, method_name, args, error
|
||
):
|
||
with pytest.raises(error):
|
||
getattr(self.jira_integration, method_name)(*args)
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_available_issue_types_successful(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test successful retrieval of issue types for a project."""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = {
|
||
"projects": [
|
||
{
|
||
"issuetypes": [
|
||
{"name": "Bug"},
|
||
{"name": "Task"},
|
||
{"name": "Story"},
|
||
]
|
||
}
|
||
]
|
||
}
|
||
mock_get.return_value = mock_response
|
||
|
||
issue_types = self.jira_integration.get_available_issue_types(
|
||
project_key="TEST"
|
||
)
|
||
|
||
expected_issue_types = ["Bug", "Task", "Story"]
|
||
assert issue_types == expected_issue_types
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_available_issue_types_no_projects_found(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test that get_available_issue_types raises JiraNoProjectsError when no projects are found, later JiraGetAvailableIssueTypesError is raised."""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = {"projects": []}
|
||
mock_get.return_value = mock_response
|
||
|
||
with pytest.raises(JiraGetAvailableIssueTypesError):
|
||
self.jira_integration.get_available_issue_types(project_key="TEST")
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_available_issue_types_response_error(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test that get_available_issue_types raises JiraGetAvailableIssueTypesResponseError on non-200 response, JiraGetAvailableIssueTypesError will be raised later"""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 404
|
||
mock_response.json.return_value = {"error": "Not Found"}
|
||
mock_get.return_value = mock_response
|
||
|
||
with pytest.raises(JiraGetAvailableIssueTypesError):
|
||
self.jira_integration.get_available_issue_types(project_key="TEST")
|
||
|
||
@patch.object(
|
||
Jira,
|
||
"get_access_token",
|
||
side_effect=JiraRefreshTokenError("Failed to refresh the access token"),
|
||
)
|
||
def test_get_available_issue_types_refresh_token_error(self, mock_get_access_token):
|
||
"""Test that get_available_issue_types raises JiraRefreshTokenError when refreshing the token fails."""
|
||
# To disable vulture
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
with pytest.raises(JiraRefreshTokenError):
|
||
self.jira_integration.get_available_issue_types(project_key="TEST")
|
||
|
||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||
@patch.object(
|
||
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
|
||
)
|
||
@patch.object(Jira, "get_projects", return_value={"TEST-1": "Test Project"})
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_send_findings_successful(
|
||
self,
|
||
mock_post,
|
||
mock_get_available_issue_types,
|
||
mock_get_projects,
|
||
mock_get_access_token,
|
||
):
|
||
"""Test successful sending of findings to Jira."""
|
||
# To disable vulture
|
||
mock_get_available_issue_types = mock_get_available_issue_types
|
||
mock_get_access_token = mock_get_access_token
|
||
mock_get_projects = mock_get_projects
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 201
|
||
mock_response.json.return_value = {"id": "12345", "key": "TEST-1"}
|
||
mock_post.return_value = mock_response
|
||
|
||
finding = MagicMock()
|
||
finding.status.value = "FAIL"
|
||
finding.status_extended = "status_extended"
|
||
finding.metadata.Severity.value = "HIGH"
|
||
finding.metadata.CheckID = "CHECK-1"
|
||
finding.metadata.CheckTitle = "Check Title"
|
||
finding.resource_uid = "resource-1"
|
||
finding.resource_name = "resource_name"
|
||
finding.metadata.Provider = "aws"
|
||
finding.region = "region"
|
||
finding.metadata.Risk = "risk"
|
||
finding.metadata.Remediation.Recommendation.Text = "remediation_text"
|
||
finding.metadata.Remediation.Recommendation.Url = "remediation_url"
|
||
finding.metadata.Remediation.Code.NativeIaC = (
|
||
'resource "aws_s3_bucket" "example" { bucket = "my-bucket" }'
|
||
)
|
||
finding.metadata.Remediation.Code.Terraform = (
|
||
"terraform apply -target=aws_s3_bucket.example"
|
||
)
|
||
finding.metadata.Remediation.Code.CLI = (
|
||
"aws s3api create-bucket --bucket my-bucket --region us-east-1"
|
||
)
|
||
finding.metadata.Remediation.Code.Other = (
|
||
"Manual configuration required in AWS Console"
|
||
)
|
||
finding.resource_tags = {"Environment": "Production", "Owner": "SecurityTeam"}
|
||
finding.compliance = {"CIS": ["2.1.1", "2.1.2"], "NIST": ["AC-3", "AC-6"]}
|
||
|
||
self.jira_integration.cloud_id = "valid_cloud_id"
|
||
self.jira_integration._site_url = "https://example.atlassian.net"
|
||
|
||
self.jira_integration.send_findings(
|
||
findings=[finding],
|
||
project_key="TEST-1",
|
||
issue_type="Bug",
|
||
issue_labels=["scan-mocked", "whatever"],
|
||
finding_url="https://prowler-cloud-link/findings/12345",
|
||
tenant_info="Tenant Info",
|
||
)
|
||
|
||
mock_post.assert_called_once()
|
||
|
||
call_args = mock_post.call_args
|
||
|
||
expected_url = (
|
||
"https://api.atlassian.com/ex/jira/valid_cloud_id/rest/api/3/issue"
|
||
)
|
||
expected_headers = {
|
||
"Authorization": "Bearer valid_access_token",
|
||
"Content-Type": "application/json",
|
||
"X-Force-Accept-Language": "true",
|
||
"Accept-Language": "en",
|
||
}
|
||
|
||
assert call_args[0][0] == expected_url
|
||
assert call_args.kwargs["headers"] == expected_headers
|
||
|
||
payload = call_args.kwargs["json"]
|
||
assert payload["fields"]["project"]["key"] == "TEST-1"
|
||
assert payload["fields"]["summary"] == "[Prowler] HIGH - CHECK-1 - resource-1"
|
||
assert payload["fields"]["issuetype"]["name"] == "Bug"
|
||
assert payload["fields"]["description"]["type"] == "doc"
|
||
assert payload["fields"]["labels"] == ["scan-mocked", "whatever"]
|
||
|
||
description_content = payload["fields"]["description"]["content"]
|
||
|
||
intro_paragraph = description_content[0]
|
||
assert intro_paragraph["type"] == "paragraph"
|
||
intro_text = intro_paragraph["content"][0]
|
||
assert intro_text["type"] == "text"
|
||
assert intro_text["text"] == "Prowler has discovered the following finding:"
|
||
assert all(
|
||
self._collect_text_from_cell({"content": node.get("content", [])})
|
||
!= "Summary"
|
||
for node in description_content
|
||
if node.get("type") == "heading"
|
||
)
|
||
|
||
table = description_content[1]
|
||
assert table["type"] == "table"
|
||
|
||
table_rows = table["content"]
|
||
row_entries = {}
|
||
recommendation_cell = None
|
||
|
||
for row in table_rows:
|
||
if row["type"] != "tableRow":
|
||
continue
|
||
|
||
cells = row["content"]
|
||
if len(cells) != 2:
|
||
continue
|
||
|
||
key_text = self._collect_text_from_cell(cells[0])
|
||
value_text = self._collect_text_from_cell(cells[1])
|
||
|
||
if key_text:
|
||
row_entries[key_text] = value_text
|
||
if key_text == "Recommendation":
|
||
recommendation_cell = cells[1]
|
||
|
||
expected_keys = [
|
||
"Check Id",
|
||
"Check Title",
|
||
"Severity",
|
||
"Status",
|
||
"Status Extended",
|
||
"Provider",
|
||
"Region",
|
||
"Resource UID",
|
||
"Resource Name",
|
||
"Risk",
|
||
"Resource Tags",
|
||
"Compliance",
|
||
"Recommendation",
|
||
"Remediation Native IaC",
|
||
"Remediation Terraform",
|
||
"Remediation CLI",
|
||
"Remediation Other",
|
||
"Finding URL",
|
||
"Tenant Info",
|
||
]
|
||
|
||
for expected_key in expected_keys:
|
||
assert expected_key in row_entries, f"Missing row key: {expected_key}"
|
||
|
||
row_dict = row_entries
|
||
|
||
assert recommendation_cell is not None
|
||
link_mark = self._find_link_mark(recommendation_cell.get("content", []))
|
||
assert link_mark is not None
|
||
assert link_mark.get("attrs", {}).get("href") == "remediation_url"
|
||
assert row_dict["Check Id"] == "CHECK-1"
|
||
assert row_dict["Check Title"] == "Check Title"
|
||
assert row_dict["Status"] == "FAIL"
|
||
assert row_dict["Severity"] == "HIGH"
|
||
assert row_dict["Resource UID"] == "resource-1"
|
||
assert row_dict["Resource Name"] == "resource_name"
|
||
assert row_dict["Provider"] == "aws"
|
||
assert row_dict["Region"] == "region"
|
||
assert row_dict["Risk"] == "risk"
|
||
assert "remediation_text" in row_dict["Recommendation"]
|
||
assert "remediation_url" in row_dict["Recommendation"]
|
||
assert (
|
||
row_dict["Remediation Native IaC"]
|
||
== 'resource "aws_s3_bucket" "example" { bucket = "my-bucket" }'
|
||
)
|
||
assert (
|
||
row_dict["Remediation Terraform"]
|
||
== "terraform apply -target=aws_s3_bucket.example"
|
||
)
|
||
assert (
|
||
row_dict["Remediation CLI"]
|
||
== "aws s3api create-bucket --bucket my-bucket --region us-east-1"
|
||
)
|
||
assert (
|
||
row_dict["Remediation Other"]
|
||
== "Manual configuration required in AWS Console"
|
||
)
|
||
assert "Environment=Production" in row_dict["Resource Tags"]
|
||
assert "Owner=SecurityTeam" in row_dict["Resource Tags"]
|
||
assert "CIS: 2.1.1, 2.1.2" in row_dict["Compliance"]
|
||
assert "NIST: AC-3, AC-6" in row_dict["Compliance"]
|
||
assert "https://prowler-cloud-link/findings/12345" in row_dict["Finding URL"]
|
||
assert "Tenant Info" in row_dict["Tenant Info"]
|
||
|
||
def test_get_adf_description_renders_markdown(self):
|
||
status_extended_md = "Finding uses **bold** text and `code` snippets."
|
||
risk_md = "High risk:\n- Item one\n- Item two"
|
||
recommendation_md = "Apply fixes:\n- Step one\n- Step two"
|
||
recommendation_url = "https://example.com/fix"
|
||
|
||
adf_description = self.jira_integration.get_adf_description(
|
||
check_id="CHECK-1",
|
||
check_title="Sample check",
|
||
severity="HIGH",
|
||
severity_color="#FF0000",
|
||
status="FAIL",
|
||
status_color="#00FF00",
|
||
status_extended=status_extended_md,
|
||
provider="aws",
|
||
region="us-east-1",
|
||
resource_uid="resource-1",
|
||
resource_name="resource-name",
|
||
risk=risk_md,
|
||
recommendation_text=recommendation_md,
|
||
recommendation_url=recommendation_url,
|
||
)
|
||
|
||
assert adf_description["type"] == "doc"
|
||
table = adf_description["content"][1]
|
||
assert table["type"] == "table"
|
||
|
||
rows = {}
|
||
for row in table["content"]:
|
||
if row.get("type") != "tableRow":
|
||
continue
|
||
key_cell, value_cell = row["content"]
|
||
key_text = self._collect_text_from_cell(key_cell)
|
||
rows[key_text] = value_cell
|
||
|
||
assert "Status Extended" in rows
|
||
assert "Risk" in rows
|
||
assert "Recommendation" in rows
|
||
|
||
def walk_nodes(nodes: List[dict]):
|
||
stack = list(nodes)
|
||
while stack:
|
||
current = stack.pop()
|
||
yield current
|
||
stack.extend(current.get("content", []))
|
||
|
||
status_text = self._collect_text_from_cell(rows["Status Extended"])
|
||
assert status_text == status_extended_md
|
||
|
||
risk_nodes = list(walk_nodes(rows["Risk"].get("content", [])))
|
||
assert any(node.get("type") == "bulletList" for node in risk_nodes)
|
||
|
||
recommendation_cell = rows["Recommendation"]
|
||
recommendation_nodes = list(walk_nodes(recommendation_cell.get("content", [])))
|
||
assert any(node.get("type") == "bulletList" for node in recommendation_nodes)
|
||
link_mark = self._find_link_mark(recommendation_cell.get("content", []))
|
||
assert link_mark is not None
|
||
assert link_mark.get("attrs", {}).get("href") == recommendation_url
|
||
|
||
def test_get_adf_description_code_blocks_strip_fences(self):
|
||
code_block_value = """```hcl\nresource \"aws_s3_bucket\" \"example\" {\n bucket = \"my-bucket\"\n}\n```"""
|
||
|
||
adf_description = self.jira_integration.get_adf_description(
|
||
check_id="CHECK-1",
|
||
check_title="Sample check",
|
||
severity="HIGH",
|
||
severity_color="#FF0000",
|
||
status="FAIL",
|
||
status_color="#00FF00",
|
||
recommendation_text="",
|
||
remediation_code_native_iac=code_block_value,
|
||
)
|
||
|
||
table = adf_description["content"][1]
|
||
code_row = self._find_table_row(table["content"], "Remediation Native IaC")
|
||
code_cell = code_row["content"][1]
|
||
code_block = code_cell["content"][0]
|
||
|
||
assert code_block["type"] == "codeBlock"
|
||
assert code_block.get("attrs", {}).get("language") == "hcl"
|
||
expected_text = (
|
||
'resource "aws_s3_bucket" "example" {\n bucket = "my-bucket"\n}'
|
||
)
|
||
assert code_block["content"][0]["text"] == expected_text
|
||
|
||
def test_get_adf_description_other_remediation_uses_markdown(self):
|
||
other_value = "Use **bold** text"
|
||
|
||
adf_description = self.jira_integration.get_adf_description(
|
||
check_id="CHECK-1",
|
||
check_title="Sample check",
|
||
severity="HIGH",
|
||
severity_color="#FF0000",
|
||
status="FAIL",
|
||
status_color="#00FF00",
|
||
recommendation_text="",
|
||
remediation_code_other=other_value,
|
||
)
|
||
|
||
table = adf_description["content"][1]
|
||
other_row = self._find_table_row(table["content"], "Remediation Other")
|
||
other_cell = other_row["content"][1]
|
||
|
||
paragraph = other_cell["content"][0]
|
||
assert paragraph["type"] == "paragraph"
|
||
assert any(
|
||
mark.get("type") == "strong"
|
||
for node in paragraph.get("content", [])
|
||
for mark in node.get("marks", [])
|
||
)
|
||
|
||
@staticmethod
|
||
def _find_empty_text_nodes(node) -> List[str]:
|
||
# ADF forbids empty text nodes; collect any to assert the document is valid.
|
||
empties: List[str] = []
|
||
|
||
def walk(current) -> None:
|
||
if isinstance(current, dict):
|
||
if current.get("type") == "text" and current.get("text", "") == "":
|
||
empties.append(current.get("text", ""))
|
||
for value in current.values():
|
||
walk(value)
|
||
elif isinstance(current, list):
|
||
for item in current:
|
||
walk(item)
|
||
|
||
walk(node)
|
||
return empties
|
||
|
||
def test_get_adf_description_empty_resource_name_has_no_empty_text_nodes(self):
|
||
# A resource without a name (e.g. an AWS-managed IAM policy) used to emit an
|
||
# empty ADF text node, making Jira reject the issue with 400 INVALID_INPUT.
|
||
adf_description = self.jira_integration.get_adf_description(
|
||
check_id="CHECK-1",
|
||
check_title="Sample check",
|
||
severity="CRITICAL",
|
||
severity_color="#FF0000",
|
||
status="FAIL",
|
||
status_color="#FF0000",
|
||
status_extended="Some status",
|
||
provider="aws",
|
||
region="eu-west-1",
|
||
resource_uid="arn:aws:iam::aws:policy/AdministratorAccess",
|
||
resource_name="",
|
||
recommendation_text="",
|
||
)
|
||
|
||
assert self._find_empty_text_nodes(adf_description) == []
|
||
|
||
table = adf_description["content"][1]
|
||
resource_name_row = self._find_table_row(table["content"], "Resource Name")
|
||
value_cell = resource_name_row["content"][1]
|
||
assert self._collect_text_from_cell(value_cell) == "-"
|
||
|
||
@pytest.mark.parametrize(
|
||
"field, header",
|
||
[
|
||
("check_id", "Check Id"),
|
||
("check_title", "Check Title"),
|
||
("status_extended", "Status Extended"),
|
||
("provider", "Provider"),
|
||
("region", "Region"),
|
||
("resource_uid", "Resource UID"),
|
||
("resource_name", "Resource Name"),
|
||
],
|
||
)
|
||
def test_get_adf_description_empty_plain_text_fields_render_placeholder(
|
||
self, field, header
|
||
):
|
||
base_kwargs = dict(
|
||
check_id="CHECK-1",
|
||
check_title="Sample check",
|
||
severity="HIGH",
|
||
severity_color="#FF0000",
|
||
status="FAIL",
|
||
status_color="#00FF00",
|
||
status_extended="Some status",
|
||
provider="aws",
|
||
region="us-east-1",
|
||
resource_uid="resource-1",
|
||
resource_name="resource-name",
|
||
recommendation_text="",
|
||
)
|
||
base_kwargs[field] = ""
|
||
|
||
adf_description = self.jira_integration.get_adf_description(**base_kwargs)
|
||
|
||
assert self._find_empty_text_nodes(adf_description) == []
|
||
|
||
table = adf_description["content"][1]
|
||
row = self._find_table_row(table["content"], header)
|
||
value_cell = row["content"][1]
|
||
assert self._collect_text_from_cell(value_cell) == "-"
|
||
|
||
def test_get_grouped_adf_description_uses_capped_finding_group_link_copy(self):
|
||
finding_group_url = (
|
||
"https://security.example.com/findings?"
|
||
"filter%5Bcheck_id%5D=admincenter_users_admins_reduced_license_footprint&"
|
||
"expandedCheckId=admincenter_users_admins_reduced_license_footprint"
|
||
)
|
||
finding_group_link_text = "View the remaining grouped findings."
|
||
recommendation_url = (
|
||
"https://hub.prowler.com/check/"
|
||
"admincenter_users_admins_reduced_license_footprint"
|
||
)
|
||
adf_description = self.jira_integration.get_grouped_adf_description(
|
||
check_id="admincenter_users_admins_reduced_license_footprint",
|
||
check_title="Administrative user has no license or an allowed license",
|
||
check_description="Administrative users are assigned productivity licenses.",
|
||
severity="HIGH",
|
||
status="FAIL",
|
||
provider="m365",
|
||
service="exchange",
|
||
affected_failing_resources=123,
|
||
last_seen="Jul 09, 2026 11:38AM UTC",
|
||
failing_for="< 1 day",
|
||
grouped_resources=[
|
||
{
|
||
"resource_name": "rich@prowler.com",
|
||
"resource_uid": "3f9a216b-b66b-4d5d-a812-2ad538732cfb",
|
||
"provider": "m365",
|
||
"service": "exchange",
|
||
"provider_account": "ProwlerPro.onmicrosoft.com",
|
||
"status": "FAIL",
|
||
"severity": "high",
|
||
"region": "global",
|
||
"last_seen": "Jul 09, 2026 11:38AM UTC",
|
||
"failing_for": "< 1 day",
|
||
"triage": "Open",
|
||
}
|
||
],
|
||
resources_total=123,
|
||
resources_shown=100,
|
||
finding_group_url=finding_group_url,
|
||
finding_group_link_text=finding_group_link_text,
|
||
risk="Productivity licenses on privileged identities create risk.",
|
||
recommendation_text="Maintain dedicated admin accounts.",
|
||
recommendation_url=recommendation_url,
|
||
)
|
||
|
||
assert adf_description["type"] == "doc"
|
||
assert self._find_empty_text_nodes(adf_description) == []
|
||
|
||
main_table = adf_description["content"][1]
|
||
main_rows = {}
|
||
for row in main_table["content"]:
|
||
key_cell, value_cell = row["content"]
|
||
main_rows[self._collect_text_from_cell(key_cell)] = (
|
||
self._collect_text_from_cell(value_cell)
|
||
)
|
||
|
||
assert (
|
||
main_rows["Check Id"]
|
||
== "admincenter_users_admins_reduced_license_footprint"
|
||
)
|
||
assert main_rows["Service"] == "exchange"
|
||
assert main_rows["Affected Failing Resources"] == "123"
|
||
assert (
|
||
main_rows["Risk"]
|
||
== "Productivity licenses on privileged identities create risk."
|
||
)
|
||
assert main_rows["Recommendation"] == (
|
||
"Maintain dedicated admin accounts. " + recommendation_url
|
||
)
|
||
assert "Finding Group Link" not in main_rows
|
||
assert "Region" not in main_rows
|
||
|
||
top_level_headings = [
|
||
self._collect_text_from_cell({"content": node.get("content", [])})
|
||
for node in adf_description["content"]
|
||
if node.get("type") == "heading"
|
||
]
|
||
assert "Risk" not in top_level_headings
|
||
assert "Recommendation" not in top_level_headings
|
||
assert "Summary" not in top_level_headings
|
||
|
||
def text_marks(cell: dict) -> list[dict]:
|
||
return cell["content"][0]["content"][0]["marks"]
|
||
|
||
severity_marks = text_marks(
|
||
self._find_table_row(main_table["content"], "Severity")["content"][1]
|
||
)
|
||
status_marks = text_marks(
|
||
self._find_table_row(main_table["content"], "Status")["content"][1]
|
||
)
|
||
assert {
|
||
"type": "backgroundColor",
|
||
"attrs": {"color": "#FFA500"},
|
||
} in severity_marks
|
||
assert {"type": "textColor", "attrs": {"color": "#FF0000"}} in status_marks
|
||
|
||
resource_table = next(
|
||
node
|
||
for node in adf_description["content"]
|
||
if node.get("type") == "table"
|
||
and self._collect_text_from_cell(node["content"][0]["content"][0])
|
||
== "Resource"
|
||
)
|
||
resource_cells = resource_table["content"][1]["content"]
|
||
assert {"type": "textColor", "attrs": {"color": "#FF0000"}} in text_marks(
|
||
resource_cells[5]
|
||
)
|
||
assert {
|
||
"type": "backgroundColor",
|
||
"attrs": {"color": "#FFA500"},
|
||
} in text_marks(resource_cells[6])
|
||
|
||
document_text = self._collect_text_from_cell(
|
||
{"content": adf_description["content"]}
|
||
)
|
||
assert (
|
||
"Administrative users are assigned productivity licenses."
|
||
not in document_text
|
||
)
|
||
assert "Affected failing resources" in document_text
|
||
capped_link_copy = (
|
||
f"Showing 100 of 123 Findings in this Jira issue. {finding_group_link_text}"
|
||
)
|
||
assert document_text.count(capped_link_copy) == 1
|
||
assert "Finding Group Link" not in document_text
|
||
assert recommendation_url in document_text
|
||
recommendation_link_mark = self._find_link_mark_by_href(
|
||
adf_description["content"], recommendation_url
|
||
)
|
||
assert recommendation_link_mark is not None
|
||
link_mark = self._find_link_mark_by_href(
|
||
adf_description["content"], finding_group_url
|
||
)
|
||
assert link_mark is not None
|
||
assert link_mark["attrs"]["href"] == finding_group_url
|
||
assert self._collect_link_texts_by_href(
|
||
adf_description["content"], finding_group_url
|
||
) == [finding_group_link_text]
|
||
assert (
|
||
len(
|
||
self._collect_link_texts_by_href(
|
||
adf_description["content"], finding_group_url
|
||
)
|
||
)
|
||
== 1
|
||
)
|
||
assert "filter%5Bcheck_id%5D=" in link_mark["attrs"]["href"]
|
||
assert "expandedCheckId=" in link_mark["attrs"]["href"]
|
||
|
||
def test_get_grouped_adf_description_includes_link_when_not_capped(self):
|
||
finding_group_url = (
|
||
"https://security.example.com/findings?"
|
||
"filter%5Bcheck_id%5D=s3_bucket_public_access&"
|
||
"expandedCheckId=s3_bucket_public_access"
|
||
)
|
||
finding_group_link_text = "View this grouped finding."
|
||
adf_description = self.jira_integration.get_grouped_adf_description(
|
||
check_id="s3_bucket_public_access",
|
||
check_title="S3 bucket public access",
|
||
severity="HIGH",
|
||
status="FAIL",
|
||
provider="aws",
|
||
service="s3",
|
||
affected_failing_resources=1,
|
||
grouped_resources=[
|
||
{
|
||
"resource_name": "bucket-a",
|
||
"resource_uid": "arn:aws:s3:::bucket-a",
|
||
"provider": "aws",
|
||
"service": "s3",
|
||
"provider_account": "production (123456789012)",
|
||
"status": "FAIL",
|
||
"severity": "high",
|
||
"region": "us-east-1",
|
||
"last_seen": "Jul 09, 2026 11:38AM UTC",
|
||
"failing_for": "< 1 day",
|
||
"triage": "Open",
|
||
}
|
||
],
|
||
resources_total=1,
|
||
resources_shown=1,
|
||
finding_group_url=finding_group_url,
|
||
finding_group_link_text=finding_group_link_text,
|
||
)
|
||
|
||
document_text = self._collect_text_from_cell(
|
||
{"content": adf_description["content"]}
|
||
)
|
||
assert "Showing 1 of 1 Findings." not in document_text
|
||
assert "remaining Findings" not in document_text
|
||
assert document_text.count(finding_group_link_text) == 1
|
||
assert "Finding Group Link" not in document_text
|
||
main_table = adf_description["content"][1]
|
||
main_row_headers = [
|
||
self._collect_text_from_cell(row["content"][0])
|
||
for row in main_table["content"]
|
||
]
|
||
assert "Finding Group Link" not in main_row_headers
|
||
link_mark = self._find_link_mark_by_href(
|
||
adf_description["content"], finding_group_url
|
||
)
|
||
assert link_mark is not None
|
||
assert link_mark["attrs"]["href"] == finding_group_url
|
||
assert self._collect_link_texts_by_href(
|
||
adf_description["content"], finding_group_url
|
||
) == [finding_group_link_text]
|
||
assert (
|
||
"filter%5Bcheck_id%5D=s3_bucket_public_access" in link_mark["attrs"]["href"]
|
||
)
|
||
assert "expandedCheckId=s3_bucket_public_access" in link_mark["attrs"]["href"]
|
||
|
||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||
@patch.object(
|
||
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
|
||
)
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_send_findings_invalid_issue_type(
|
||
self, mock_post, mock_get_available_issue_types, mock_get_access_token
|
||
):
|
||
"""Test that send_findings raises JiraInvalidIssueTypeError if the issue type is invalid that will raise JiraCreateIssueError later."""
|
||
# To disable vulture
|
||
mock_get_available_issue_types = mock_get_available_issue_types
|
||
mock_get_access_token = mock_get_access_token
|
||
mock_post = mock_post
|
||
mock_post = mock_post
|
||
|
||
with pytest.raises(JiraCreateIssueError):
|
||
self.jira_integration.send_findings(
|
||
findings=[MagicMock()], project_key="TEST", issue_type="InvalidType"
|
||
)
|
||
|
||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||
@patch.object(
|
||
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
|
||
)
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_send_findings_response_error(
|
||
self, mock_post, mock_get_available_issue_types, mock_get_access_token
|
||
):
|
||
"""Test that send_findings raises JiraSendFindingsResponseError on non-201 response that will raise JiraCreateIssueError later."""
|
||
# To disable vulture
|
||
mock_get_available_issue_types = mock_get_available_issue_types
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 400
|
||
mock_response.json.return_value = {"error": "Bad Request"}
|
||
mock_post.return_value = mock_response
|
||
|
||
finding = MagicMock()
|
||
finding.status.value = "FAIL"
|
||
finding.metadata.Severity.value = "HIGH"
|
||
finding.metadata.CheckID = "CHECK-1"
|
||
finding.resource_uid = "resource-1"
|
||
|
||
with pytest.raises(JiraCreateIssueError):
|
||
self.jira_integration.send_findings(
|
||
findings=[finding], project_key="TEST", issue_type="Bug"
|
||
)
|
||
|
||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||
@patch.object(
|
||
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
|
||
)
|
||
@patch.object(Jira, "get_projects", return_value={"TEST-1": "Test Project"})
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_send_findings_custom_fields_required_error(
|
||
self,
|
||
mock_post,
|
||
mock_get_projects,
|
||
mock_get_available_issue_types,
|
||
mock_get_access_token,
|
||
):
|
||
"""Test that send_findings raises JiraRequiredCustomFieldsError when custom fields are required."""
|
||
# To disable vulture
|
||
mock_get_available_issue_types = mock_get_available_issue_types
|
||
mock_get_access_token = mock_get_access_token
|
||
mock_get_projects = mock_get_projects
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 400
|
||
mock_response.json.return_value = {
|
||
"errorMessages": [],
|
||
"errors": {
|
||
"customfield_10148": "Team is required.",
|
||
"customfield_10088": "Component is required.",
|
||
},
|
||
}
|
||
mock_post.return_value = mock_response
|
||
|
||
finding = MagicMock()
|
||
finding.status.value = "FAIL"
|
||
finding.status_extended = "status_extended"
|
||
finding.metadata.Severity.value = "HIGH"
|
||
finding.metadata.CheckID = "CHECK-1"
|
||
finding.metadata.CheckTitle = "Check Title"
|
||
finding.resource_uid = "resource-1"
|
||
finding.resource_name = "resource_name"
|
||
finding.metadata.Provider = "aws"
|
||
finding.region = "region"
|
||
finding.metadata.Risk = "risk"
|
||
finding.metadata.Remediation.Recommendation.Text = "remediation_text"
|
||
finding.metadata.Remediation.Recommendation.Url = "remediation_url"
|
||
finding.metadata.Remediation.Code.NativeIaC = ""
|
||
finding.metadata.Remediation.Code.Terraform = ""
|
||
finding.metadata.Remediation.Code.CLI = ""
|
||
finding.metadata.Remediation.Code.Other = ""
|
||
finding.resource_tags = {}
|
||
finding.compliance = {}
|
||
|
||
self.jira_integration.cloud_id = "valid_cloud_id"
|
||
|
||
with pytest.raises(JiraRequiredCustomFieldsError):
|
||
self.jira_integration.send_findings(
|
||
findings=[finding],
|
||
project_key="TEST-1",
|
||
issue_type="Bug",
|
||
issue_labels=["scan-mocked", "whatever"],
|
||
finding_url="https://prowler-cloud-link/findings/12345",
|
||
tenant_info="Tenant Info",
|
||
)
|
||
|
||
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
|
||
@patch.object(
|
||
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
|
||
)
|
||
@patch.object(Jira, "get_projects", return_value={"TEST-1": "Test Project"})
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_send_findings_non_custom_field_400_error(
|
||
self,
|
||
mock_post,
|
||
mock_get_projects,
|
||
mock_get_available_issue_types,
|
||
mock_get_access_token,
|
||
):
|
||
"""Test that send_findings raises JiraCreateIssueError for non-custom field 400 errors."""
|
||
# To disable vulture
|
||
mock_get_available_issue_types = mock_get_available_issue_types
|
||
mock_get_access_token = mock_get_access_token
|
||
mock_get_projects = mock_get_projects
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 400
|
||
mock_response.json.return_value = {
|
||
"errorMessages": [],
|
||
"errors": {
|
||
"summary": "Summary is required.",
|
||
"description": "Description is required.",
|
||
},
|
||
}
|
||
mock_post.return_value = mock_response
|
||
|
||
finding = MagicMock()
|
||
finding.status.value = "FAIL"
|
||
finding.status_extended = "status_extended"
|
||
finding.metadata.Severity.value = "HIGH"
|
||
finding.metadata.CheckID = "CHECK-1"
|
||
finding.metadata.CheckTitle = "Check Title"
|
||
finding.resource_uid = "resource-1"
|
||
finding.resource_name = "resource_name"
|
||
finding.metadata.Provider = "aws"
|
||
finding.region = "region"
|
||
finding.metadata.Risk = "risk"
|
||
finding.metadata.Remediation.Recommendation.Text = "remediation_text"
|
||
finding.metadata.Remediation.Recommendation.Url = "remediation_url"
|
||
finding.metadata.Remediation.Code.NativeIaC = ""
|
||
finding.metadata.Remediation.Code.Terraform = ""
|
||
finding.metadata.Remediation.Code.CLI = ""
|
||
finding.metadata.Remediation.Code.Other = ""
|
||
finding.resource_tags = {}
|
||
finding.compliance = {}
|
||
|
||
self.jira_integration.cloud_id = "valid_cloud_id"
|
||
|
||
with pytest.raises(JiraCreateIssueError):
|
||
self.jira_integration.send_findings(
|
||
findings=[finding],
|
||
project_key="TEST-1",
|
||
issue_type="Bug",
|
||
issue_labels=["scan-mocked", "whatever"],
|
||
finding_url="https://prowler-cloud-link/findings/12345",
|
||
tenant_info="Tenant Info",
|
||
)
|
||
|
||
@pytest.mark.parametrize(
|
||
"status, expected_color",
|
||
[
|
||
("FAIL", "#FF0000"),
|
||
("PASS", "#008000"),
|
||
("MUTED", "#FFA500"),
|
||
("MANUAL", "#FFFF00"),
|
||
],
|
||
)
|
||
def test_get_color_from_status(self, status, expected_color):
|
||
"""Test that get_color_from_status returns the correct color for a status."""
|
||
assert self.jira_integration.get_color_from_status(status) == expected_color
|
||
|
||
@pytest.mark.parametrize(
|
||
"severity, expected_color",
|
||
[
|
||
("critical", "#FF0000"),
|
||
("high", "#FFA500"),
|
||
("medium", "#FFFF00"),
|
||
("low", "#008000"),
|
||
("informational", "#0000FF"),
|
||
("unknown", "#000000"),
|
||
],
|
||
)
|
||
def test_get_severity_color(self, severity, expected_color):
|
||
"""Test that get_severity_color returns the correct color for a severity."""
|
||
assert self.jira_integration.get_severity_color(severity) == expected_color
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_projects_and_issue_types_successful(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test successful retrieval of metadata associated to projects from Jira."""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
# Mock the projects response
|
||
mock_projects_response = MagicMock()
|
||
mock_projects_response.status_code = 200
|
||
mock_projects_response.json.return_value = [
|
||
{"key": "PROJ1", "name": "Project One"},
|
||
{"key": "PROJ2", "name": "Project Two"},
|
||
]
|
||
|
||
# Mock the issue types responses
|
||
mock_issue_types_response_1 = MagicMock()
|
||
mock_issue_types_response_1.status_code = 200
|
||
mock_issue_types_response_1.json.return_value = {
|
||
"projects": [
|
||
{
|
||
"issuetypes": [
|
||
{"name": "Bug", "id": "1"},
|
||
{"name": "Task", "id": "2"},
|
||
]
|
||
}
|
||
]
|
||
}
|
||
|
||
mock_issue_types_response_2 = MagicMock()
|
||
mock_issue_types_response_2.status_code = 200
|
||
mock_issue_types_response_2.json.return_value = {
|
||
"projects": [
|
||
{
|
||
"issuetypes": [
|
||
{"name": "Story", "id": "3"},
|
||
{"name": "Epic", "id": "4"},
|
||
]
|
||
}
|
||
]
|
||
}
|
||
|
||
# Configure side_effect to return different responses for different calls
|
||
mock_get.side_effect = [
|
||
mock_projects_response,
|
||
mock_issue_types_response_1,
|
||
mock_issue_types_response_2,
|
||
]
|
||
|
||
jira_metadata = self.jira_integration.get_metadata()
|
||
|
||
expected_result = {
|
||
"PROJ1": {
|
||
"name": "Project One",
|
||
"issue_types": ["Bug", "Task"],
|
||
},
|
||
"PROJ2": {
|
||
"name": "Project Two",
|
||
"issue_types": ["Story", "Epic"],
|
||
},
|
||
}
|
||
|
||
assert jira_metadata == expected_result
|
||
|
||
# Verify the correct number of calls were made
|
||
assert mock_get.call_count == 3
|
||
|
||
# Verify the URLs called
|
||
calls = mock_get.call_args_list
|
||
assert (
|
||
calls[0][0][0]
|
||
== "https://api.atlassian.com/ex/jira/test_cloud_id/rest/api/3/project"
|
||
)
|
||
assert "projectKeys=PROJ1" in calls[1][0][0]
|
||
assert "projectKeys=PROJ2" in calls[2][0][0]
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_projects_and_issue_types_no_projects_found(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test that get_metadata raises JiraNoProjectsError when no projects are found."""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = []
|
||
mock_get.return_value = mock_response
|
||
|
||
with pytest.raises(JiraNoProjectsError):
|
||
self.jira_integration.get_metadata()
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_projects_and_issue_types_projects_response_error(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test that get_metadata raises JiraGetProjectsError when projects request fails."""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 404
|
||
mock_response.text = "Not Found"
|
||
mock_get.return_value = mock_response
|
||
|
||
with pytest.raises(JiraGetProjectsError):
|
||
self.jira_integration.get_metadata()
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_projects_and_issue_types_issue_types_response_error(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test that get_metadata raises JiraGetProjectsError when issue types request fails."""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
# Mock successful projects response
|
||
mock_projects_response = MagicMock()
|
||
mock_projects_response.status_code = 200
|
||
mock_projects_response.json.return_value = [
|
||
{"key": "PROJ1", "name": "Project One"}
|
||
]
|
||
|
||
# Mock failed issue types response
|
||
mock_issue_types_response = MagicMock()
|
||
mock_issue_types_response.status_code = 404
|
||
|
||
mock_get.side_effect = [mock_projects_response, mock_issue_types_response]
|
||
|
||
with pytest.raises(JiraGetProjectsError):
|
||
self.jira_integration.get_metadata()
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_projects_and_issue_types_no_project_metadata(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test that get_metadata returns empty issue_types when project metadata is empty."""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
# Mock successful projects response
|
||
mock_projects_response = MagicMock()
|
||
mock_projects_response.status_code = 200
|
||
mock_projects_response.json.return_value = [
|
||
{"key": "PROJ1", "name": "Project One"}
|
||
]
|
||
|
||
# Mock issue types response with empty projects list
|
||
mock_issue_types_response = MagicMock()
|
||
mock_issue_types_response.status_code = 200
|
||
mock_issue_types_response.json.return_value = {"projects": []}
|
||
|
||
mock_get.side_effect = [mock_projects_response, mock_issue_types_response]
|
||
|
||
projects_and_issue_types = self.jira_integration.get_metadata()
|
||
|
||
expected_result = {
|
||
"PROJ1": {
|
||
"name": "Project One",
|
||
"issue_types": [],
|
||
}
|
||
}
|
||
|
||
assert projects_and_issue_types == expected_result
|
||
|
||
@patch.object(
|
||
Jira,
|
||
"get_access_token",
|
||
side_effect=JiraRefreshTokenError("Failed to refresh the access token"),
|
||
)
|
||
def test_get_projects_and_issue_types_refresh_token_error(
|
||
self, mock_get_access_token
|
||
):
|
||
"""Test that get_metadata raises JiraRefreshTokenError when refreshing the token fails."""
|
||
# To disable vulture
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
with pytest.raises(JiraRefreshTokenError):
|
||
self.jira_integration.get_metadata()
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_projects_and_issue_types_mixed_scenarios(
|
||
self, mock_get, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test get_metadata with mixed success and empty metadata scenarios."""
|
||
# To disable vulture
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
|
||
# Mock projects response with two projects
|
||
mock_projects_response = MagicMock()
|
||
mock_projects_response.status_code = 200
|
||
mock_projects_response.json.return_value = [
|
||
{"key": "PROJ1", "name": "Project One"},
|
||
{"key": "PROJ2", "name": "Project Two"},
|
||
]
|
||
|
||
# Mock successful issue types response for first project
|
||
mock_issue_types_response_1 = MagicMock()
|
||
mock_issue_types_response_1.status_code = 200
|
||
mock_issue_types_response_1.json.return_value = {
|
||
"projects": [
|
||
{
|
||
"issuetypes": [
|
||
{"name": "Bug", "id": "1"},
|
||
{"name": "Task", "id": "2"},
|
||
]
|
||
}
|
||
]
|
||
}
|
||
|
||
# Mock empty issue types response for second project
|
||
mock_issue_types_response_2 = MagicMock()
|
||
mock_issue_types_response_2.status_code = 200
|
||
mock_issue_types_response_2.json.return_value = {"projects": []}
|
||
|
||
mock_get.side_effect = [
|
||
mock_projects_response,
|
||
mock_issue_types_response_1,
|
||
mock_issue_types_response_2,
|
||
]
|
||
|
||
projects_and_issue_types = self.jira_integration.get_metadata()
|
||
|
||
expected_result = {
|
||
"PROJ1": {
|
||
"name": "Project One",
|
||
"issue_types": ["Bug", "Task"],
|
||
},
|
||
"PROJ2": {
|
||
"name": "Project Two",
|
||
"issue_types": [],
|
||
},
|
||
}
|
||
|
||
assert projects_and_issue_types == expected_result
|
||
|
||
@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_successful(
|
||
self,
|
||
mock_post,
|
||
mock_get_issue_types,
|
||
mock_get_projects,
|
||
mock_cloud_id,
|
||
mock_get_access_token,
|
||
):
|
||
"""Test that send_finding returns a confirmed typed result."""
|
||
# 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(status_code=201, headers={})
|
||
mock_response.json.return_value = {"id": "10001", "key": "TEST-123"}
|
||
mock_post.return_value = mock_response
|
||
self.jira_integration._site_url = "https://example.atlassian.net"
|
||
|
||
result = 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.outcome == JiraCreationOutcome.CONFIRMED_SUCCESS
|
||
assert result.issue_key == "TEST-123"
|
||
assert result.issue_id == "10001"
|
||
assert result.issue_url == "https://example.atlassian.net/browse/TEST-123"
|
||
assert result.is_confirmed_success is True
|
||
assert bool(result) is True
|
||
mock_post.assert_called_once()
|
||
|
||
@pytest.mark.parametrize(
|
||
("payload", "json_error", "site_url", "error_code"),
|
||
[
|
||
(
|
||
None,
|
||
ValueError("invalid JSON"),
|
||
"https://example",
|
||
"malformed_success_response",
|
||
),
|
||
({"id": "10001"}, None, "https://example", "incomplete_success_response"),
|
||
({"key": "TEST-1"}, None, "https://example", "incomplete_success_response"),
|
||
(
|
||
{"id": "10001", "key": "invalid key"},
|
||
None,
|
||
"https://example",
|
||
"incomplete_success_response",
|
||
),
|
||
(
|
||
{"id": "not-an-id", "key": "TEST-1"},
|
||
None,
|
||
"https://example",
|
||
"incomplete_success_response",
|
||
),
|
||
(
|
||
{"id": "10001", "key": "TEST-1"},
|
||
None,
|
||
None,
|
||
"incomplete_success_response",
|
||
),
|
||
],
|
||
)
|
||
def test_send_finding_invalid_201_is_uncertain(
|
||
self,
|
||
send_finding_post,
|
||
jira_response,
|
||
payload,
|
||
json_error,
|
||
site_url,
|
||
error_code,
|
||
):
|
||
self.jira_integration._site_url = site_url
|
||
send_finding_post.post.return_value = jira_response(
|
||
201, payload, json_error=json_error
|
||
)
|
||
result = self.jira_integration.send_finding(
|
||
project_key="TEST", issue_type="Bug", delivery_attempt_marker="attempt-123"
|
||
)
|
||
|
||
assert result.outcome == JiraCreationOutcome.UNCERTAIN
|
||
assert result.error_code == error_code
|
||
assert result.delivery_marker == "attempt-123"
|
||
assert result.http_status == 201
|
||
assert bool(result) is False
|
||
if site_url is None:
|
||
assert (result.issue_id, result.issue_key, result.issue_url) == (
|
||
"10001",
|
||
"TEST-1",
|
||
None,
|
||
)
|
||
|
||
@pytest.mark.parametrize(
|
||
("status_code", "outcome", "retry_after"),
|
||
[
|
||
(401, JiraCreationOutcome.CONFIRMED_REJECTION, None),
|
||
(403, JiraCreationOutcome.CONFIRMED_REJECTION, None),
|
||
(408, JiraCreationOutcome.UNCERTAIN, None),
|
||
(429, JiraCreationOutcome.RETRYABLE_FAILURE, "17"),
|
||
],
|
||
)
|
||
def test_send_finding_classifies_http_failures(
|
||
self, send_finding_post, jira_response, status_code, outcome, retry_after
|
||
):
|
||
send_finding_post.post.return_value = jira_response(
|
||
status_code,
|
||
{"errorMessages": ["Jira error"]},
|
||
{"Retry-After": retry_after} if retry_after else None,
|
||
)
|
||
result = self.jira_integration.send_finding(
|
||
project_key="TEST", issue_type="Bug"
|
||
)
|
||
|
||
assert result.outcome == outcome
|
||
assert result.http_status == status_code
|
||
assert result.retry_after == retry_after
|
||
|
||
@pytest.mark.parametrize(
|
||
"transport_error, expected_outcome",
|
||
[
|
||
(
|
||
requests.exceptions.ConnectTimeout(),
|
||
JiraCreationOutcome.RETRYABLE_FAILURE,
|
||
),
|
||
(requests.exceptions.ReadTimeout(), JiraCreationOutcome.UNCERTAIN),
|
||
(requests.exceptions.ConnectionError(), JiraCreationOutcome.UNCERTAIN),
|
||
],
|
||
)
|
||
def test_send_finding_classifies_transport_failures(
|
||
self, send_finding_post, transport_error, expected_outcome
|
||
):
|
||
send_finding_post.post.side_effect = transport_error
|
||
result = self.jira_integration.send_finding(
|
||
project_key="TEST",
|
||
issue_type="Bug",
|
||
delivery_attempt_marker="stable-marker",
|
||
)
|
||
|
||
assert result.outcome == expected_outcome
|
||
assert result.delivery_marker == "stable-marker"
|
||
|
||
@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_returns_issue_url_with_basic_auth(
|
||
self,
|
||
mock_post,
|
||
mock_get_issue_types,
|
||
mock_get_projects,
|
||
mock_cloud_id,
|
||
mock_get_access_token,
|
||
):
|
||
"""Test that send_finding builds the browse URL from the basic auth site name."""
|
||
# 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(status_code=201, headers={})
|
||
mock_response.json.return_value = {"id": "10001", "key": "TEST-7"}
|
||
mock_post.return_value = mock_response
|
||
|
||
result = self.jira_integration_basic_auth.send_finding(
|
||
check_id="test-check",
|
||
check_title="Test Finding",
|
||
severity="High",
|
||
status="FAIL",
|
||
project_key="TEST",
|
||
issue_type="Bug",
|
||
)
|
||
|
||
assert result.outcome == JiraCreationOutcome.CONFIRMED_SUCCESS
|
||
assert result.issue_key == "TEST-7"
|
||
assert result.issue_id == "10001"
|
||
assert result.issue_url == "https://test-domain.atlassian.net/browse/TEST-7"
|
||
|
||
@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_sanitizes_issue_labels(
|
||
self,
|
||
mock_post,
|
||
mock_get_issue_types,
|
||
mock_get_projects,
|
||
mock_cloud_id,
|
||
mock_get_access_token,
|
||
):
|
||
"""Test that labels are sanitized, deduplicated and empties dropped before sending."""
|
||
# 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(status_code=201, headers={})
|
||
mock_response.json.return_value = {"id": "10001", "key": "TEST-123"}
|
||
mock_post.return_value = mock_response
|
||
|
||
self.jira_integration.send_finding(
|
||
check_id="test-check",
|
||
check_title="Test Finding",
|
||
severity="High",
|
||
status="FAIL",
|
||
project_key="TEST",
|
||
issue_type="Bug",
|
||
issue_labels=[
|
||
"prowler",
|
||
"prowler-finding-arn:aws:s3:::my bucket/with space",
|
||
"prowler",
|
||
"",
|
||
" ",
|
||
],
|
||
)
|
||
|
||
payload = mock_post.call_args.kwargs["json"]
|
||
assert payload["fields"]["labels"] == [
|
||
"prowler",
|
||
"prowler-finding-arn:aws:s3:::my_bucket/with_space",
|
||
]
|
||
|
||
def test_send_finding_reuses_marker_and_caches_valid_destinations(
|
||
self, send_finding_post, jira_response
|
||
):
|
||
"""Test retries keep one marker and catalogs are cached per destination."""
|
||
responses = [
|
||
jira_response(
|
||
201,
|
||
{
|
||
"id": str(10001 + index),
|
||
"key": f"TEST-{index + 1}",
|
||
},
|
||
)
|
||
for index in range(3)
|
||
]
|
||
send_finding_post.projects.return_value = {"TEST": {}, "OPS": {}}
|
||
send_finding_post.issue_types.side_effect = [["Bug"], ["Task"]]
|
||
send_finding_post.post.side_effect = responses
|
||
|
||
for _ in range(2):
|
||
self.jira_integration.send_finding(
|
||
project_key="TEST",
|
||
issue_type="Bug",
|
||
delivery_attempt_marker="marker 123",
|
||
)
|
||
self.jira_integration.send_finding(
|
||
project_key="OPS",
|
||
issue_type="Task",
|
||
delivery_attempt_marker="marker 123",
|
||
)
|
||
|
||
assert send_finding_post.projects.call_count == 2
|
||
assert send_finding_post.issue_types.call_count == 2
|
||
labels = [
|
||
call.kwargs["json"]["fields"]["labels"]
|
||
for call in send_finding_post.post.call_args_list
|
||
]
|
||
assert labels == [["prowler-attempt-marker_123"]] * 3
|
||
|
||
@pytest.mark.parametrize(
|
||
"projects, issue_types, expected_code",
|
||
[
|
||
({}, ["Bug"], "invalid_project"),
|
||
({"TEST": {}}, [], "invalid_issue_type"),
|
||
(JiraNoProjectsError(message="No projects"), ["Bug"], "invalid_project"),
|
||
],
|
||
)
|
||
def test_send_finding_invalid_destination_is_confirmed_rejection(
|
||
self, send_finding_post, projects, issue_types, expected_code
|
||
):
|
||
if isinstance(projects, Exception):
|
||
send_finding_post.projects.side_effect = projects
|
||
else:
|
||
send_finding_post.projects.return_value = projects
|
||
send_finding_post.issue_types.return_value = issue_types
|
||
result = self.jira_integration.send_finding(
|
||
project_key="TEST", issue_type="Bug"
|
||
)
|
||
|
||
assert result.outcome == JiraCreationOutcome.CONFIRMED_REJECTION
|
||
assert result.error_code == expected_code
|
||
send_finding_post.post.assert_not_called()
|
||
|
||
@pytest.mark.parametrize(
|
||
("failure_stage", "status_code", "outcome", "error_code", "retry_after"),
|
||
[
|
||
(
|
||
"projects",
|
||
403,
|
||
JiraCreationOutcome.CONFIRMED_REJECTION,
|
||
"invalid_credentials",
|
||
None,
|
||
),
|
||
(
|
||
"issue_types",
|
||
403,
|
||
JiraCreationOutcome.CONFIRMED_REJECTION,
|
||
"invalid_credentials",
|
||
None,
|
||
),
|
||
(
|
||
"projects",
|
||
429,
|
||
JiraCreationOutcome.RETRYABLE_FAILURE,
|
||
"destination_temporarily_unavailable",
|
||
"23",
|
||
),
|
||
],
|
||
)
|
||
def test_send_finding_classifies_catalog_failures(
|
||
self,
|
||
oauth_post,
|
||
jira_response,
|
||
failure_stage,
|
||
status_code,
|
||
outcome,
|
||
error_code,
|
||
retry_after,
|
||
):
|
||
failure = jira_response(
|
||
status_code, headers={"Retry-After": retry_after} if retry_after else None
|
||
)
|
||
failure.text = "unsafe response body"
|
||
responses = [failure]
|
||
if failure_stage == "issue_types":
|
||
projects = jira_response(200, [{"key": "TEST", "name": "Test"}])
|
||
responses = [projects, failure]
|
||
|
||
with patch("prowler.lib.outputs.jira.jira.requests.get", side_effect=responses):
|
||
result = self.jira_integration.send_finding(
|
||
project_key="TEST", issue_type="Bug"
|
||
)
|
||
|
||
assert result.outcome == outcome
|
||
assert result.http_status == status_code
|
||
assert result.error_code == error_code
|
||
assert result.retry_after == retry_after
|
||
assert "unsafe response body" not in result.error_message
|
||
oauth_post.assert_not_called()
|
||
|
||
def test_sanitize_label(self):
|
||
"""Test the deterministic Jira label sanitizer."""
|
||
assert Jira.sanitize_label("") == ""
|
||
assert Jira.sanitize_label(None) == ""
|
||
assert Jira.sanitize_label(" ") == ""
|
||
assert Jira.sanitize_label("simple") == "simple"
|
||
assert Jira.sanitize_label("with space") == "with_space"
|
||
assert Jira.sanitize_label(" many spaces \t tabs\nnewline ") == (
|
||
"many_spaces_tabs_newline"
|
||
)
|
||
assert Jira.sanitize_label("ctrl\x00char\x07here") == "ctrlcharhere"
|
||
assert Jira.sanitize_label("__ Keep__CASE Here __") == "Keep_CASE_Here"
|
||
assert Jira.sanitize_label("arn:aws:iam::123456789012:role/Admin") == (
|
||
"arn:aws:iam::123456789012:role/Admin"
|
||
)
|
||
long_label = "x" * 300
|
||
assert Jira.sanitize_label(long_label) == "x" * 255
|
||
# Idempotent: sanitizing an already sanitized value is a no-op
|
||
once = Jira.sanitize_label("a b\tc")
|
||
assert Jira.sanitize_label(once) == once
|
||
|
||
def test_prefixed_labels_are_length_safe_and_collision_resistant(self):
|
||
"""Test finding and attempt labels preserve identity at Jira's limit."""
|
||
assert Jira.build_finding_label("") == ""
|
||
assert Jira.build_delivery_attempt_label(None) == ""
|
||
finding_prefix = f"{Jira.FINDING_LABEL_PREFIX}-"
|
||
exact_uid = "A" * (Jira.LABEL_MAX_LENGTH - len(finding_prefix))
|
||
assert Jira.build_finding_label(exact_uid) == f"{finding_prefix}{exact_uid}"
|
||
|
||
long_prefix = "A" * 400
|
||
first_uid = f"{long_prefix}-first"
|
||
second_uid = f"{long_prefix}-second"
|
||
first_label = Jira.build_finding_label(first_uid)
|
||
second_label = Jira.build_finding_label(second_uid)
|
||
|
||
assert len(first_label) == Jira.LABEL_MAX_LENGTH
|
||
assert len(second_label) == Jira.LABEL_MAX_LENGTH
|
||
assert first_label.endswith(hashlib.sha256(first_uid.encode()).hexdigest())
|
||
assert second_label.endswith(hashlib.sha256(second_uid.encode()).hexdigest())
|
||
assert first_label != second_label
|
||
assert Jira.build_delivery_attempt_label("Attempt CASE") == (
|
||
"prowler-attempt-Attempt_CASE"
|
||
)
|
||
long_attempt_label = Jira.build_delivery_attempt_label(first_uid)
|
||
assert len(long_attempt_label) == Jira.LABEL_MAX_LENGTH
|
||
assert long_attempt_label.endswith(
|
||
hashlib.sha256(first_uid.encode()).hexdigest()
|
||
)
|
||
|
||
def test_sanitize_labels(self):
|
||
"""Test list sanitization keeps order, drops empties and duplicates."""
|
||
assert Jira.sanitize_labels(None) == []
|
||
assert Jira.sanitize_labels([]) == []
|
||
assert Jira.sanitize_labels(["b", "a b", "b", "", "a_b"]) == ["b", "a_b"]
|
||
|
||
def test_site_url_and_issue_url(self):
|
||
"""Test site_url derivation and browse URL building for both auth modes."""
|
||
assert (
|
||
self.jira_integration_basic_auth.site_url
|
||
== "https://test-domain.atlassian.net"
|
||
)
|
||
assert (
|
||
self.jira_integration_basic_auth.get_issue_url("TEST-1")
|
||
== "https://test-domain.atlassian.net/browse/TEST-1"
|
||
)
|
||
# OAuth: unknown until accessible-resources is fetched
|
||
assert self.jira_integration.site_url is None
|
||
assert self.jira_integration.get_issue_url("TEST-1") is None
|
||
self.jira_integration._site_url = "https://oauth-site.atlassian.net/"
|
||
assert (
|
||
self.jira_integration.get_issue_url("TEST-1")
|
||
== "https://oauth-site.atlassian.net/browse/TEST-1"
|
||
)
|
||
assert self.jira_integration.get_issue_url("") is None
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_get_issues_status(self, mock_post, mock_cloud_id, mock_get_access_token):
|
||
"""Test bulk status lookup returns explicit outcomes in input order."""
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
mock_response = MagicMock(status_code=200, headers={})
|
||
mock_response.json.return_value = {
|
||
"issues": [
|
||
{
|
||
"id": "10001",
|
||
"key": "SEC-1",
|
||
"fields": {
|
||
"status": {
|
||
"name": "Resolved by policy",
|
||
"statusCategory": {"key": "done"},
|
||
}
|
||
},
|
||
},
|
||
{
|
||
"id": "10002",
|
||
"key": "SEC-2",
|
||
"fields": {
|
||
"status": {
|
||
"name": "In Progress",
|
||
"statusCategory": {"key": "indeterminate"},
|
||
}
|
||
},
|
||
},
|
||
],
|
||
"issueErrors": [{"id": "10003", "statusCode": 404}],
|
||
}
|
||
mock_post.return_value = mock_response
|
||
|
||
references = [
|
||
JiraIssueReference("10001", "SEC-1"),
|
||
JiraIssueReference("10002", "SEC-2"),
|
||
JiraIssueReference("10003", "SEC-3"),
|
||
JiraIssueReference("10001", "SEC-1"),
|
||
]
|
||
result = self.jira_integration.get_issues_status(references)
|
||
|
||
assert [item.reference for item in result] == references[:3]
|
||
assert [item.outcome for item in result] == [
|
||
JiraIssueLookupOutcome.DONE,
|
||
JiraIssueLookupOutcome.OPEN,
|
||
JiraIssueLookupOutcome.MISSING,
|
||
]
|
||
assert result[0].status == "Resolved by policy"
|
||
assert result[1].status_category == "indeterminate"
|
||
assert result[2].http_status == 404
|
||
mock_post.assert_called_once()
|
||
assert mock_post.call_args.args[0].endswith("/rest/api/3/issue/bulkfetch")
|
||
assert mock_post.call_args.kwargs["json"] == {
|
||
"issueIdsOrKeys": ["10001", "10002", "10003"],
|
||
"fields": ["status"],
|
||
}
|
||
|
||
def test_get_issues_status_moved_precedes_status_and_parses_mixed_errors(
|
||
self, oauth_post, jira_response, jira_issue
|
||
):
|
||
self.jira_integration._site_url = "https://example.atlassian.net"
|
||
oauth_post.return_value = jira_response(
|
||
200,
|
||
{
|
||
"issues": [
|
||
jira_issue("10001", "OPS-9", "Custom completion", "done"),
|
||
jira_issue("10004", "SEC-4"),
|
||
jira_issue("10006", "SEC-6", "Custom", "unclassified"),
|
||
],
|
||
"issueErrors": [
|
||
{"issueIdOrKey": "10002", "errorCode": 403},
|
||
{"issueId": "10003", "status": 404},
|
||
],
|
||
},
|
||
)
|
||
references = [
|
||
JiraIssueReference("10001", "SEC-1"),
|
||
JiraIssueReference("10002", "SEC-2"),
|
||
JiraIssueReference("10003", "SEC-3"),
|
||
JiraIssueReference("10004", "SEC-4"),
|
||
JiraIssueReference("10005", "SEC-5"),
|
||
JiraIssueReference("10006", "SEC-6"),
|
||
]
|
||
|
||
results = self.jira_integration.get_issues_status(references)
|
||
|
||
assert [result.outcome for result in results] == [
|
||
JiraIssueLookupOutcome.MOVED,
|
||
JiraIssueLookupOutcome.FORBIDDEN,
|
||
JiraIssueLookupOutcome.MISSING,
|
||
JiraIssueLookupOutcome.UNKNOWN,
|
||
JiraIssueLookupOutcome.UNKNOWN,
|
||
JiraIssueLookupOutcome.UNKNOWN,
|
||
]
|
||
assert results[0].current_issue_key == "OPS-9"
|
||
assert results[0].current_issue_url.endswith("/browse/OPS-9")
|
||
assert results[0].status == "Custom completion"
|
||
assert results[3].error_code == "malformed_issue"
|
||
assert results[4].error_code == "omitted_issue"
|
||
assert results[5].error_code == "malformed_issue"
|
||
|
||
def test_get_issues_status_malformed_batch_is_unknown(
|
||
self, oauth_post, jira_response
|
||
):
|
||
oauth_post.return_value = jira_response(200, {"issues": "invalid"})
|
||
reference = JiraIssueReference("10001", "SEC-1")
|
||
|
||
result = self.jira_integration.get_issues_status([reference])[0]
|
||
|
||
assert result.outcome == JiraIssueLookupOutcome.UNKNOWN
|
||
assert result.error_code == "malformed_response"
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_get_issues_status_batches_requests(
|
||
self, mock_post, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test that more than ISSUE_STATUS_BATCH_SIZE keys are fetched in batches."""
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
mock_response = MagicMock(status_code=200, headers={})
|
||
mock_response.json.return_value = {"issues": []}
|
||
mock_post.return_value = mock_response
|
||
|
||
references = [JiraIssueReference(str(i), f"SEC-{i}") for i in range(1, 251)]
|
||
results = self.jira_integration.get_issues_status(references)
|
||
assert len(results) == 250
|
||
assert all(
|
||
result.outcome == JiraIssueLookupOutcome.UNKNOWN for result in results
|
||
)
|
||
assert mock_post.call_count == 3
|
||
sizes = [
|
||
len(call.kwargs["json"]["issueIdsOrKeys"])
|
||
for call in mock_post.call_args_list
|
||
]
|
||
assert sizes == [100, 100, 50]
|
||
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_get_issues_status_without_keys(self, mock_post):
|
||
"""Test that no request is made when there is nothing to look up."""
|
||
assert self.jira_integration.get_issues_status([]) == []
|
||
invalid_references = [
|
||
JiraIssueReference("1", "SEC-1\n"),
|
||
JiraIssueReference("2", "SEC-١"),
|
||
JiraIssueReference("not-an-id", "SEC-3"),
|
||
]
|
||
results = self.jira_integration.get_issues_status(invalid_references)
|
||
assert [result.outcome for result in results] == [
|
||
JiraIssueLookupOutcome.UNKNOWN,
|
||
JiraIssueLookupOutcome.UNKNOWN,
|
||
JiraIssueLookupOutcome.UNKNOWN,
|
||
]
|
||
assert all(result.error_code == "invalid_reference" for result in results)
|
||
mock_post.assert_not_called()
|
||
|
||
def test_get_issues_status_preserves_success_when_later_batch_fails(
|
||
self, oauth_post, jira_response, jira_issue
|
||
):
|
||
"""Test that a failing later batch does not erase earlier results."""
|
||
ok = jira_response(
|
||
200,
|
||
{"issues": [jira_issue("1", "SEC-1", "Done", "done")]},
|
||
)
|
||
failed = jira_response(502, headers={"Retry-After": "12"})
|
||
oauth_post.side_effect = [ok, failed]
|
||
|
||
references = [JiraIssueReference(str(i), f"SEC-{i}") for i in range(1, 102)]
|
||
results = self.jira_integration.get_issues_status(references)
|
||
assert results[0].outcome == JiraIssueLookupOutcome.DONE
|
||
assert all(
|
||
result.outcome == JiraIssueLookupOutcome.UNKNOWN for result in results[1:]
|
||
)
|
||
assert results[-1].http_status == 502
|
||
assert results[-1].retry_after == "12"
|
||
assert oauth_post.call_count == 2
|
||
|
||
@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("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_get_issues_status_response_error(
|
||
self, mock_post, mock_cloud_id, mock_get_access_token
|
||
):
|
||
"""Test that a whole-batch error produces unknown without losing input."""
|
||
mock_cloud_id = mock_cloud_id
|
||
mock_get_access_token = mock_get_access_token
|
||
mock_response = MagicMock(status_code=403, headers={})
|
||
mock_response.text = "forbidden"
|
||
mock_post.return_value = mock_response
|
||
|
||
reference = JiraIssueReference("10001", "SEC-1")
|
||
result = self.jira_integration.get_issues_status([reference])[0]
|
||
assert result.reference == reference
|
||
assert result.outcome == JiraIssueLookupOutcome.UNKNOWN
|
||
assert result.http_status == 403
|
||
|
||
@patch.object(Jira, "get_access_token", side_effect=JiraGetAccessTokenError())
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_get_issues_status_access_token_error_returns_unknown(
|
||
self, mock_post, mock_get_access_token
|
||
):
|
||
reference = JiraIssueReference("10001", "SEC-1")
|
||
result = self.jira_integration.get_issues_status([reference])[0]
|
||
|
||
assert result.outcome == JiraIssueLookupOutcome.UNKNOWN
|
||
assert result.error_code == "authentication_failed"
|
||
mock_get_access_token.assert_called_once_with()
|
||
mock_post.assert_not_called()
|
||
|
||
@pytest.mark.parametrize(
|
||
"issues",
|
||
[
|
||
[],
|
||
[{"id": "10001", "key": "SEC-1"}],
|
||
[
|
||
{"id": "10001", "key": "SEC-1"},
|
||
{"id": "10002", "key": "SEC-2"},
|
||
],
|
||
],
|
||
)
|
||
def test_search_issues_by_delivery_attempt_returns_all_matches(
|
||
self, oauth_post, jira_response, issues
|
||
):
|
||
self.jira_integration._site_url = "https://example.atlassian.net"
|
||
oauth_post.return_value = jira_response(200, {"issues": issues})
|
||
result = self.jira_integration.search_issues_by_delivery_attempt("attempt 123")
|
||
|
||
assert result.outcome == JiraIssueSearchOutcome.SUCCESS
|
||
assert [match.issue_id for match in result.matches] == [
|
||
issue["id"] for issue in issues
|
||
]
|
||
assert all(
|
||
match.issue_url.endswith(match.issue_key) for match in result.matches
|
||
)
|
||
assert oauth_post.call_args.args[0].endswith("/rest/api/3/search/jql")
|
||
assert oauth_post.call_args.kwargs["json"]["jql"] == (
|
||
'labels = "prowler-attempt-attempt_123"'
|
||
)
|
||
|
||
@pytest.mark.parametrize(
|
||
"response_kwargs, side_effect, expected_outcome, expected_code, retry_after",
|
||
[
|
||
(
|
||
{"status_code": 429, "headers": {"Retry-After": "9"}},
|
||
None,
|
||
JiraIssueSearchOutcome.RETRYABLE_FAILURE,
|
||
"jira_http_429",
|
||
"9",
|
||
),
|
||
(
|
||
{"json_error": ValueError("invalid JSON")},
|
||
None,
|
||
JiraIssueSearchOutcome.UNKNOWN,
|
||
"malformed_response",
|
||
None,
|
||
),
|
||
(
|
||
{},
|
||
requests.exceptions.ReadTimeout(),
|
||
JiraIssueSearchOutcome.RETRYABLE_FAILURE,
|
||
"transport_failure",
|
||
None,
|
||
),
|
||
(
|
||
{"payload": {"issues": [], "nextPageToken": "repeated"}},
|
||
None,
|
||
JiraIssueSearchOutcome.UNKNOWN,
|
||
"pagination_stalled",
|
||
None,
|
||
),
|
||
],
|
||
)
|
||
def test_search_issues_by_delivery_attempt_classifies_failures(
|
||
self,
|
||
oauth_post,
|
||
jira_response,
|
||
response_kwargs,
|
||
side_effect,
|
||
expected_outcome,
|
||
expected_code,
|
||
retry_after,
|
||
):
|
||
oauth_post.return_value = jira_response(**response_kwargs)
|
||
oauth_post.side_effect = side_effect
|
||
result = self.jira_integration.search_issues_by_delivery_attempt("attempt-123")
|
||
|
||
assert result.outcome == expected_outcome
|
||
assert result.error_code == expected_code
|
||
assert result.retry_after == retry_after
|
||
|
||
@patch.object(Jira, "get_access_token", side_effect=JiraGetAccessTokenError())
|
||
@patch("prowler.lib.outputs.jira.jira.requests.post")
|
||
def test_search_issues_by_delivery_attempt_access_token_error_returns_unknown(
|
||
self, mock_post, mock_get_access_token
|
||
):
|
||
result = self.jira_integration.search_issues_by_delivery_attempt("attempt-123")
|
||
|
||
assert result.outcome == JiraIssueSearchOutcome.UNKNOWN
|
||
assert result.error_code == "authentication_failed"
|
||
mock_get_access_token.assert_called_once_with()
|
||
mock_post.assert_not_called()
|
||
|
||
@pytest.mark.parametrize(
|
||
"issue",
|
||
[
|
||
{"id": "not-an-id", "key": "SEC-1"},
|
||
{"id": "10001", "key": "invalid key"},
|
||
],
|
||
)
|
||
def test_search_issues_by_delivery_attempt_rejects_invalid_identity(
|
||
self, oauth_post, jira_response, issue
|
||
):
|
||
self.jira_integration._site_url = "https://example.atlassian.net"
|
||
oauth_post.return_value = jira_response(200, {"issues": [issue]})
|
||
result = self.jira_integration.search_issues_by_delivery_attempt("attempt-123")
|
||
|
||
assert result.outcome == JiraIssueSearchOutcome.UNKNOWN
|
||
assert result.error_code == "malformed_issue"
|
||
|
||
@patch("prowler.lib.outputs.jira.jira.requests.get")
|
||
def test_get_cloud_id_captures_site_url(self, mock_get):
|
||
"""Test that the OAuth cloud id lookup records the site URL."""
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = [
|
||
{"id": "cloud-1", "url": "https://oauth-site.atlassian.net"}
|
||
]
|
||
mock_get.return_value = mock_response
|
||
|
||
assert self.jira_integration.get_cloud_id("token") == "cloud-1"
|
||
assert self.jira_integration.site_url == "https://oauth-site.atlassian.net"
|
||
|
||
@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_sanitizes_summary_control_characters(
|
||
self,
|
||
mock_post,
|
||
mock_get_issue_types,
|
||
mock_get_projects,
|
||
mock_cloud_id,
|
||
mock_get_access_token,
|
||
):
|
||
"""Test that Jira summary is sent as one line."""
|
||
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(status_code=201, headers={})
|
||
mock_response.json.return_value = {"id": "10001", "key": "TEST-123"}
|
||
mock_post.return_value = mock_response
|
||
long_check_id = "check\nwith\rcontrol\tcharacters " + "x" * 260
|
||
|
||
result = self.jira_integration.send_finding(
|
||
check_id=long_check_id,
|
||
check_title="Test Finding",
|
||
severity="High\n",
|
||
status="FAIL",
|
||
project_key="TEST",
|
||
issue_type="Bug",
|
||
affected_failing_resources=2,
|
||
grouped_resources=[],
|
||
)
|
||
|
||
assert result.issue_key == "TEST-123"
|
||
payload = mock_post.call_args.kwargs["json"]
|
||
expected_summary = (
|
||
f"[Prowler] HIGH - {' '.join(long_check_id.split())} - "
|
||
"2 affected failing resources"
|
||
)[:255]
|
||
assert payload["fields"]["summary"] == expected_summary
|
||
assert len(payload["fields"]["summary"]) == 255
|
||
|
||
@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_failure(
|
||
self,
|
||
mock_post,
|
||
mock_get_issue_types,
|
||
mock_get_projects,
|
||
mock_cloud_id,
|
||
mock_get_access_token,
|
||
):
|
||
"""Test that a definitive Jira 400 is a confirmed rejection."""
|
||
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(status_code=400, headers={})
|
||
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",
|
||
)
|
||
|
||
assert result.outcome == JiraCreationOutcome.CONFIRMED_REJECTION
|
||
assert result.error_code == "jira_http_400"
|
||
assert "'Team': 'Team is required.'" in result.error_message
|
||
assert "Field 'Team' cannot be set." in result.error_message
|
||
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 a 5xx response is uncertain even without a JSON body."""
|
||
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(status_code=502, headers={})
|
||
mock_response.json.side_effect = ValueError("No JSON body")
|
||
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",
|
||
)
|
||
|
||
assert result.outcome == JiraCreationOutcome.UNCERTAIN
|
||
assert result.error_code == "jira_http_502"
|
||
assert "Jira returned status code 502" in result.error_message
|
||
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_custom_fields_error(
|
||
self,
|
||
mock_post,
|
||
mock_get_issue_types,
|
||
mock_get_projects,
|
||
mock_cloud_id,
|
||
mock_get_access_token,
|
||
):
|
||
"""Test that required custom fields are a confirmed rejection."""
|
||
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(status_code=400, headers={})
|
||
mock_response.json.return_value = {
|
||
"errors": {
|
||
"customfield_10001": "This custom field is required",
|
||
"customfield_10002": "Invalid value for custom field",
|
||
}
|
||
}
|
||
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",
|
||
)
|
||
|
||
assert result.outcome == JiraCreationOutcome.CONFIRMED_REJECTION
|
||
assert "customfield_10001" in result.error_message
|
||
mock_post.assert_called_once()
|
||
|
||
@pytest.mark.parametrize(
|
||
"access_token_error",
|
||
[
|
||
JiraRefreshTokenError(message="Failed to refresh the access token"),
|
||
JiraGetAccessTokenError(message="Failed to get the access token"),
|
||
],
|
||
)
|
||
@patch.object(Jira, "get_access_token")
|
||
def test_send_finding_access_token_errors_are_retryable(
|
||
self, mock_get_access_token, access_token_error
|
||
):
|
||
"""Test access-token failures before sending are retryable."""
|
||
mock_get_access_token.side_effect = access_token_error
|
||
result = 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.outcome == JiraCreationOutcome.RETRYABLE_FAILURE
|
||
|
||
@patch.object(Jira, "get_access_token", return_value=None)
|
||
def test_send_finding_reraises_no_token_error(self, mock_get_access_token):
|
||
"""Test missing credentials are a confirmed rejection."""
|
||
mock_get_access_token = mock_get_access_token
|
||
result = 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.outcome == JiraCreationOutcome.CONFIRMED_REJECTION
|
||
|
||
@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 refresh response failures before sending are retryable."""
|
||
mock_get_access_token = mock_get_access_token
|
||
result = 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.outcome == JiraCreationOutcome.RETRYABLE_FAILURE
|
||
|
||
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
|
||
|
||
headers = self.jira_integration.get_headers(
|
||
access_token="test_oauth_token", content_type_json=True
|
||
)
|
||
|
||
expected_headers = {
|
||
"Authorization": "Bearer test_oauth_token",
|
||
"Content-Type": "application/json",
|
||
"X-Force-Accept-Language": "true",
|
||
"Accept-Language": "en",
|
||
}
|
||
assert headers == expected_headers
|
||
|
||
def test_get_headers_oauth_without_content_type(self):
|
||
"""Test get_headers returns OAuth headers without Content-Type when content_type_json=False."""
|
||
self.jira_integration._using_basic_auth = False
|
||
|
||
headers = self.jira_integration.get_headers(
|
||
access_token="test_oauth_token", content_type_json=False
|
||
)
|
||
|
||
expected_headers = {
|
||
"Authorization": "Bearer test_oauth_token",
|
||
"X-Force-Accept-Language": "true",
|
||
"Accept-Language": "en",
|
||
}
|
||
assert headers == expected_headers
|
||
assert "Content-Type" not in headers
|
||
|
||
def test_get_headers_basic_auth_with_access_token(self):
|
||
"""Test get_headers returns correct Basic Auth headers with access token."""
|
||
self.jira_integration_basic_auth._using_basic_auth = True
|
||
|
||
headers = self.jira_integration_basic_auth.get_headers(
|
||
access_token="test_basic_token", content_type_json=True
|
||
)
|
||
|
||
expected_headers = {
|
||
"Authorization": "Basic test_basic_token",
|
||
"Content-Type": "application/json",
|
||
"X-Force-Accept-Language": "true",
|
||
"Accept-Language": "en",
|
||
}
|
||
assert headers == expected_headers
|
||
|
||
def test_get_headers_basic_auth_without_content_type(self):
|
||
"""Test get_headers returns Basic Auth headers without Content-Type when content_type_json=False."""
|
||
self.jira_integration_basic_auth._using_basic_auth = True
|
||
|
||
headers = self.jira_integration_basic_auth.get_headers(
|
||
access_token="test_basic_token", content_type_json=False
|
||
)
|
||
|
||
expected_headers = {
|
||
"Authorization": "Basic test_basic_token",
|
||
"X-Force-Accept-Language": "true",
|
||
"Accept-Language": "en",
|
||
}
|
||
assert headers == expected_headers
|
||
assert "Content-Type" not in headers
|
||
|
||
def test_get_headers_without_access_token_with_content_type(self):
|
||
"""Test get_headers returns headers without Authorization when no access token provided."""
|
||
headers = self.jira_integration.get_headers(content_type_json=True)
|
||
|
||
expected_headers = {
|
||
"Content-Type": "application/json",
|
||
"X-Force-Accept-Language": "true",
|
||
"Accept-Language": "en",
|
||
}
|
||
assert headers == expected_headers
|
||
assert "Authorization" not in headers
|
||
|
||
def test_get_headers_without_access_token_without_content_type(self):
|
||
"""Test get_headers returns minimal headers when no access token and no content type."""
|
||
headers = self.jira_integration.get_headers(content_type_json=False)
|
||
|
||
expected_headers = {
|
||
"X-Force-Accept-Language": "true",
|
||
"Accept-Language": "en",
|
||
}
|
||
assert headers == expected_headers
|
||
assert "Authorization" not in headers
|
||
assert "Content-Type" not in headers
|
||
|
||
def test_get_headers_default_parameters(self):
|
||
"""Test get_headers with default parameters (no access token, no content type)."""
|
||
headers = self.jira_integration.get_headers()
|
||
|
||
expected_headers = {
|
||
"X-Force-Accept-Language": "true",
|
||
"Accept-Language": "en",
|
||
}
|
||
assert headers == expected_headers
|
||
assert "Authorization" not in headers
|
||
assert "Content-Type" not in headers
|