test(mcp): cover the integrations tools and models (#12343)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rubén De la Torre Vico
2026-08-05 16:47:11 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent f3c602a5ac
commit 97c342ad80
10 changed files with 1580 additions and 77 deletions
@@ -0,0 +1 @@
Test coverage for the integrations tools and models, pinning the connection-check choreography and the Jira dispatch retry safety
@@ -0,0 +1 @@
`prowler_send_findings_to_jira` now reports `safe_to_retry` on every outcome, true only when Prowler knows no Jira work item was created: a dispatch the API refused is retryable, one that failed on the server or got no answer is not
@@ -0,0 +1 @@
`prowler_list_integrations` no longer requests the `configuration` it discards, now that the API tolerates a sparse fieldset without it
@@ -261,20 +261,20 @@ class JiraDispatchResult(MinimalSerializerMixin, BaseModel):
model_config = ConfigDict(frozen=True)
status: Literal["completed", "in_progress", "unknown"] = Field(
description="Outcome of the dispatch: 'completed' when Prowler finished creating the work items, 'in_progress' when the background task is still running, 'unknown' when the task stopped before reporting a result and Prowler cannot tell how many work items it had already created"
status: Literal["completed", "in_progress", "unknown", "failed"] = Field(
description="Outcome of the dispatch: 'completed' when Prowler finished creating the work items, 'in_progress' when the background task is still running, 'failed' when the dispatch was rejected before it started so nothing was created, 'unknown' when the dispatch stopped before reporting a result and Prowler cannot tell how many work items it had already created"
)
safe_to_retry: bool = Field(
description="True only when Prowler is certain that no Jira work item was created. When False the dispatch must NOT be sent again: some work items may already exist and retrying would duplicate them. Report the outcome to the user and let them check Jira instead"
)
created_count: int | None = Field(
default=None,
description="Number of Jira work items successfully created, absent when the outcome is unknown",
description="Number of Jira work items successfully created, absent unless the dispatch completed",
ge=0,
)
failed_count: int | None = Field(
default=None,
description="Number of findings that could not be sent to Jira, absent when the outcome is unknown",
description="Number of findings that could not be sent to Jira, absent unless the dispatch completed",
ge=0,
)
error: str | None = Field(
@@ -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")
@@ -17,15 +17,15 @@ from prowler_mcp_server.prowler_app.models.integrations import (
IntegrationsListResponse,
JiraDispatchResult,
JiraIssueTypes,
SimplifiedIntegration,
)
from prowler_mcp_server.prowler_app.tools.base import BaseTool
from prowler_mcp_server.prowler_app.utils.api_client import ProwlerAPIError
# The configuration is deliberately left out of the list view, it belongs to the
# 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 +36,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 +495,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 +528,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 +547,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}")
@@ -706,13 +725,16 @@ class IntegrationsTools(BaseTool):
The result includes:
- status: 'completed' when Prowler finished the dispatch, 'in_progress' when the task
is still running, 'unknown' when the task stopped without reporting a result
is still running, 'failed' when the dispatch was rejected before it started,
'unknown' when the task stopped without reporting a result
- safe_to_retry: whether the dispatch can be sent again. It is only true when no work
item was created. NEVER call this tool again for the same findings when it is false,
the work items already created would be duplicated. Report the outcome to the user
and let them check Jira instead
- created_count: number of work items created in Jira, absent when status='unknown'
- failed_count: number of findings that could not be sent, absent when status='unknown'
item was created, which is the case when the dispatch was rejected before it
started. NEVER call this tool again for the same findings when it is false, the
work items already created would be duplicated. Report the outcome to the user and
let them check Jira instead
- created_count: number of work items created in Jira, absent unless status='completed'
- failed_count: number of findings that could not be sent, absent unless
status='completed'
Workflow:
1. Use prowler_search_security_findings to select the findings to escalate
@@ -748,10 +770,36 @@ class IntegrationsTools(BaseTool):
params=params,
json_data=dispatch_body,
)
except ValueError as e:
# Refused here, so the request never went out
self.logger.error(f"Jira dispatch was refused before the request: {e}")
return self._jira_dispatch_rejected(str(e))
except ProwlerAPIError as e:
# Only a client error is a refusal: the API validates the dispatch and
# then queues the background task before serializing its answer, so a
# server error may well come back with work items already being created
if e.status_code >= 500:
self.logger.error(f"Jira dispatch failed on the server: {e}")
return self._jira_dispatch_unknown(
task_id=None,
error=(
f"the request that starts the dispatch failed on the server: {e} "
"It may have been queued anyway."
),
)
self.logger.error(f"Jira dispatch was rejected by Prowler: {e}")
return self._jira_dispatch_rejected(str(e))
except Exception as e:
# Nothing was dispatched yet, so this failure is safe to act on
# No answer came back, so the request may still have been accepted
self.logger.error(f"Jira dispatch could not be started: {e}")
return {"error": str(e), "status": "failed"}
return self._jira_dispatch_unknown(
task_id=None,
error=(
f"the request that starts the dispatch got no answer: {e} "
"It may have been accepted anyway."
),
)
task_id = task_response.get("data", {}).get("id")
if not task_id:
@@ -769,14 +817,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 +872,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 +962,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
@@ -1047,6 +1068,17 @@ class IntegrationsTools(BaseTool):
task_id=task_id,
).model_dump()
def _jira_dispatch_rejected(self, error: str) -> dict[str, Any]:
"""Report a dispatch that was refused before any work item could be created.
This is the only outcome safe to retry, and it is reserved for the failures
that prove nothing was queued: a validation error raised here, or a client
error from the API, which rejects the dispatch before starting its task.
"""
return JiraDispatchResult(
status="failed", safe_to_retry=True, error=error
).model_dump()
def _jira_dispatch_unknown(self, task_id: str | None, error: str) -> dict[str, Any]:
"""Report a dispatch whose outcome Prowler cannot determine.
@@ -15,6 +15,20 @@ from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth
ALLOWED_EXTERNAL_DOMAINS: frozenset[str] = frozenset({"raw.githubusercontent.com"})
class ProwlerAPIError(Exception):
"""An error response returned by the Prowler API.
Raised only when the API answered with an error status, which tells a caller
something no plain exception can: the request reached Prowler and was
rejected, so it changed nothing. A timeout or a dropped connection stays a
bare exception because the request may well have been processed.
"""
def __init__(self, message: str, status_code: int) -> None:
super().__init__(message)
self.status_code: int = status_code
class HTTPMethod(StrEnum):
"""HTTP methods enum."""
@@ -73,7 +87,8 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
API response as dictionary
Raises:
Exception: If API request fails
ProwlerAPIError: If the API answered with an error status
Exception: If the request could not be completed
"""
try:
token: str = await self.auth_manager.get_valid_token()
@@ -105,8 +120,9 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
except Exception:
error_detail = e.response.text
raise Exception(
f"API request failed: {e.response.status_code} - {error_detail}"
raise ProwlerAPIError(
f"API request failed: {e.response.status_code} - {error_detail}",
e.response.status_code,
)
except Exception as e:
logger.error(f"Error during {method.value} {path}: {e}")
+10
View File
@@ -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]
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,10 @@ Reference for later branches: drive the client through ``mock_api_client`` +
query encoding and header assembly stay covered.
"""
import httpx
import pytest
from prowler_mcp_server.prowler_app.utils.api_client import ProwlerAPIError
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_error, jsonapi_resource
from tests.helpers.tokens import FAKE_API_KEY
@@ -56,9 +58,32 @@ async def test_error_response_surfaces_the_jsonapi_detail(mock_api_client, mock_
json=jsonapi_error(404, "Not found."),
)
with pytest.raises(Exception, match=r"API request failed: 404 - Not found\."):
with pytest.raises(
ProwlerAPIError, match=r"API request failed: 404 - Not found\."
) as raised:
await mock_api_client.get("/findings/nope")
assert raised.value.status_code == 404
async def test_a_request_that_got_no_answer_is_not_an_api_error(
mock_api_client, mock_router
):
"""`ProwlerAPIError` means the API answered, and callers act on that.
A write tool tells a rejected request -- which changed nothing -- from one
that may have been processed by the type of the failure, so a timeout must
not be dressed up as a rejection.
"""
def timed_out(request):
raise httpx.ReadTimeout("Timed out reading the response", request=request)
mock_router.add_handler("GET", "/api/v1/findings", timed_out)
with pytest.raises(httpx.ReadTimeout):
await mock_api_client.get("/findings")
def test_build_filter_params_normalises_types_for_the_api(mock_api_client):
"""Booleans become lowercase strings, sequences become CSV, `None` is dropped."""