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
+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."""