diff --git a/Packs/GCP/Integrations/GCP/GCP.py b/Packs/GCP/Integrations/GCP/GCP.py index d3a9e9001ee..0a30a2c99b4 100644 --- a/Packs/GCP/Integrations/GCP/GCP.py +++ b/Packs/GCP/Integrations/GCP/GCP.py @@ -12,6 +12,13 @@ urllib3.disable_warnings() +# Placeholder resource used by the Cloud Functions connectivity probe. That API exposes +# testIamPermissions only at function scope, and returns an empty permission set (rather than +# NOT_FOUND) for a resource that does not exist, so no real function has to be present. +# A concrete location is used rather than the "-" wildcard, which is only accepted by list methods. +CLOUD_FUNCTIONS_PROBE_LOCATION = "us-central1" +CLOUD_FUNCTIONS_PROBE_FUNCTION = "connectivity-probe" + def build_http_client(use_proxy: bool, verify_ssl: bool) -> httplib2.Http: """Builds an httplib2.Http honoring the given proxy and SSL settings. @@ -72,6 +79,7 @@ class GCPServices(Enum): CONTAINER = ("container", "v1", "container.googleapis.com") RESOURCE_MANAGER = ("cloudresourcemanager", "v3", "cloudresourcemanager.googleapis.com") BIGQUERY = ("bigquery", "v2", "bigquery.googleapis.com") + CLOUD_FUNCTIONS = ("cloudfunctions", "v2", "cloudfunctions.googleapis.com") # The following services are currently unsupported: # IAM_V1 = ("iam", "v1", "iam.googleapis.com") @@ -138,11 +146,11 @@ def build(self, credentials, **kwargs): def test_connectivity(self, credentials, project_id: str) -> None: """Issues a lightweight, project-scoped API call to verify connectivity to this service. - Resource Manager uses ``testIamPermissions``, which succeeds for any authenticated - caller regardless of the roles granted (it returns the subset of granted permissions), - making it a permission-agnostic probe. The other services do not expose a project-level - ``testIamPermissions``, so a minimal ``list`` call is used instead. Each service exposes - the call on a different resource, so the correct shape is selected per service. + Resource Manager and Cloud Functions use ``testIamPermissions``, which succeeds for any + authenticated caller regardless of the roles granted (it returns the subset of granted + permissions), making it a permission-agnostic probe. The remaining services do not expose + ``testIamPermissions`` at all, so a minimal ``list`` call is used instead. Each service + exposes the call on a different resource, so the correct shape is selected per service. The call is allowed to raise so callers can inspect the error (e.g. distinguish a disabled-API 403 from a real failure). Use ``test_all_services`` for a non-raising, @@ -175,6 +183,18 @@ def test_connectivity(self, credentials, project_id: str) -> None: elif self == GCPServices.BIGQUERY: # BigQuery has no project-level testIamPermissions; a lightweight dataset list verifies connectivity. client.datasets().list(projectId=project_id, maxResults=1).execute() # pylint: disable=E1101 + elif self == GCPServices.CLOUD_FUNCTIONS: + # Cloud Functions exposes testIamPermissions only at function scope (the API requires a + # resource matching projects/*/locations/*/functions/*), so a placeholder function is used. + # Per the API contract a non-existent resource returns an empty permission set rather than + # NOT_FOUND, which keeps this probe permission-agnostic like the Resource Manager one. + client.projects().locations().functions().testIamPermissions( # pylint: disable=E1101 + resource=( + f"projects/{project_id}/locations/{CLOUD_FUNCTIONS_PROBE_LOCATION}" + f"/functions/{CLOUD_FUNCTIONS_PROBE_FUNCTION}" + ), + body={"permissions": ["cloudfunctions.functions.get"]}, + ).execute() else: raise NotImplementedError(f"No connectivity probe defined for service {self.api_name}") @@ -328,6 +348,19 @@ def test_all_services(cls, credentials, project_id: str) -> list[tuple[str, bool GCPServices.RESOURCE_MANAGER, ["resourcemanager.projects.getIamPolicy", "resourcemanager.projects.setIamPolicy"], ), + # Cloud Run functions commands + "gcp-cloudrun-functions-list": ( + GCPServices.CLOUD_FUNCTIONS, + ["cloudfunctions.functions.list"], + ), + "gcp-cloudrun-locations-list": ( + GCPServices.CLOUD_FUNCTIONS, + ["cloudfunctions.locations.list"], + ), + "gcp-cloudrun-function-get": ( + GCPServices.CLOUD_FUNCTIONS, + ["cloudfunctions.functions.get"], + ), # The following commands are currently unsupported: # "gcp-compute-instance-metadata-add": ( # GCPServices.COMPUTE, @@ -2402,12 +2435,17 @@ def validate_limit(limit): """ Validates that the provided limit argument is within the allowed range. + A ``None`` limit means the argument was omitted, in which case the API applies its own + default page size, so there is nothing to validate. + Args: - limit (int): The limit value to validate. + limit (int | None): The limit value to validate, or None when not provided. Raises: - DemistoException: If the limit is not set or is outside the allowed range (1-500 inclusive). + DemistoException: If the limit is outside the allowed range (1-500 inclusive). """ + if limit is None: + return if limit > 500 or limit < 1: raise DemistoException( f"The acceptable values of the argument limit are 1 to 500, inclusive. Currently the value is {limit}" @@ -2522,6 +2560,7 @@ def test_module(creds: Credentials, params: dict[str, Any]) -> str: GCPServices.STORAGE, GCPServices.CONTAINER, GCPServices.BIGQUERY, + GCPServices.CLOUD_FUNCTIONS, ] for service in services_to_try: @@ -2944,6 +2983,163 @@ def gcp_compute_networks_list(creds: Credentials, args: dict[str, Any]) -> Comma ) +def cloud_run_function_list(creds: Credentials, args: dict[str, Any]) -> CommandResults: + """ + Lists Google Cloud Run functions in the specified project and region. + + Args: + creds (Credentials): Authorized GCP credentials used to access the Cloud Functions API. + args (dict): Command arguments including: + - project_id (str): The GCP project ID. + - region (str, optional): The region of the functions. Defaults to all regions ("-"). + - limit (int, optional): Maximum number of results to return (1-500). + - next_token (str, optional): Token to retrieve the next page of results. + - filter (str, optional): Expression for filtering the listed functions. + - order_by (str, optional): The sort order of the returned functions. + + Returns: + CommandResults: Object containing the list of Cloud Functions under `GCP.CloudRun.Functions`, + with the continuation token under `GCP.CloudRun.FunctionsNextToken`. + """ + project_id = args.get("project_id") + # "-" is the API's wildcard for "every location". + region = args.get("region") or "-" + limit = arg_to_number(args.get("limit")) + next_token = args.get("next_token") + validate_limit(limit) + + params: dict[str, Any] = { + "parent": f"projects/{project_id}/locations/{region}", + "pageSize": limit, + "pageToken": next_token, + "filter": args.get("filter"), + "orderBy": args.get("order_by"), + } + remove_nulls_from_dictionary(params) + demisto.debug(f"[GCP: cloud_run_function_list] Listing functions with params: {params}") + + service = GCPServices.CLOUD_FUNCTIONS.build(creds) + response = service.projects().locations().functions().list(**params).execute() # pylint: disable=E1101 + functions = response.get("functions", []) + if not functions: + return CommandResults(readable_output="No functions found.", raw_response=response) + + next_page_token = response.get("nextPageToken") + display_region = "All" if region == "-" else region + + # When listing across all locations, the API reports any locations it could not reach. + if unreachable := response.get("unreachable"): + demisto.debug(f"[GCP: cloud_run_function_list] Unreachable locations: {unreachable}") + + headers = ["name", "state", "environment", "updateTime", "url", "labels"] + readable_output = tableToMarkdown( + f'GCP Cloud Functions in project "{project_id}" and region "{display_region}"', + functions, + headers=headers, + headerTransform=pascalToSpace, + removeNull=True, + ) + outputs = { + "GCP.CloudRun.Functions(val.name && val.name == obj.name)": functions, + "GCP.CloudRun(true)": {"FunctionsNextToken": next_page_token}, + } + return CommandResults( + readable_output=readable_output, + outputs=outputs, + raw_response=response, + ) + + +def cloud_run_location_list(creds: Credentials, args: dict[str, Any]) -> CommandResults: + """ + Lists all locations (regions) available for Google Cloud Run functions in the project. + + Args: + creds (Credentials): Authorized GCP credentials used to access the Cloud Functions API. + args (dict): Command arguments including: + - project_id (str): The GCP project ID. + - limit (int, optional): Maximum number of results to return (1-500). + - next_token (str, optional): Token to retrieve the next page of results. + + Returns: + CommandResults: Object containing the list of locations under `GCP.CloudRun.Locations`, + with the continuation token under `GCP.CloudRun.LocationsNextToken`. + """ + project_id = args.get("project_id") + limit = arg_to_number(args.get("limit")) + next_token = args.get("next_token") + validate_limit(limit) + + params: dict[str, Any] = { + "name": f"projects/{project_id}", + "pageSize": limit, + "pageToken": next_token, + } + remove_nulls_from_dictionary(params) + demisto.debug(f"[GCP: cloud_run_location_list] Listing locations with params: {params}") + + service = GCPServices.CLOUD_FUNCTIONS.build(creds) + response = service.projects().locations().list(**params).execute() # pylint: disable=E1101 + locations = response.get("locations", []) + if not locations: + return CommandResults(readable_output="No locations found.", raw_response=response) + + next_page_token = response.get("nextPageToken") + readable_output = tableToMarkdown( + f'GCP Cloud Function Locations in project "{project_id}"', + locations, + headers=["locationId", "name", "labels"], + headerTransform=pascalToSpace, + removeNull=True, + ) + outputs = { + "GCP.CloudRun.Locations(val.locationId && val.locationId == obj.locationId)": locations, + "GCP.CloudRun(true)": {"LocationsNextToken": next_page_token}, + } + return CommandResults( + readable_output=readable_output, + outputs=outputs, + raw_response=response, + ) + + +def cloud_run_function_get(creds: Credentials, args: dict[str, Any]) -> CommandResults: + """ + Retrieves the details of a specific Google Cloud Run function. + + Args: + creds (Credentials): Authorized GCP credentials used to access the Cloud Functions API. + args (dict): Command arguments including: + - project_id (str): The GCP project ID. + - region (str): The region of the function. + - function_name (str): The name of the function to retrieve. + + Returns: + CommandResults: Object containing the function details under `GCP.CloudRun.Functions`. + """ + project_id = args.get("project_id") + region = args.get("region") + function_name = args.get("function_name") + name = f"projects/{project_id}/locations/{region}/functions/{function_name}" + demisto.debug(f"[GCP: cloud_run_function_get] Getting function: {name}") + + service = GCPServices.CLOUD_FUNCTIONS.build(creds) + response = service.projects().locations().functions().get(name=name).execute() # pylint: disable=E1101 + readable_output = tableToMarkdown( + f"GCP Cloud Function: {function_name}", + response, + headerTransform=pascalToSpace, + removeNull=True, + ) + return CommandResults( + readable_output=readable_output, + outputs_prefix="GCP.CloudRun.Functions", + outputs_key_field="name", + outputs=response, + raw_response=response, + ) + + def main(): # pragma: no cover """ Main function to route commands and execute logic. @@ -3006,6 +3202,10 @@ def main(): # pragma: no cover "gcp-iam-project-policy-binding-remove": iam_project_policy_binding_remove, # BigQuery commands "gcp-bq-dataset-policy-remove": bq_dataset_policy_remove_command, + # Cloud Run functions commands + "gcp-cloudrun-functions-list": cloud_run_function_list, + "gcp-cloudrun-locations-list": cloud_run_location_list, + "gcp-cloudrun-function-get": cloud_run_function_get, # Quick Actions - Firewall "gcp-compute-firewall-patch-disable-gcp-default-firewall-rule-quick-action": compute_firewall_patch, # Quick Actions - Storage Bucket Policy diff --git a/Packs/GCP/Integrations/GCP/GCP.yml b/Packs/GCP/Integrations/GCP/GCP.yml index 0da160cfb68..794a99ba5d1 100644 --- a/Packs/GCP/Integrations/GCP/GCP.yml +++ b/Packs/GCP/Integrations/GCP/GCP.yml @@ -4210,6 +4210,171 @@ script: # description: Custom fields of the user. # type: Unknown + - name: gcp-cloudrun-functions-list + description: "Lists Google Cloud Functions in the specified project and region. Required Permission: cloudfunctions.functions.list." + arguments: + - name: project_id + required: false + required:platform: true + description: The GCP project ID. Required for Cortex Platform (which includes Cortex XSIAM version >=3.0 and Cortex Cloud). Optional for Cortex XSOAR and Cortex XSIAM version < 3.0, where it can be retrieved from the integration configuration. + - name: region + description: The region of the Google Cloud functions. Default is all regions. You can get a full list of regions using the gcp-cloudrun-locations-list command. + required: false + - name: limit + defaultValue: '50' + description: "Maximum number of results to return. Acceptable values are 1 to 500, inclusive." + - name: next_token + description: The token for the next set of items to return, used for pagination. + - name: filter + description: 'A filter expression for the functions listed in the response. For example, to return only active functions, use state="ACTIVE".' + - name: order_by + description: 'The sort order of the returned functions, as a comma-separated list of fields. Append " desc" to a field to sort it in descending order. For example, name desc.' + outputs: + - contextPath: GCP.CloudRun.Functions.name + description: 'A user-defined name of the function. Function names are unique globally and match the pattern projects/*/locations/*/functions/*.' + type: String + - contextPath: GCP.CloudRun.Functions.description + description: User-provided description of the function. + type: String + - contextPath: GCP.CloudRun.Functions.buildConfig + description: The build step of the function that builds a container from the given source. + type: Unknown + - contextPath: GCP.CloudRun.Functions.serviceConfig + description: The service being deployed. Currently deploys services to Cloud Run (fully managed). + type: Unknown + - contextPath: GCP.CloudRun.Functions.eventTrigger + description: An Eventarc trigger that fires events in response to a condition in another service. + type: Unknown + - contextPath: GCP.CloudRun.Functions.state + description: 'State of the function. Possible values are: STATE_UNSPECIFIED, ACTIVE, FAILED, DEPLOYING, DELETING, UNKNOWN, DETACHING, DETACH_FAILED.' + type: String + - contextPath: GCP.CloudRun.Functions.updateTime + description: The last update timestamp of the function. + type: Date + - contextPath: GCP.CloudRun.Functions.labels + description: Labels associated with this function. + type: Unknown + - contextPath: GCP.CloudRun.Functions.stateMessages + description: State messages for this function. + type: Unknown + - contextPath: GCP.CloudRun.Functions.environment + description: 'Whether the function is 1st Gen or 2nd Gen. Possible values are: ENVIRONMENT_UNSPECIFIED, GEN_1, GEN_2.' + type: String + - contextPath: GCP.CloudRun.Functions.upgradeInfo + description: Upgrade information for this function. + type: Unknown + - contextPath: GCP.CloudRun.Functions.url + description: The deployed URL of the function. + type: String + - contextPath: GCP.CloudRun.Functions.kmsKeyName + description: 'Resource name of a KMS crypto key (managed by the user) used to encrypt/decrypt function resources. Matches the pattern projects/{project}/locations/{location}/keyRings/{key_ring}/cryptoKeys/{crypto_key}.' + type: String + - contextPath: GCP.CloudRun.Functions.satisfiesPzs + description: Reserved for future use. + type: Boolean + - contextPath: GCP.CloudRun.Functions.createTime + description: The create timestamp of the function. This is only applicable to 2nd Gen functions. + type: Date + - contextPath: GCP.CloudRun.Functions.satisfiesPzi + description: Reserved for future use. + type: Boolean + - contextPath: GCP.CloudRun.FunctionsNextToken + description: The token to retrieve the next page of Google Cloud Run functions. + type: String + - name: gcp-cloudrun-locations-list + description: "Lists all locations (regions) available for Google Cloud Functions in the project. Required Permission: cloudfunctions.locations.list." + arguments: + - name: project_id + required: false + required:platform: true + description: The GCP project ID. Required for Cortex Platform (which includes Cortex XSIAM version >=3.0 and Cortex Cloud). Optional for Cortex XSOAR and Cortex XSIAM version < 3.0, where it can be retrieved from the integration configuration. + - name: limit + defaultValue: '50' + description: "Maximum number of results to return. Acceptable values are 1 to 500, inclusive." + - name: next_token + description: The token for the next set of items to return, used for pagination. + outputs: + - contextPath: GCP.CloudRun.Locations.name + description: 'Resource name for the location, which may vary between implementations. For example: projects/example-project/locations/us-east1.' + type: String + - contextPath: GCP.CloudRun.Locations.locationId + description: 'The canonical ID for this location. For example: us-east1.' + type: String + - contextPath: GCP.CloudRun.Locations.displayName + description: 'The friendly name for this location, typically a nearby city name. For example, Tokyo.' + type: String + - contextPath: GCP.CloudRun.Locations.labels + description: 'Cross-service attributes for the location. For example {"cloud.googleapis.com/region": "us-east1"}.' + type: Unknown + - contextPath: GCP.CloudRun.Locations.metadata + description: Service-specific metadata. For example the available capacity at the given location. + type: Unknown + - contextPath: GCP.CloudRun.LocationsNextToken + description: The token to retrieve the next page of locations. + type: String + - name: gcp-cloudrun-function-get + description: "Gets the details of a specific Google Cloud function. Required Permission: cloudfunctions.functions.get." + arguments: + - name: project_id + required: false + required:platform: true + description: The GCP project ID. Required for Cortex Platform (which includes Cortex XSIAM version >=3.0 and Cortex Cloud). Optional for Cortex XSOAR and Cortex XSIAM version < 3.0, where it can be retrieved from the integration configuration. + - name: region + description: The region of the Google Cloud function. You can get a full list of regions using the gcp-cloudrun-locations-list command. + required: true + - name: function_name + description: The name of the function. + required: true + outputs: + - contextPath: GCP.CloudRun.Functions.name + description: 'A user-defined name of the function. Function names are unique globally and match the pattern projects/*/locations/*/functions/*.' + type: String + - contextPath: GCP.CloudRun.Functions.description + description: User-provided description of the function. + type: String + - contextPath: GCP.CloudRun.Functions.buildConfig + description: The build step of the function that builds a container from the given source. + type: Unknown + - contextPath: GCP.CloudRun.Functions.serviceConfig + description: The service being deployed. Currently deploys services to Cloud Run (fully managed). + type: Unknown + - contextPath: GCP.CloudRun.Functions.eventTrigger + description: An Eventarc trigger that fires events in response to a condition in another service. + type: Unknown + - contextPath: GCP.CloudRun.Functions.state + description: 'State of the function. Possible values are: STATE_UNSPECIFIED, ACTIVE, FAILED, DEPLOYING, DELETING, UNKNOWN, DETACHING, DETACH_FAILED.' + type: String + - contextPath: GCP.CloudRun.Functions.updateTime + description: The last update timestamp of the function. + type: Date + - contextPath: GCP.CloudRun.Functions.labels + description: Labels associated with this function. + type: Unknown + - contextPath: GCP.CloudRun.Functions.stateMessages + description: State messages for this function. + type: Unknown + - contextPath: GCP.CloudRun.Functions.environment + description: 'Whether the function is 1st Gen or 2nd Gen. Possible values are: ENVIRONMENT_UNSPECIFIED, GEN_1, GEN_2.' + type: String + - contextPath: GCP.CloudRun.Functions.upgradeInfo + description: Upgrade information for this function. + type: Unknown + - contextPath: GCP.CloudRun.Functions.url + description: The deployed URL of the function. + type: String + - contextPath: GCP.CloudRun.Functions.kmsKeyName + description: 'Resource name of a KMS crypto key (managed by the user) used to encrypt/decrypt function resources. Matches the pattern projects/{project}/locations/{location}/keyRings/{key_ring}/cryptoKeys/{crypto_key}.' + type: String + - contextPath: GCP.CloudRun.Functions.satisfiesPzs + description: Reserved for future use. + type: Boolean + - contextPath: GCP.CloudRun.Functions.createTime + description: The create timestamp of the function. This is only applicable to 2nd Gen functions. + type: Date + - contextPath: GCP.CloudRun.Functions.satisfiesPzi + description: Reserved for future use. + type: Boolean + # The command is currently unsupported. # - name: gcp-admin-user-signout # description: Signs a user out of all web and device sessions and reset their sign-in cookies. diff --git a/Packs/GCP/Integrations/GCP/GCP_test.py b/Packs/GCP/Integrations/GCP/GCP_test.py index 3917364294c..bd5a30db657 100644 --- a/Packs/GCP/Integrations/GCP/GCP_test.py +++ b/Packs/GCP/Integrations/GCP/GCP_test.py @@ -1,6 +1,7 @@ import ast import json import pytest +from CommonServerPython import DemistoException from google.oauth2.credentials import Credentials from unittest.mock import MagicMock import os @@ -3262,6 +3263,18 @@ def test_gcp_compute_instance_label_set_command_add_labels_false(mocker): assert result.outputs == mock_operation_response +def test_validate_limit_none_is_allowed(): + """ + Given: No limit value (the argument was omitted, so arg_to_number returned None). + When: validate_limit is called. + Then: It returns without raising, letting the API apply its own default page size. + Guards against the TypeError raised by comparing None to an int. + """ + from GCP import validate_limit + + validate_limit(None) + + def test_validate_limit_valid_input(): """ Given: A valid limit value (between 1 and 500 inclusive) @@ -5786,6 +5799,39 @@ def test_test_connectivity_container_uses_clusters_list(mocker): ) +def test_test_connectivity_cloud_functions_uses_testiampermissions(mocker): + """ + Given: + - The CLOUD_FUNCTIONS service and a built API client. + When: + - test_connectivity is called. + Then: + - The function-scoped testIamPermissions endpoint is invoked (rather than a functions list, + which would fail for a project that has not enabled the Cloud Functions API), using the + placeholder probe resource and a concrete location. + """ + from GCP import CLOUD_FUNCTIONS_PROBE_FUNCTION, CLOUD_FUNCTIONS_PROBE_LOCATION, GCPServices + from google.oauth2.credentials import Credentials + + creds = MagicMock(spec=Credentials) + mock_client = MagicMock() + mocker.patch.object(GCPServices.CLOUD_FUNCTIONS, "build", return_value=mock_client) + + GCPServices.CLOUD_FUNCTIONS.test_connectivity(creds, "dummy-project-id") + + functions = mock_client.projects.return_value.locations.return_value.functions.return_value + functions.testIamPermissions.assert_called_once_with( + resource=( + f"projects/dummy-project-id/locations/{CLOUD_FUNCTIONS_PROBE_LOCATION}" f"/functions/{CLOUD_FUNCTIONS_PROBE_FUNCTION}" + ), + body={"permissions": ["cloudfunctions.functions.get"]}, + ) + # The previous list-based probe must not be used: it 403s on projects with the API disabled. + functions.list.assert_not_called() + # The wildcard location is only valid for list methods, not for a resource-scoped call. + assert CLOUD_FUNCTIONS_PROBE_LOCATION != "-" + + def test_test_all_services_wraps_results_into_tuples(mocker): """ Given: @@ -6652,3 +6698,291 @@ def test_extract_output_prefixes_does_not_strip_whitespace_typos(): handler = _top_level_functions(ast.parse(source))["handler"] assert _extract_output_prefixes(handler) == {" GCP.Compute.Operations"} + + +# --------------------------------------------------------------------------- +# Cloud Functions (migrated from the legacy GoogleCloudFunctions integration) +# --------------------------------------------------------------------------- + + +def test_cloud_run_function_list_success(mocker): + """ + Given: Valid credentials and a project_id/region plus pagination arguments. + When: cloud_run_function_list is called. + Then: It builds the correct parent, forwards limit/next_token as pageSize/pageToken, + and returns the functions plus the continuation token in the outputs. + """ + from GCP import cloud_run_function_list + + mock_creds = mocker.Mock(spec=Credentials) + mock_service = mocker.Mock() + mock_functions = mocker.Mock() + mock_service.projects.return_value.locations.return_value.functions.return_value = mock_functions + mock_functions.list.return_value.execute.return_value = { + "functions": [{"name": "projects/mock_project_id/locations/us-central1/functions/fn-1", "state": "ACTIVE"}], + "nextPageToken": "tok", + } + mocker.patch("GCP.build", return_value=mock_service) + + args = {"project_id": "mock_project_id", "region": "us-central1", "limit": "10", "next_token": "prev"} + result = cloud_run_function_list(mock_creds, args) + + called_kwargs = mock_functions.list.call_args[1] + assert called_kwargs["parent"] == "projects/mock_project_id/locations/us-central1" + assert called_kwargs["pageSize"] == 10 + assert called_kwargs["pageToken"] == "prev" + functions = result.outputs["GCP.CloudRun.Functions(val.name && val.name == obj.name)"] + assert functions[0]["state"] == "ACTIVE" + assert result.outputs["GCP.CloudRun(true)"]["FunctionsNextToken"] == "tok" + assert "fn-1" in result.readable_output + + +def test_cloud_run_function_list_defaults_to_all_regions(mocker): + """ + Given: No region argument. + When: cloud_run_function_list is called. + Then: The parent uses the "-" wildcard so functions from every location are listed, + and no pagination keys are sent when limit/next_token are omitted. + """ + from GCP import cloud_run_function_list + + mock_creds = mocker.Mock(spec=Credentials) + mock_service = mocker.Mock() + mock_functions = mocker.Mock() + mock_service.projects.return_value.locations.return_value.functions.return_value = mock_functions + mock_functions.list.return_value.execute.return_value = {"functions": [{"name": "fn-1"}]} + mocker.patch("GCP.build", return_value=mock_service) + + result = cloud_run_function_list(mock_creds, {"project_id": "mock_project_id"}) + + called_kwargs = mock_functions.list.call_args[1] + assert called_kwargs["parent"] == "projects/mock_project_id/locations/-" + assert "pageSize" not in called_kwargs + assert "pageToken" not in called_kwargs + assert result.outputs["GCP.CloudRun(true)"]["FunctionsNextToken"] is None + + +def test_cloud_run_function_list_invalid_limit_raises(mocker): + """ + Given: A limit above the documented maximum. + When: cloud_run_function_list is called. + Then: A DemistoException is raised and no API call is made. + """ + from GCP import cloud_run_function_list + + mock_creds = mocker.Mock(spec=Credentials) + mock_service = mocker.Mock() + mocker.patch("GCP.build", return_value=mock_service) + + with pytest.raises(DemistoException, match="acceptable values of the argument limit"): + cloud_run_function_list(mock_creds, {"project_id": "mock_project_id", "limit": "501"}) + + mock_service.projects.return_value.locations.return_value.functions.return_value.list.assert_not_called() + + +def test_cloud_run_function_list_empty(mocker): + """ + Given: A Cloud Run functions service returning no functions. + When: cloud_run_function_list is called. + Then: A human-readable "No functions found." message is returned. + """ + from GCP import cloud_run_function_list + + mock_creds = mocker.Mock(spec=Credentials) + mock_service = mocker.Mock() + mock_service.projects.return_value.locations.return_value.functions.return_value.list.return_value.execute.return_value = { + "functions": [] + } + mocker.patch("GCP.build", return_value=mock_service) + + result = cloud_run_function_list(mock_creds, {"project_id": "mock_project_id"}) + assert "No functions found." in result.readable_output + + +def test_cloud_run_location_list_success(mocker): + """ + Given: Valid credentials and a project_id plus pagination arguments. + When: cloud_run_location_list is called. + Then: It builds the correct name, forwards limit/next_token as pageSize/pageToken, + and returns the locations plus the continuation token in the outputs. + """ + from GCP import cloud_run_location_list + + mock_creds = mocker.Mock(spec=Credentials) + mock_service = mocker.Mock() + mock_locations = mocker.Mock() + mock_service.projects.return_value.locations.return_value = mock_locations + mock_locations.list.return_value.execute.return_value = { + "locations": [{"locationId": "us-central1", "name": "projects/mock_project_id/locations/us-central1"}], + "nextPageToken": "tok", + } + mocker.patch("GCP.build", return_value=mock_service) + + result = cloud_run_location_list(mock_creds, {"project_id": "mock_project_id", "limit": "5", "next_token": "prev"}) + + called_kwargs = mock_locations.list.call_args[1] + assert called_kwargs["name"] == "projects/mock_project_id" + assert called_kwargs["pageSize"] == 5 + assert called_kwargs["pageToken"] == "prev" + locations = result.outputs["GCP.CloudRun.Locations(val.locationId && val.locationId == obj.locationId)"] + assert locations[0]["locationId"] == "us-central1" + assert result.outputs["GCP.CloudRun(true)"]["LocationsNextToken"] == "tok" + assert "us-central1" in result.readable_output + + +def test_cloud_run_location_list_empty(mocker): + """ + Given: A Cloud Run functions service returning no locations. + When: cloud_run_location_list is called. + Then: A human-readable "No locations found." message is returned. + """ + from GCP import cloud_run_location_list + + mock_creds = mocker.Mock(spec=Credentials) + mock_service = mocker.Mock() + mock_service.projects.return_value.locations.return_value.list.return_value.execute.return_value = {"locations": []} + mocker.patch("GCP.build", return_value=mock_service) + + result = cloud_run_location_list(mock_creds, {"project_id": "mock_project_id"}) + assert "No locations found." in result.readable_output + + +def test_cloud_run_function_get_success(mocker): + """ + Given: An existing function name. + When: cloud_run_function_get is called. + Then: The fully-qualified resource name is passed to the API and the function + details are returned under the GCP.CloudRun.Functions prefix. + """ + from GCP import cloud_run_function_get + + mock_creds = mocker.Mock(spec=Credentials) + mock_service = mocker.Mock() + mock_functions = mocker.Mock() + mock_service.projects.return_value.locations.return_value.functions.return_value = mock_functions + mocker.patch("GCP.build", return_value=mock_service) + + mock_functions.get.return_value.execute.return_value = { + "name": "projects/mock_project_id/locations/us-central1/functions/fn-1", + "state": "ACTIVE", + } + res = cloud_run_function_get(mock_creds, {"project_id": "mock_project_id", "region": "us-central1", "function_name": "fn-1"}) + + assert res.outputs_prefix == "GCP.CloudRun.Functions" + assert res.outputs_key_field == "name" + called_kwargs = mock_functions.get.call_args[1] + assert called_kwargs["name"] == "projects/mock_project_id/locations/us-central1/functions/fn-1" + + +def test_cloud_run_function_get_not_found_propagates(mocker): + """ + Given: A function that does not exist, so the API raises a 404 HttpError. + When: cloud_run_function_get is called. + Then: The HttpError propagates to main(), which routes it through + handle_permission_error, rather than being swallowed by the command. + """ + from GCP import cloud_run_function_get + from googleapiclient.errors import HttpError + + mock_creds = mocker.Mock(spec=Credentials) + mock_service = mocker.Mock() + mock_functions = mocker.Mock() + mock_service.projects.return_value.locations.return_value.functions.return_value = mock_functions + mocker.patch("GCP.build", return_value=mock_service) + + resp = mocker.MagicMock() + resp.status = 404 + mock_functions.get.return_value.execute.side_effect = HttpError( + resp, b'{"error": {"message": "The resource fn-2 was not found"}}' + ) + + with pytest.raises(HttpError): + cloud_run_function_get(mock_creds, {"project_id": "mock_project_id", "region": "us-central1", "function_name": "fn-2"}) + + +def test_cloud_run_function_list_permission_error_propagates(mocker): + """ + Given: A caller lacking cloudfunctions.functions.list, so the API raises a 403 HttpError. + When: cloud_run_function_list is called. + Then: The HttpError propagates out of the command so main() can route it through + handle_permission_error, rather than being swallowed into a success result. + """ + from GCP import cloud_run_function_list + from googleapiclient.errors import HttpError + + mock_creds = mocker.Mock(spec=Credentials) + mock_service = mocker.Mock() + mock_functions = mocker.Mock() + mock_service.projects.return_value.locations.return_value.functions.return_value = mock_functions + mocker.patch("GCP.build", return_value=mock_service) + + resp = mocker.MagicMock() + resp.status = 403 + mock_functions.list.return_value.execute.side_effect = HttpError( + resp, b'{"error": {"message": "Permission \'cloudfunctions.functions.list\' denied on resource"}}' + ) + + with pytest.raises(HttpError): + cloud_run_function_list(mock_creds, {"project_id": "mock_project_id"}) + + +def test_cloud_run_location_list_permission_error_propagates(mocker): + """ + Given: A caller lacking cloudfunctions.locations.list, so the API raises a 403 HttpError. + When: cloud_run_location_list is called. + Then: The HttpError propagates out of the command so main() can route it through + handle_permission_error. + """ + from GCP import cloud_run_location_list + from googleapiclient.errors import HttpError + + mock_creds = mocker.Mock(spec=Credentials) + mock_service = mocker.Mock() + mock_locations = mocker.Mock() + mock_service.projects.return_value.locations.return_value = mock_locations + mocker.patch("GCP.build", return_value=mock_service) + + resp = mocker.MagicMock() + resp.status = 403 + mock_locations.list.return_value.execute.side_effect = HttpError( + resp, b'{"error": {"message": "Permission \'cloudfunctions.locations.list\' denied on resource"}}' + ) + + with pytest.raises(HttpError): + cloud_run_location_list(mock_creds, {"project_id": "mock_project_id"}) + + +@pytest.mark.parametrize( + "command_name, permission", + [ + ("gcp-cloudrun-functions-list", "cloudfunctions.functions.list"), + ("gcp-cloudrun-locations-list", "cloudfunctions.locations.list"), + ("gcp-cloudrun-function-get", "cloudfunctions.functions.get"), + ], +) +def test_cloud_run_permission_error_reports_declared_permission(mocker, command_name, permission): + """ + Given: A 403 HttpError naming the permission a Cloud Run command requires. + When: handle_permission_error is called for that command. + Then: The permission is matched against COMMAND_REQUIREMENTS and reported by name, + proving each Cloud Run command is wired into the permission registry. + """ + from GCP import handle_permission_error + from googleapiclient.errors import HttpError + + mock_resp = mocker.MagicMock() + mock_resp.status = 403 + mock_resp.get.return_value = "application/json" + + error_content = {"error": {"message": f"Permission '{permission}' denied on resource"}} + http_error = HttpError(mock_resp, json.dumps(error_content).encode()) + + mocker.patch("GCP.demisto.debug") + mock_return_error = mocker.patch("GCP.return_multiple_permissions_error") + + handle_permission_error(http_error, "mock_project_id", command_name) + + error_entries = mock_return_error.call_args[0][0] + assert len(error_entries) == 1 + assert error_entries[0]["account_id"] == "mock_project_id" + assert error_entries[0]["name"] == permission