mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-11 22:14:17 +00:00
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
281 lines
9.5 KiB
Python
281 lines
9.5 KiB
Python
"""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)
|