diff --git a/mcp_server/changelog.d/integrations-tests.added.md b/mcp_server/changelog.d/integrations-tests.added.md new file mode 100644 index 0000000000..a349f9af7a --- /dev/null +++ b/mcp_server/changelog.d/integrations-tests.added.md @@ -0,0 +1 @@ +Test coverage for the integrations tools and models, pinning the connection-check choreography and the Jira dispatch retry safety diff --git a/mcp_server/changelog.d/list-integrations-sparse-fieldset.changed.md b/mcp_server/changelog.d/list-integrations-sparse-fieldset.changed.md new file mode 100644 index 0000000000..032b729f07 --- /dev/null +++ b/mcp_server/changelog.d/list-integrations-sparse-fieldset.changed.md @@ -0,0 +1 @@ +`prowler_list_integrations` no longer requests the `configuration` it discards, now that the API tolerates a sparse fieldset without it diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/integrations.py b/mcp_server/prowler_mcp_server/prowler_app/models/integrations.py index 35d6c3d518..649e9e83f6 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/integrations.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/integrations.py @@ -295,14 +295,20 @@ class JiraDispatchResult(MinimalSerializerMixin, BaseModel): @classmethod def from_task_result( - cls, result: dict[str, Any], task_id: str | None = None + cls, result: Any, task_id: str | None = None ) -> "JiraDispatchResult": """Build the dispatch result from the completed background task result. Raises: - ValueError: If the task result does not carry both counters. Defaulting them to - zero would report a dispatch as retryable when it may have created work items + ValueError: If the task result is not an object, or does not carry both + counters. Defaulting them to zero would report a dispatch as retryable + when it may have created work items """ + if not isinstance(result, dict): + raise ValueError( + "The completed dispatch task did not report a result object." + ) + created_count = result.get("created_count") failed_count = result.get("failed_count") diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/integrations.py b/mcp_server/prowler_mcp_server/prowler_app/tools/integrations.py index e189a95684..dce20b32cf 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/integrations.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/integrations.py @@ -17,7 +17,6 @@ from prowler_mcp_server.prowler_app.models.integrations import ( IntegrationsListResponse, JiraDispatchResult, JiraIssueTypes, - SimplifiedIntegration, ) from prowler_mcp_server.prowler_app.tools.base import BaseTool @@ -25,7 +24,7 @@ from prowler_mcp_server.prowler_app.tools.base import BaseTool # detailed view returned by prowler_get_integration INTEGRATION_LIST_FIELDS = ( "enabled,connected,connection_last_checked_at,integration_type,providers," - "configuration,inserted_at,updated_at" + "inserted_at,updated_at" ) CONNECTION_CHECK_TIMEOUT = 120 @@ -36,6 +35,17 @@ JIRA_DISPATCH_TIMEOUT = 300 JIRA_REQUIRED_CREDENTIALS = ("domain", "user_mail", "api_token") +def _providers_relationship(provider_ids: list[str]) -> dict[str, Any]: + """Build the JSON:API relationship linkage attaching an integration to providers.""" + return { + "providers": { + "data": [ + {"type": "providers", "id": provider_id} for provider_id in provider_ids + ] + } + } + + class IntegrationsTools(BaseTool): """Tools for integration management operations. @@ -484,12 +494,22 @@ class IntegrationsTools(BaseTool): self.logger.info(f"Updating integration {integration_id}...") try: - current = await self._get_integration_raw(integration_id) - current_attributes = current["attributes"] - integration_type = current_attributes["integration_type"] + current = DetailedIntegration.from_api_response( + await self._get_integration_raw(integration_id) + ) + integration_type = current.integration_type if provider_ids is not None: - self._validate_provider_ids(integration_type, provider_ids) + if integration_type == "jira": + raise ValueError( + "Jira integrations are tenant-wide and cannot be attached to providers." + ) + if integration_type == "aws_security_hub" and len(provider_ids) != 1: + raise ValueError( + "AWS Security Hub integrations must stay attached to exactly one AWS " + f"provider, got {len(provider_ids)}. Pass a single provider ID, or use " + "prowler_delete_integration to stop sending findings to Security Hub." + ) attributes: dict[str, Any] = {} if enabled is not None: @@ -507,20 +527,16 @@ class IntegrationsTools(BaseTool): "Update the credentials instead, or run prowler_test_integration_connection to " "refresh the available projects and issue types." ) - merged = dict(current_attributes.get("configuration") or {}) + merged = dict(current.configuration) merged.update(self._as_dict(configuration, "configuration")) # Server-owned, the API repopulates it from the connection check merged.pop("regions", None) merged.pop("enabled_regions", None) attributes["configuration"] = merged - providers_changed = provider_ids is not None and sorted( - provider_ids - ) != sorted(SimplifiedIntegration._extract_provider_ids(current)) - if not attributes and provider_ids is None: self.logger.info("No changes provided, returning the current state") - return DetailedIntegration.from_api_response(current).model_dump() + return current.model_dump() update_body: dict[str, Any] = { "data": { @@ -530,33 +546,35 @@ class IntegrationsTools(BaseTool): } } if provider_ids is not None: - update_body["data"]["relationships"] = { - "providers": { - "data": [ - {"type": "providers", "id": provider_id} - for provider_id in provider_ids - ] - } - } + update_body["data"]["relationships"] = _providers_relationship( + provider_ids + ) await self.api_client.patch( f"/integrations/{integration_id}", json_data=update_body ) # A different provider means different effective credentials and different - # discovered configuration, so the stored connection state is stale - if ( + # discovered configuration, so the stored connection state is stale too + providers_changed = provider_ids is not None and set(provider_ids) != set( + current.provider_ids + ) + recheck_connection = ( credentials is not None or configuration is not None or providers_changed - ): - connection_status = await self._test_connection(integration_id) - updated = await self._get_integration_raw(integration_id) + ) + connection_status = ( + await self._test_connection(integration_id) + if recheck_connection + else None + ) + + updated = await self._get_integration_raw(integration_id) + if connection_status is not None: return IntegrationConnectionStatus.create( updated, connection_status ).model_dump() - - updated = await self._get_integration_raw(integration_id) return DetailedIntegration.from_api_response(updated).model_dump() except Exception as e: self.logger.error(f"Integration update failed: {e}") @@ -769,14 +787,10 @@ class IntegrationsTools(BaseTool): self.logger.error(f"Jira dispatch did not complete cleanly: {e}") return await self._jira_dispatch_fallback(task_id, str(e)) - task_result = completed_task.get("data", {}).get("attributes", {}).get("result") - try: - if not isinstance(task_result, dict): - raise ValueError( - "The completed dispatch task did not report a result object." - ) - return JiraDispatchResult.from_task_result(task_result).model_dump() + return JiraDispatchResult.from_task_result( + completed_task.get("data", {}).get("attributes", {}).get("result") + ).model_dump() except ValueError as e: self.logger.error(f"Jira dispatch result could not be read: {e}") return self._jira_dispatch_unknown(task_id, str(e)) @@ -828,22 +842,6 @@ class IntegrationsTools(BaseTool): ) return normalized - def _validate_provider_ids( - self, integration_type: str, provider_ids: list[str] - ) -> None: - """Reject provider changes an integration type cannot survive.""" - if integration_type == "jira": - raise ValueError( - "Jira integrations are tenant-wide and cannot be attached to providers." - ) - - if integration_type == "aws_security_hub" and len(provider_ids) != 1: - raise ValueError( - "AWS Security Hub integrations must stay attached to exactly one AWS provider, " - f"got {len(provider_ids)}. Pass a single provider ID, or use " - "prowler_delete_integration to stop sending findings to Security Hub." - ) - def _validate_credentials( self, integration_type: str, credentials: dict[str, Any] ) -> dict[str, Any]: @@ -934,14 +932,7 @@ class IntegrationsTools(BaseTool): } } if provider_ids: - create_body["data"]["relationships"] = { - "providers": { - "data": [ - {"type": "providers", "id": provider_id} - for provider_id in provider_ids - ] - } - } + create_body["data"]["relationships"] = _providers_relationship(provider_ids) api_response = await self.api_client.post( "/integrations", json_data=create_body diff --git a/mcp_server/tests/helpers/http.py b/mcp_server/tests/helpers/http.py index 2315c60ab3..b22994eac0 100644 --- a/mcp_server/tests/helpers/http.py +++ b/mcp_server/tests/helpers/http.py @@ -7,6 +7,7 @@ JSON decoding. A test that asserts on a recorded request is therefore asserting on the bytes that would really have gone out. """ +import json from collections.abc import Callable from typing import Any @@ -103,6 +104,15 @@ class MockRouter: """Return the decoded query parameters of the last request for a route.""" return dict(self.request_for(method, path).url.params) + def json_body(self, method: str, path: str) -> Any: + """Return the decoded JSON body of the last request for a route. + + Write tools build a JSON:API document by hand, and the API silently + ignores an attribute it does not recognise, so the body is the only place + a misspelled key shows up. + """ + return json.loads(self.request_for(method, path).content) + def paths(self) -> list[str]: """Return every request made so far, as ``"METHOD /path"`` strings.""" return [f"{request.method} {request.url.path}" for request in self.requests] diff --git a/mcp_server/tests/prowler_app/models/test_integrations.py b/mcp_server/tests/prowler_app/models/test_integrations.py new file mode 100644 index 0000000000..c62646866d --- /dev/null +++ b/mcp_server/tests/prowler_app/models/test_integrations.py @@ -0,0 +1,280 @@ +"""Tests for the integration models. + +Two things here are not ordinary serialization and carry the weight of the +module: the Security Hub ``regions`` map, which is rewritten into the far smaller +``enabled_regions`` list before an agent ever sees it, and the Jira dispatch +result, whose ``safe_to_retry`` flag is the only thing standing between a +half-finished dispatch and a project full of duplicated work items. +""" + +import pytest + +from prowler_mcp_server.prowler_app.models.integrations import ( + DetailedIntegration, + IntegrationConnectionStatus, + IntegrationsListResponse, + JiraDispatchResult, + JiraIssueTypes, + SimplifiedIntegration, +) +from tests.helpers.jsonapi import ( + jsonapi_collection, + jsonapi_relationship_many, + jsonapi_resource, +) + +S3_ATTRIBUTES = { + "integration_type": "amazon_s3", + "enabled": True, + "connected": True, + "connection_last_checked_at": "2025-01-15T10:00:00Z", + "inserted_at": "2025-01-10T09:00:00Z", + "updated_at": "2025-01-15T10:00:00Z", + "configuration": {"bucket_name": "my-reports", "output_directory": "prowler"}, +} + +SECURITY_HUB_ATTRIBUTES = { + "integration_type": "aws_security_hub", + "enabled": True, + "connected": True, + "configuration": { + "send_only_fails": True, + "archive_previous_findings": False, + "regions": {"us-east-1": True, "eu-west-1": False, "eu-west-3": True}, + }, +} + +JIRA_ATTRIBUTES = { + "integration_type": "jira", + "enabled": True, + "connected": None, + "configuration": {"domain": "acme", "projects": {}, "issue_types": {}}, +} + + +def test_simplified_integration_lifts_the_attached_provider_ids(): + """`provider_ids` comes from the relationship linkage, not the attributes. + + It is what tells an agent whether an integration covers the account it is + looking at, so reading it out of the wrong place silently scopes every + integration to the whole tenant. + """ + integration = SimplifiedIntegration.from_api_response( + jsonapi_resource( + "integrations", + "i1", + S3_ATTRIBUTES, + relationships={ + "providers": jsonapi_relationship_many("providers", "p1", "p2") + }, + ) + ) + + assert integration.provider_ids == ["p1", "p2"] + assert integration.integration_type == "amazon_s3" + + +def test_a_never_checked_integration_still_reports_its_connected_field(): + """`connected: null` means "never checked", which is not "not connected". + + Every other empty value is dropped to save tokens, so without the override + this field would vanish exactly when its absence is most misleading. + """ + integration = SimplifiedIntegration.from_api_response( + jsonapi_resource("integrations", "i1", {**JIRA_ATTRIBUTES, "connected": None}) + ) + + dumped = integration.model_dump() + + assert dumped["connected"] is None + # Contrast: an untouched empty field is dropped + assert "connection_last_checked_at" not in dumped + + +def test_the_list_view_drops_a_configuration_the_api_still_sends(): + """The sparse fieldset asks the API to leave `configuration` out. + + The model must drop it anyway rather than pass it through: the fieldset is a + request, not a guarantee, and a Jira configuration listing every project of + the site is exactly what the separate detailed view exists to hold back. + """ + integration = SimplifiedIntegration.from_api_response( + jsonapi_resource("integrations", "i1", JIRA_ATTRIBUTES) + ) + + assert "configuration" not in integration.model_dump() + + +def test_security_hub_regions_are_collapsed_into_the_enabled_ones(): + """The API returns every region of the partition with a boolean. + + Only the enabled ones carry information, so the map is rewritten as a sorted + list. Passing the raw map through would spend tokens listing dozens of + regions to say "no". + """ + integration = DetailedIntegration.from_api_response( + jsonapi_resource("integrations", "i1", SECURITY_HUB_ATTRIBUTES) + ) + + assert integration.configuration["enabled_regions"] == ["eu-west-3", "us-east-1"] + assert "regions" not in integration.configuration + + +def test_an_unexpected_regions_shape_is_preserved_rather_than_dropped(): + """A shape the rewrite does not understand is kept verbatim. + + Silently dropping it would hide a real API change behind an integration that + merely looks like it has no regions enabled. + """ + attributes = { + **SECURITY_HUB_ATTRIBUTES, + "configuration": {"regions": ["us-east-1"]}, + } + + integration = DetailedIntegration.from_api_response( + jsonapi_resource("integrations", "i1", attributes) + ) + + assert integration.configuration["regions"] == ["us-east-1"] + assert "enabled_regions" not in integration.configuration + + +def test_a_non_security_hub_configuration_is_passed_through_untouched(): + """Only Security Hub has a configuration worth rewriting.""" + integration = DetailedIntegration.from_api_response( + jsonapi_resource("integrations", "i1", S3_ATTRIBUTES) + ) + + assert integration.configuration == S3_ATTRIBUTES["configuration"] + + +def test_the_list_response_reports_the_pagination_of_the_whole_query(): + """Counts come from `meta.pagination`, not from the length of this page.""" + response = IntegrationsListResponse.from_api_response( + jsonapi_collection( + [jsonapi_resource("integrations", "i1", S3_ATTRIBUTES)], + page=2, + pages=3, + count=7, + ) + ) + + assert [integration.id for integration in response.integrations] == ["i1"] + assert (response.total_num_integrations, response.total_num_pages) == (7, 3) + assert response.current_page == 2 + + +@pytest.mark.parametrize( + ("connected", "expected"), + [(True, "connected"), (False, "failed"), (None, "not_tested")], +) +def test_the_connection_check_maps_its_tri_state_onto_a_readable_outcome( + connected, expected +): + """`null` is "the check did not run", which is not the same as a failure. + + Collapsing it onto `failed` would send an agent chasing credentials that were + never actually tested. + """ + status = IntegrationConnectionStatus.create( + jsonapi_resource("integrations", "i1", S3_ATTRIBUTES), + {"connected": connected}, + ) + + assert status.connected == expected + + +def test_an_unreadable_connection_result_raises_instead_of_guessing(): + """Anything other than a boolean or null is an API change, not a failure.""" + with pytest.raises(ValueError, match="unexpected connection check result"): + IntegrationConnectionStatus.create( + jsonapi_resource("integrations", "i1", S3_ATTRIBUTES), + {"connected": "yes"}, + ) + + +def test_the_connection_error_is_only_reported_when_there_is_one(): + """A successful check must not carry an empty `error` key.""" + status = IntegrationConnectionStatus.create( + jsonapi_resource("integrations", "i1", S3_ATTRIBUTES), {"connected": True} + ) + + assert "error" not in status.model_dump() + + +def test_jira_issue_types_are_read_from_a_wrapped_or_a_bare_payload(): + """This endpoint returns a non-model resource, so both shapes must work.""" + wrapped = JiraIssueTypes.from_api_response( + jsonapi_resource( + "jira-issue-types", "i1", {"project_key": "PROJ", "issue_types": ["Task"]} + ) + ) + bare = JiraIssueTypes.from_api_response( + {"project_key": "PROJ", "issue_types": ["Task"]} + ) + + assert ( + wrapped.model_dump() + == bare.model_dump() + == { + "project_key": "PROJ", + "issue_types": ["Task"], + } + ) + + +def test_an_unreadable_issue_types_payload_raises(): + """Returning an empty list would read as "this project has no issue types".""" + with pytest.raises(ValueError, match="unexpected Jira issue types payload"): + JiraIssueTypes.from_api_response({"project_key": "PROJ"}) + + +def test_a_dispatch_that_created_nothing_is_the_only_one_safe_to_retry(): + """Work items are created one by one and Prowler cannot delete them. + + So a retry is only safe when the run provably created none. Anything else + duplicates work items in a project a human then has to clean up. + """ + empty = JiraDispatchResult.from_task_result({"created_count": 0, "failed_count": 3}) + partial = JiraDispatchResult.from_task_result( + {"created_count": 1, "failed_count": 2} + ) + + assert empty.safe_to_retry is True + assert partial.safe_to_retry is False + + +def test_a_zero_count_survives_serialization(): + """Zero created work items is an outcome; an unknown count is not. + + The minimal serializer drops empty values, so without the override a fully + failed dispatch would report no counts at all. + """ + dumped = JiraDispatchResult.from_task_result( + {"created_count": 0, "failed_count": 3} + ).model_dump() + + assert dumped["created_count"] == 0 + assert dumped["failed_count"] == 3 + assert dumped["status"] == "completed" + + +@pytest.mark.parametrize( + "result", + [ + {"failed_count": 2}, + {"created_count": 1}, + {"created_count": "1", "failed_count": 0}, + None, + "done", + ], + ids=["no-created", "no-failed", "not-an-int", "null", "not-an-object"], +) +def test_a_dispatch_result_without_usable_counters_raises(result): + """Defaulting the counters to zero would report the run as safe to retry. + + That is the one wrong answer here: it invites a second dispatch on top of + work items that may already exist. + """ + with pytest.raises(ValueError, match="dispatch task did not report"): + JiraDispatchResult.from_task_result(result) diff --git a/mcp_server/tests/prowler_app/tools/test_integrations.py b/mcp_server/tests/prowler_app/tools/test_integrations.py new file mode 100644 index 0000000000..d46310a72c --- /dev/null +++ b/mcp_server/tests/prowler_app/tools/test_integrations.py @@ -0,0 +1,1040 @@ +"""Tests for the integrations tools. + +These tools are the only write surface in the MCP server that reaches a system +Prowler does not own -- an S3 bucket, a Security Hub account, a Jira project -- +so what goes out on the wire matters as much as what comes back. Three things +drive most of the assertions here: + +* Creating or updating an integration is a multi-request choreography (write, + connection check, task poll, re-read). ``mock_router.paths()`` is what pins it; + a skipped connection check leaves a Jira integration with no discovered + projects and is invisible in the response body. +* The API *replaces* credentials and configuration wholesale, so the guards that + refuse a partial payload are protecting stored secrets, not just being tidy. +* A Jira dispatch creates work items one at a time and Prowler cannot delete + them, so ``safe_to_retry`` must never be optimistic. + +As in ``test_findings``, tools are driven through an in-memory MCP client so +FastMCP resolves the pydantic ``Field`` defaults. +""" + +import pytest +from fastmcp import Client + +from tests.helpers.http import MockRouter +from tests.helpers.jsonapi import ( + jsonapi_collection, + jsonapi_document, + jsonapi_error, + jsonapi_relationship_many, + jsonapi_resource, +) + +INTEGRATIONS = "/api/v1/integrations" +INTEGRATION = f"{INTEGRATIONS}/i1" +CONNECTION = f"{INTEGRATION}/connection" +DISPATCHES = f"{INTEGRATION}/jira/dispatches" +ISSUE_TYPES = f"{INTEGRATION}/jira/issue_types" +TASK = "/api/v1/tasks/t1" + +S3_ATTRIBUTES = { + "integration_type": "amazon_s3", + "enabled": True, + "connected": True, + "connection_last_checked_at": "2025-01-15T10:00:00Z", + "configuration": {"bucket_name": "my-reports", "output_directory": "prowler"}, +} + +SECURITY_HUB_ATTRIBUTES = { + "integration_type": "aws_security_hub", + "enabled": True, + "connected": True, + "configuration": { + "send_only_fails": False, + "archive_previous_findings": True, + "regions": {"us-east-1": True, "eu-west-1": False}, + }, +} + +JIRA_ATTRIBUTES = { + "integration_type": "jira", + "enabled": True, + "connected": True, + "configuration": { + "domain": "acme", + "projects": {"PROJ": "Security"}, + "issue_types": {"PROJ": ["Task", "Bug"]}, + }, +} + + +def stub_integration( + mock_router: MockRouter, + attributes: dict, + *, + provider_ids: tuple[str, ...] = (), +) -> MockRouter: + """Serve ``GET /integrations/i1`` for every read a tool makes. + + A single registration is enough because the router repeats its last response, + and the tools read the integration both before and after a write. + """ + relationships = ( + {"providers": jsonapi_relationship_many("providers", *provider_ids)} + if provider_ids + else None + ) + return mock_router.add( + "GET", + INTEGRATION, + json=jsonapi_document( + jsonapi_resource("integrations", "i1", attributes, relationships) + ), + ) + + +def stub_connection_check( + mock_router: MockRouter, *, connected: bool = True, error: str | None = None +) -> MockRouter: + """Serve the check as Prowler runs it: a POST that returns a task to poll.""" + result: dict = {"connected": connected} + if error is not None: + result["error"] = error + + mock_router.add( + "POST", CONNECTION, json=jsonapi_document(jsonapi_resource("tasks", "t1", {})) + ) + return mock_router.add( + "GET", + TASK, + json=jsonapi_document( + jsonapi_resource("tasks", "t1", {"state": "completed", "result": result}) + ), + ) + + +# ------------------------------------------------------------------ read tools + + +async def test_listing_does_not_ask_for_the_configuration( + mcp_root_server, mock_api_client, mock_router +): + """The sparse fieldset is what keeps a list of integrations small. + + A Jira configuration carries every project and every issue type of the site, + which is the bulk of the payload and useless until an agent has picked one + integration to work with -- that is what prowler_get_integration is for. + """ + mock_router.add("GET", INTEGRATIONS, json=jsonapi_collection([])) + + async with Client(mcp_root_server) as client: + await client.call_tool("prowler_list_integrations", {}) + + params = mock_router.query_params("GET", INTEGRATIONS) + assert "configuration" not in params["fields[integrations]"] + assert params["page[size]"] == "50" + assert params["page[number]"] == "1" + + +async def test_listing_filters_by_type_with_a_comma_separated_value( + mcp_root_server, mock_api_client, mock_router +): + """Multi-value filters reach the API as CSV, not as repeated query keys.""" + mock_router.add( + "GET", + INTEGRATIONS, + json=jsonapi_collection( + [jsonapi_resource("integrations", "i1", S3_ATTRIBUTES)] + ), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_list_integrations", {"integration_type": ["amazon_s3", "jira"]} + ) + + params = mock_router.query_params("GET", INTEGRATIONS) + assert params["filter[integration_type__in]"] == "amazon_s3,jira" + assert result.data["integrations"][0]["integration_type"] == "amazon_s3" + + +async def test_getting_an_integration_returns_its_configuration( + mcp_root_server, mock_api_client, mock_router +): + """The configuration is the whole reason this tool exists next to the list.""" + stub_integration(mock_router, JIRA_ATTRIBUTES) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_get_integration", {"integration_id": "i1"} + ) + + assert result.data["configuration"]["projects"] == {"PROJ": "Security"} + + +@pytest.mark.parametrize( + ("document", "message"), + [ + ({"data": None}, "was not found"), + ({"data": {"type": "integrations", "id": "i1"}}, "without its attributes"), + ], + ids=["no-resource", "no-attributes"], +) +async def test_an_unusable_integration_payload_is_rejected_with_a_next_step( + mcp_root_server, mock_api_client, mock_router, document, message +): + """Both shapes arrive as a 200, so neither raises on its own. + + Left alone they surface as an opaque attribute error somewhere downstream + instead of telling the agent to go look the ID up. The two are reported + differently because a missing resource is the caller's mistake and a resource + without attributes is the API's. + """ + mock_router.add("GET", INTEGRATION, json=document) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match=message): + await client.call_tool("prowler_get_integration", {"integration_id": "i1"}) + + +# ---------------------------------------------------------------- create tools + + +async def test_creating_an_s3_integration_checks_the_connection_before_returning( + mcp_root_server, mock_api_client, mock_router +): + """Creation is a write, a connection check, a task poll and a re-read. + + The check is not optional: it is what proves the bucket policy lets Prowler + write, and skipping it would report a broken integration as ready. + """ + mock_router.add( + "POST", + INTEGRATIONS, + json=jsonapi_document(jsonapi_resource("integrations", "i1", S3_ATTRIBUTES)), + ) + stub_connection_check(mock_router) + stub_integration(mock_router, S3_ATTRIBUTES, provider_ids=("p1",)) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_create_amazon_s3_integration", + {"bucket_name": "my-reports", "provider_ids": ["p1"]}, + ) + + assert result.data["connected"] == "connected" + assert result.data["integration"]["id"] == "i1" + assert mock_router.paths() == [ + f"POST {INTEGRATIONS}", + f"POST {CONNECTION}", + f"GET {TASK}", + f"GET {INTEGRATION}", + ] + + +async def test_creating_an_s3_integration_sends_only_the_credentials_given( + mcp_root_server, mock_api_client, mock_router +): + """Omitted credentials must be absent, not present and null. + + An empty credentials object is a meaningful instruction -- use the ambient + AWS credentials of the deployment -- so sending nulls for the keys the caller + left out would be rejected instead of falling back. + """ + mock_router.add( + "POST", + INTEGRATIONS, + json=jsonapi_document(jsonapi_resource("integrations", "i1", S3_ATTRIBUTES)), + ) + stub_connection_check(mock_router) + stub_integration(mock_router, S3_ATTRIBUTES, provider_ids=("p1",)) + + async with Client(mcp_root_server) as client: + await client.call_tool( + "prowler_create_amazon_s3_integration", + { + "bucket_name": "my-reports", + "provider_ids": ["p1"], + "role_arn": "arn:aws:iam::123456789012:role/ProwlerS3Integration", + }, + ) + + data = mock_router.json_body("POST", INTEGRATIONS)["data"] + assert data["attributes"]["credentials"] == { + "role_arn": "arn:aws:iam::123456789012:role/ProwlerS3Integration", + "session_duration": 3600, + } + assert data["attributes"]["configuration"] == { + "bucket_name": "my-reports", + "output_directory": "output", + } + assert data["relationships"]["providers"]["data"] == [ + {"type": "providers", "id": "p1"} + ] + + +async def test_creating_a_jira_integration_reduces_a_site_url_to_its_name( + mcp_root_server, mock_api_client, mock_router +): + """The API only accepts the bare site name, and a URL is what people paste. + + It also rejects any configuration in the payload, since it generates it from + the connection check. + """ + mock_router.add( + "POST", + INTEGRATIONS, + json=jsonapi_document(jsonapi_resource("integrations", "i1", JIRA_ATTRIBUTES)), + ) + stub_connection_check(mock_router) + stub_integration(mock_router, JIRA_ATTRIBUTES) + + async with Client(mcp_root_server) as client: + await client.call_tool( + "prowler_create_jira_integration", + { + "domain": "https://acme.atlassian.net/jira/software", + "user_mail": "security@acme.com", + "api_token": "fake-atlassian-token-for-testing", + }, + ) + + attributes = mock_router.json_body("POST", INTEGRATIONS)["data"]["attributes"] + assert attributes["credentials"]["domain"] == "acme" + assert attributes["configuration"] == {} + + +async def test_creating_a_jira_integration_rejects_an_empty_domain( + mcp_root_server, mock_api_client, mock_router +): + """A domain that normalizes to nothing is caught before the round trip.""" + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_create_jira_integration", + { + "domain": "https://", + "user_mail": "security@acme.com", + "api_token": "fake-atlassian-token-for-testing", + }, + ) + + assert result.data["status"] == "failed" + assert "Invalid Jira domain" in result.data["error"] + assert mock_router.requests == [] + + +@pytest.mark.parametrize( + ("tool", "arguments"), + [ + ("prowler_create_aws_security_hub_integration", {"provider_id": "p1"}), + ("prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"}), + ], + ids=["security-hub", "amazon-s3"], +) +async def test_a_rejected_creation_is_reported_rather_than_raised( + mcp_root_server, mock_api_client, mock_router, tool, arguments +): + """Write tools answer with an error object so the agent can act on it. + + A raised exception reaches the model as a tool failure with no detail, and + the API's message is exactly what tells it what to do next. + """ + mock_router.add( + "POST", + INTEGRATIONS, + status=409, + json=jsonapi_error(409, "This provider already has this integration."), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool(tool, arguments) + + assert result.data["status"] == "failed" + assert "already has this integration" in result.data["error"] + + +async def test_a_creation_with_no_id_back_warns_before_a_blind_retry( + mcp_root_server, mock_api_client, mock_router +): + """The integration may well exist, so retrying could create a second one. + + Without the ID there is nothing to check its connection with either, which + makes "look it up before trying again" the only safe instruction. + """ + mock_router.add("POST", INTEGRATIONS, json={"data": {}}) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"} + ) + + assert result.data["status"] == "failed" + assert "did not return its ID" in result.data["error"] + assert mock_router.paths() == [f"POST {INTEGRATIONS}"] + + +async def test_a_creation_whose_read_back_fails_still_hands_over_the_id( + mcp_root_server, mock_api_client, mock_router +): + """The integration was created; only reading it back went wrong. + + Reporting the read failure alone would read as "creation failed" and invite a + duplicate, so the error carries the ID the agent needs to go and inspect it. + """ + mock_router.add( + "POST", + INTEGRATIONS, + json=jsonapi_document(jsonapi_resource("integrations", "i1", S3_ATTRIBUTES)), + ) + stub_connection_check(mock_router) + mock_router.add( + "GET", INTEGRATION, status=500, json=jsonapi_error(500, "Server error.") + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"} + ) + + assert result.data["status"] == "failed" + assert "Integration i1 was created" in result.data["error"] + + +async def test_a_connection_check_that_cannot_run_is_not_reported_as_a_failure( + mcp_root_server, mock_api_client, mock_router +): + """`not_tested` says nothing about the credentials, and that is the point. + + Reporting it as `failed` would send an agent rewriting credentials that were + never actually exercised. + """ + mock_router.add( + "POST", + INTEGRATIONS, + json=jsonapi_document(jsonapi_resource("integrations", "i1", S3_ATTRIBUTES)), + ) + # Accepted, but without the task ID there is nothing to poll + mock_router.add("POST", CONNECTION, json={"data": {}}) + stub_integration(mock_router, S3_ATTRIBUTES) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"} + ) + + assert result.data["connected"] == "not_tested" + assert "could not be completed" in result.data["error"] + + +# ---------------------------------------------------------------- update tool + + +async def test_updating_only_the_enabled_flag_does_not_recheck_the_connection( + mcp_root_server, mock_api_client, mock_router +): + """Nothing about reachability changed, so the check would be pure latency. + + It is also destructive to spend: the check is a background task the tool + waits on for up to two minutes. + """ + stub_integration(mock_router, S3_ATTRIBUTES) + mock_router.add("PATCH", INTEGRATION, json=jsonapi_document({})) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_update_integration", {"integration_id": "i1", "enabled": False} + ) + + assert mock_router.json_body("PATCH", INTEGRATION)["data"]["attributes"] == { + "enabled": False + } + assert f"POST {CONNECTION}" not in mock_router.paths() + assert result.data["id"] == "i1" + + +async def test_updating_the_configuration_merges_it_onto_the_current_one( + mcp_root_server, mock_api_client, mock_router +): + """The API replaces the configuration wholesale, so a partial one loses keys. + + The server-owned regions are stripped back out: they are refreshed by the + connection check, and sending them back would fight the API for ownership. + """ + stub_integration(mock_router, SECURITY_HUB_ATTRIBUTES, provider_ids=("p1",)) + mock_router.add("PATCH", INTEGRATION, json=jsonapi_document({})) + stub_connection_check(mock_router) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "configuration": {"send_only_fails": True}}, + ) + + assert mock_router.json_body("PATCH", INTEGRATION)["data"]["attributes"][ + "configuration" + ] == {"send_only_fails": True, "archive_previous_findings": True} + assert result.data["connected"] == "connected" + + +async def test_a_configuration_sent_as_a_json_string_is_accepted( + mcp_root_server, mock_api_client, mock_router +): + """Some MCP clients cannot pass an object and send its JSON text instead. + + Rejecting those outright would make the tool unusable from those clients, + which is why the parameter is typed to accept both. + """ + stub_integration(mock_router, S3_ATTRIBUTES) + mock_router.add("PATCH", INTEGRATION, json=jsonapi_document({})) + stub_connection_check(mock_router) + + async with Client(mcp_root_server) as client: + await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "configuration": '{"bucket_name": "new-reports"}'}, + ) + + assert ( + mock_router.json_body("PATCH", INTEGRATION)["data"]["attributes"][ + "configuration" + ]["bucket_name"] + == "new-reports" + ) + + +@pytest.mark.parametrize( + ("configuration", "message"), + [ + ("bucket_name=new", "Invalid JSON for configuration"), + ('["bucket_name"]', "configuration must be a JSON object"), + ], + ids=["not-json", "json-but-not-an-object"], +) +async def test_a_configuration_that_is_not_an_object_is_rejected_before_the_write( + mcp_root_server, mock_api_client, mock_router, configuration, message +): + """Half-parsed text must not reach the API as a replacement configuration. + + Valid JSON is not enough: the configuration is merged key by key, so a list + or a bare scalar would fail somewhere less obvious than here. + """ + stub_integration(mock_router, S3_ATTRIBUTES) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "configuration": configuration}, + ) + + assert result.data["status"] == "failed" + assert message in result.data["error"] + assert f"PATCH {INTEGRATION}" not in mock_router.paths() + + +async def test_clearing_aws_credentials_is_allowed_and_means_something( + mcp_root_server, mock_api_client, mock_router +): + """An empty object is a valid instruction for the AWS integration types. + + It falls back to the ambient credentials of the deployment, which is why the + Jira guard against an empty object must not apply here. + """ + stub_integration(mock_router, S3_ATTRIBUTES) + mock_router.add("PATCH", INTEGRATION, json=jsonapi_document({})) + stub_connection_check(mock_router) + + async with Client(mcp_root_server) as client: + await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "credentials": {}}, + ) + + assert ( + mock_router.json_body("PATCH", INTEGRATION)["data"]["attributes"]["credentials"] + == {} + ) + + +async def test_reordering_the_same_providers_does_not_recheck_the_connection( + mcp_root_server, mock_api_client, mock_router +): + """The providers decide the effective credentials, so a real change matters. + + Comparing them as sets keeps a re-sent list from paying for a two-minute + connection check that can only confirm what is already known. + """ + stub_integration(mock_router, S3_ATTRIBUTES, provider_ids=("p1", "p2")) + mock_router.add("PATCH", INTEGRATION, json=jsonapi_document({})) + + async with Client(mcp_root_server) as client: + await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "provider_ids": ["p2", "p1"]}, + ) + + assert f"POST {CONNECTION}" not in mock_router.paths() + + +async def test_attaching_a_different_provider_rechecks_the_connection( + mcp_root_server, mock_api_client, mock_router +): + """A different provider means different credentials and a stale check result.""" + stub_integration(mock_router, S3_ATTRIBUTES, provider_ids=("p1",)) + mock_router.add("PATCH", INTEGRATION, json=jsonapi_document({})) + stub_connection_check(mock_router, connected=False, error="Access denied.") + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "provider_ids": ["p2"]}, + ) + + assert mock_router.json_body("PATCH", INTEGRATION)["data"]["relationships"] == { + "providers": {"data": [{"type": "providers", "id": "p2"}]} + } + assert result.data["connected"] == "failed" + assert result.data["error"] == "Access denied." + + +async def test_an_update_with_nothing_to_change_returns_the_current_state( + mcp_root_server, mock_api_client, mock_router +): + """An empty PATCH would still cost a write and a connection check.""" + stub_integration(mock_router, S3_ATTRIBUTES) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_update_integration", {"integration_id": "i1"} + ) + + assert result.data["id"] == "i1" + assert mock_router.paths() == [f"GET {INTEGRATION}"] + + +async def test_updating_a_jira_configuration_is_refused( + mcp_root_server, mock_api_client, mock_router +): + """Prowler generates the Jira configuration from the connection check. + + Sending one would overwrite the discovered projects and issue types, leaving + an integration that looks fine but can no longer dispatch a finding. + """ + stub_integration(mock_router, JIRA_ATTRIBUTES) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "configuration": {"domain": "other"}}, + ) + + assert result.data["status"] == "failed" + assert "do not accept a configuration" in result.data["error"] + assert f"PATCH {INTEGRATION}" not in mock_router.paths() + + +async def test_attaching_a_jira_integration_to_a_provider_is_refused( + mcp_root_server, mock_api_client, mock_router +): + """Jira is tenant-wide; the API would reject this after a wasted round trip.""" + stub_integration(mock_router, JIRA_ATTRIBUTES) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "provider_ids": ["p1"]}, + ) + + assert "tenant-wide" in result.data["error"] + assert f"PATCH {INTEGRATION}" not in mock_router.paths() + + +@pytest.mark.parametrize( + "provider_ids", [[], ["p1", "p2"]], ids=["detach-all", "two-providers"] +) +async def test_security_hub_must_keep_exactly_one_provider( + mcp_root_server, mock_api_client, mock_router, provider_ids +): + """The integration cannot exist without its provider. + + Detaching it through an update leaves the API to decide what that means; the + supported way to stop sending findings is to delete the integration. + """ + stub_integration(mock_router, SECURITY_HUB_ATTRIBUTES, provider_ids=("p1",)) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "provider_ids": provider_ids}, + ) + + assert "exactly one AWS provider" in result.data["error"] + assert f"PATCH {INTEGRATION}" not in mock_router.paths() + + +@pytest.mark.parametrize( + "credentials", + [{}, {"domain": "acme"}, {"domain": "acme", "user_mail": "", "api_token": "t"}], + ids=["empty", "partial", "blank-value"], +) +async def test_partial_jira_credentials_are_refused_to_protect_the_stored_ones( + mcp_root_server, mock_api_client, mock_router, credentials +): + """The API replaces the credentials object as a whole. + + So a partial update does not patch the secret, it destroys it -- and the + integration cannot be repaired without the original API token. + """ + stub_integration(mock_router, JIRA_ATTRIBUTES) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "credentials": credentials}, + ) + + assert "replaced as a whole" in result.data["error"] + assert f"PATCH {INTEGRATION}" not in mock_router.paths() + + +async def test_replacing_jira_credentials_normalizes_the_domain( + mcp_root_server, mock_api_client, mock_router +): + """The same URL-to-site-name reduction the creation tool applies. + + Without it a credentials replacement would store a domain the API cannot use, + breaking an integration that was working. + """ + stub_integration(mock_router, JIRA_ATTRIBUTES) + mock_router.add("PATCH", INTEGRATION, json=jsonapi_document({})) + stub_connection_check(mock_router) + + async with Client(mcp_root_server) as client: + await client.call_tool( + "prowler_update_integration", + { + "integration_id": "i1", + "credentials": { + "domain": "https://acme.atlassian.net", + "user_mail": "security@acme.com", + "api_token": "fake-atlassian-token-for-testing", + }, + }, + ) + + credentials = mock_router.json_body("PATCH", INTEGRATION)["data"]["attributes"][ + "credentials" + ] + assert credentials["domain"] == "acme" + + +# ------------------------------------------------- delete and connection tools + + +async def test_deleting_an_integration_reports_the_outcome_either_way( + mcp_root_server, mock_api_client, mock_router +): + """Deletion is irreversible, so both outcomes are stated explicitly. + + A bare exception would leave the agent unsure whether the credentials are + gone, and a retry of a delete that actually succeeded reads as a new failure. + """ + mock_router.add("DELETE", INTEGRATION, status=204) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_delete_integration", {"integration_id": "i1"} + ) + + assert result.data["deleted"] is True + + +async def test_a_failed_deletion_says_it_did_not_happen( + mcp_root_server, mock_api_client, mock_router +): + """`deleted: false` is the part the agent must not have to infer.""" + mock_router.add( + "DELETE", INTEGRATION, status=403, json=jsonapi_error(403, "Permission denied.") + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_delete_integration", {"integration_id": "i1"} + ) + + assert result.data["deleted"] is False + assert "Permission denied." in result.data["message"] + + +async def test_checking_a_connection_surfaces_why_it_failed( + mcp_root_server, mock_api_client, mock_router +): + """The error is the actionable half of a failed check.""" + stub_connection_check( + mock_router, connected=False, error="Integration is not enabled" + ) + stub_integration( + mock_router, {**S3_ATTRIBUTES, "enabled": False, "connected": False} + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_test_integration_connection", {"integration_id": "i1"} + ) + + assert result.data["connected"] == "failed" + assert result.data["error"] == "Integration is not enabled" + # The re-read is what makes the refreshed configuration part of the answer + assert mock_router.paths() == [ + f"POST {CONNECTION}", + f"GET {TASK}", + f"GET {INTEGRATION}", + ] + + +# ------------------------------------------------------------------ jira tools + + +async def test_issue_types_are_requested_for_a_specific_project( + mcp_root_server, mock_api_client, mock_router +): + """Issue types differ per project, so the key has to reach the API.""" + mock_router.add( + "GET", + ISSUE_TYPES, + json={"data": {"project_key": "PROJ", "issue_types": ["Task", "Bug"]}}, + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_get_jira_issue_types", + {"integration_id": "i1", "project_key": "PROJ"}, + ) + + assert result.data["issue_types"] == ["Task", "Bug"] + assert mock_router.query_params("GET", ISSUE_TYPES)["project_key"] == "PROJ" + + +async def test_dispatching_findings_sends_them_as_a_filter_not_a_body_field( + mcp_root_server, mock_api_client, mock_router +): + """The findings are selected by query filter; the body carries the target. + + Putting the IDs in the wrong half of the request is not an error the API + reports -- it dispatches a different, unfiltered set of findings. + """ + mock_router.add( + "POST", DISPATCHES, json=jsonapi_document(jsonapi_resource("tasks", "t1", {})) + ) + mock_router.add( + "GET", + TASK, + json=jsonapi_document( + jsonapi_resource( + "tasks", + "t1", + { + "state": "completed", + "result": {"created_count": 2, "failed_count": 0}, + }, + ) + ), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_send_findings_to_jira", + { + "integration_id": "i1", + "project_key": "PROJ", + "issue_type": "Task", + "finding_ids": ["f1", "f2"], + }, + ) + + assert mock_router.query_params("POST", DISPATCHES)["filter[finding_id__in]"] == ( + "f1,f2" + ) + assert mock_router.json_body("POST", DISPATCHES)["data"]["attributes"] == { + "project_key": "PROJ", + "issue_type": "Task", + } + assert result.data["created_count"] == 2 + assert result.data["safe_to_retry"] is False + + +async def test_dispatching_no_findings_is_refused_before_the_request( + mcp_root_server, mock_api_client, mock_router +): + """An empty filter would dispatch every finding the role can see.""" + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_send_findings_to_jira", + { + "integration_id": "i1", + "project_key": "PROJ", + "issue_type": "Task", + "finding_ids": [], + }, + ) + + assert result.data["status"] == "failed" + assert mock_router.requests == [] + + +async def test_a_dispatch_that_never_started_is_the_only_one_safe_to_retry( + mcp_root_server, mock_api_client, mock_router +): + """A rejected POST created nothing, so the agent can safely fix and resend.""" + mock_router.add( + "POST", + DISPATCHES, + status=400, + json=jsonapi_error( + 400, "Issue type 'Epic' requires fields Prowler cannot fill." + ), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_send_findings_to_jira", + { + "integration_id": "i1", + "project_key": "PROJ", + "issue_type": "Epic", + "finding_ids": ["f1"], + }, + ) + + assert result.data["status"] == "failed" + assert "requires fields Prowler cannot fill" in result.data["error"] + + +async def test_an_accepted_dispatch_with_no_task_id_is_not_safe_to_retry( + mcp_root_server, mock_api_client, mock_router +): + """Prowler took the dispatch, so it is already creating work items. + + There is just no task to follow it with. Reporting that as a clean failure + would invite a resend on top of whatever it created. + """ + mock_router.add("POST", DISPATCHES, json={"data": {}}) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_send_findings_to_jira", + { + "integration_id": "i1", + "project_key": "PROJ", + "issue_type": "Task", + "finding_ids": ["f1"], + }, + ) + + assert result.data["status"] == "unknown" + assert result.data["safe_to_retry"] is False + assert "task_id" not in result.data + + +async def test_a_dispatch_task_that_died_halfway_is_never_safe_to_retry( + mcp_root_server, mock_api_client, mock_router +): + """Work items are created one at a time and Prowler cannot delete them. + + A task that failed may have created any number of them first, so the honest + answer is `unknown` plus a pointer at Jira -- never an invitation to resend. + """ + mock_router.add( + "POST", DISPATCHES, json=jsonapi_document(jsonapi_resource("tasks", "t1", {})) + ) + mock_router.add( + "GET", + TASK, + json=jsonapi_document( + jsonapi_resource("tasks", "t1", {"state": "failed", "error": "Jira 503"}) + ), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_send_findings_to_jira", + { + "integration_id": "i1", + "project_key": "PROJ", + "issue_type": "Task", + "finding_ids": ["f1"], + }, + ) + + assert result.data["status"] == "unknown" + assert result.data["safe_to_retry"] is False + assert result.data["task_id"] == "t1" + + +async def test_a_dispatch_still_running_when_the_wait_ends_reports_in_progress( + mcp_root_server, mock_api_client, mock_router, monkeypatch +): + """Giving up waiting is not the same as the dispatch stopping. + + It is still creating work items right now, so the response has to say so and + hand back the task ID rather than let the agent conclude nothing happened. + """ + from prowler_mcp_server.prowler_app.tools import integrations + + monkeypatch.setattr(integrations, "JIRA_DISPATCH_TIMEOUT", 0) + mock_router.add( + "POST", DISPATCHES, json=jsonapi_document(jsonapi_resource("tasks", "t1", {})) + ) + mock_router.add( + "GET", + TASK, + json=jsonapi_document(jsonapi_resource("tasks", "t1", {"state": "executing"})), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_send_findings_to_jira", + { + "integration_id": "i1", + "project_key": "PROJ", + "issue_type": "Task", + "finding_ids": ["f1"], + }, + ) + + assert result.data["status"] == "in_progress" + assert result.data["safe_to_retry"] is False + assert result.data["task_id"] == "t1" + + +async def test_a_completed_dispatch_with_no_counters_is_reported_as_unknown( + mcp_root_server, mock_api_client, mock_router +): + """Absent counters must not be read as zero. + + Zero created work items is precisely what makes a dispatch safe to retry, so + defaulting them would invite the duplication this whole path exists to avoid. + """ + mock_router.add( + "POST", DISPATCHES, json=jsonapi_document(jsonapi_resource("tasks", "t1", {})) + ) + mock_router.add( + "GET", + TASK, + json=jsonapi_document( + jsonapi_resource("tasks", "t1", {"state": "completed", "result": None}) + ), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_send_findings_to_jira", + { + "integration_id": "i1", + "project_key": "PROJ", + "issue_type": "Task", + "finding_ids": ["f1"], + }, + ) + + assert result.data["status"] == "unknown" + assert result.data["safe_to_retry"] is False