diff --git a/samcli/commands/delete/delete_context.py b/samcli/commands/delete/delete_context.py index 5a70fa9f07..9b1d5210b5 100644 --- a/samcli/commands/delete/delete_context.py +++ b/samcli/commands/delete/delete_context.py @@ -4,6 +4,8 @@ import boto3 + +import docker import click from click import confirm from click import prompt @@ -13,11 +15,16 @@ from samcli.lib.package.s3_uploader import S3Uploader from samcli.lib.package.artifact_exporter import mktempfile, get_cf_template_name +from samcli.yamlhelper import yaml_parse + +from samcli.lib.package.artifact_exporter import Template +from samcli.lib.package.ecr_uploader import ECRUploader +from samcli.lib.package.uploaders import Uploaders + CONFIG_COMMAND = "deploy" CONFIG_SECTION = "parameters" TEMPLATE_STAGE = "Original" - class DeleteContext: def __init__(self, stack_name: str, region: str, profile: str, config_file: str, config_env: str): self.stack_name = stack_name @@ -29,6 +36,7 @@ def __init__(self, stack_name: str, region: str, profile: str, config_file: str, self.s3_prefix = None self.cf_utils = None self.s3_uploader = None + self.uploaders = None self.cf_template_file_name = None self.delete_artifacts_folder = None self.delete_cf_template_file = None @@ -70,6 +78,7 @@ def delete(self): """ template = self.cf_utils.get_stack_template(self.stack_name, TEMPLATE_STAGE) template_str = template.get("TemplateBody", None) + template_dict = yaml_parse(template_str) if self.s3_bucket and self.s3_prefix and template_str: self.delete_artifacts_folder = confirm( @@ -92,9 +101,14 @@ def delete(self): # Delete the primary stack self.cf_utils.delete_stack(stack_name=self.stack_name) - + self.cf_utils.wait_for_delete(self.stack_name) + click.echo(f"\n\t- Deleting Cloudformation stack {self.stack_name}") - + + # Delete the artifacts + template = Template(None, None, self.uploaders, None) + template.delete(template_dict) + # Delete the CF template file in S3 if self.delete_cf_template_file: self.s3_uploader.delete_artifact(remote_path=self.cf_template_file_name) @@ -124,8 +138,14 @@ def run(self): ) s3_client = boto3.client("s3", region_name=self.region if self.region else None, config=boto_config) + ecr_client = boto3.client("ecr", region_name=self.region if self.region else None, config=boto_config) self.s3_uploader = S3Uploader(s3_client=s3_client, bucket_name=self.s3_bucket, prefix=self.s3_prefix) + + docker_client = docker.from_env() + ecr_uploader = ECRUploader(docker_client, ecr_client, None, None) + + self.uploaders = Uploaders(self.s3_uploader, ecr_uploader) self.cf_utils = CfUtils(cloudformation_client) is_deployed = self.cf_utils.has_stack(stack_name=self.stack_name) diff --git a/samcli/commands/package/exceptions.py b/samcli/commands/package/exceptions.py index a650f62843..2e23cf7458 100644 --- a/samcli/commands/package/exceptions.py +++ b/samcli/commands/package/exceptions.py @@ -62,6 +62,28 @@ def __init__(self, resource_id, property_name, property_value, ex): ) +class DeleteArtifactFailedError(UserException): + def __init__(self, resource_id, property_name, ex): + self.resource_id = resource_id + self.property_name = property_name + self.ex = ex + + message_fmt = ( + "Unable to delete artifact referenced " + "by {property_name} parameter of {resource_id} resource." + "\n" + "{ex}" + ) + + super().__init__( + message=message_fmt.format( + property_name=self.property_name, + resource_id=self.resource_id, + ex=self.ex, + ) + ) + + class ImageNotFoundError(UserException): def __init__(self, resource_id, property_name): self.resource_id = resource_id diff --git a/samcli/lib/delete/cf_utils.py b/samcli/lib/delete/cf_utils.py index a78ed6d38b..40d3b58183 100644 --- a/samcli/lib/delete/cf_utils.py +++ b/samcli/lib/delete/cf_utils.py @@ -4,10 +4,12 @@ import logging + from typing import Dict -from botocore.exceptions import ClientError, BotoCoreError +from botocore.exceptions import ClientError, BotoCoreError, WaiterError from samcli.commands.delete.exceptions import DeleteFailedError, FetchTemplateFailedError + LOG = logging.getLogger(__name__) @@ -102,3 +104,26 @@ def delete_stack(self, stack_name: str): # We don't know anything about this exception. Don't handle LOG.error("Failed to delete stack. ", exc_info=e) raise e + + def wait_for_delete(self, stack_name): + """ + Waits until the delete stack completes + + :param stack_name: Stack name + """ + + # Wait for Delete to Finish + waiter = self._client.get_waiter("stack_delete_complete") + # Poll every 5 seconds. + waiter_config = {"Delay": 5} + try: + waiter.wait(StackName=stack_name, WaiterConfig=waiter_config) + except WaiterError as ex: + + resp = ex.last_response + status = resp["Status"] + reason = resp["StatusReason"] + + raise DeleteFailedError( + stack_name=stack_name, msg="ex: {0} Status: {1}. Reason: {2}".format(ex, status, reason) + ) from ex diff --git a/samcli/lib/package/artifact_exporter.py b/samcli/lib/package/artifact_exporter.py index 85b5792ef9..2a8f484d87 100644 --- a/samcli/lib/package/artifact_exporter.py +++ b/samcli/lib/package/artifact_exporter.py @@ -130,21 +130,22 @@ def __init__( """ Reads the template and makes it ready for export """ - if not (is_local_folder(parent_dir) and os.path.isabs(parent_dir)): - raise ValueError("parent_dir parameter must be " "an absolute path to a folder {0}".format(parent_dir)) + if template_path and parent_dir: + if not (is_local_folder(parent_dir) and os.path.isabs(parent_dir)): + raise ValueError("parent_dir parameter must be " "an absolute path to a folder {0}".format(parent_dir)) - abs_template_path = make_abs_path(parent_dir, template_path) - template_dir = os.path.dirname(abs_template_path) + abs_template_path = make_abs_path(parent_dir, template_path) + template_dir = os.path.dirname(abs_template_path) - with open(abs_template_path, "r") as handle: - template_str = handle.read() + with open(abs_template_path, "r") as handle: + template_str = handle.read() - self.template_dict = yaml_parse(template_str) - self.template_dir = template_dir + self.template_dict = yaml_parse(template_str) + self.template_dir = template_dir + self.code_signer = code_signer self.resources_to_export = resources_to_export self.metadata_to_export = metadata_to_export self.uploaders = uploaders - self.code_signer = code_signer def _export_global_artifacts(self, template_dict: Dict) -> Dict: """ @@ -235,3 +236,27 @@ def export(self) -> Dict: exporter.export(resource_id, resource_dict, self.template_dir) return self.template_dict + + def delete(self, template_dict): + self.template_dict = template_dict + + if "Resources" not in self.template_dict: + return self.template_dict + + self._apply_global_values() + + for resource_id, resource in self.template_dict["Resources"].items(): + + resource_type = resource.get("Type", None) + resource_dict = resource.get("Properties", {}) + resource_deletion_policy = resource.get("DeletionPolicy", None) + if resource_deletion_policy != "Retain": + for exporter_class in self.resources_to_export: + if exporter_class.RESOURCE_TYPE != resource_type: + continue + if resource_dict.get("PackageType", ZIP) != exporter_class.ARTIFACT_TYPE: + continue + # Delete code resources + exporter = exporter_class(self.uploaders, None) + exporter.delete(resource_id, resource_dict) + return self.template_dict diff --git a/samcli/lib/package/ecr_uploader.py b/samcli/lib/package/ecr_uploader.py index fcf4e836e9..402ebbf4cf 100644 --- a/samcli/lib/package/ecr_uploader.py +++ b/samcli/lib/package/ecr_uploader.py @@ -5,12 +5,19 @@ import base64 import os +import click import botocore import docker from docker.errors import BuildError, APIError -from samcli.commands.package.exceptions import DockerPushFailedError, DockerLoginFailedError, ECRAuthorizationError +from samcli.commands.package.exceptions import ( + DockerPushFailedError, + DockerLoginFailedError, + ECRAuthorizationError, + ImageNotFoundError, + DeleteArtifactFailedError, +) from samcli.lib.package.image_utils import tag_translation from samcli.lib.package.stream_cursor_utils import cursor_up, cursor_left, cursor_down, clear_line from samcli.lib.utils.osutils import stderr @@ -83,6 +90,30 @@ def upload(self, image, resource_name): return f"{repository}:{_tag}" + def delete_artifact(self, image_uri: str, resource_id: str, property_name: str): + try: + repo_image_tag = image_uri.split("/")[1].split(":") + repository = repo_image_tag[0] + image_tag = repo_image_tag[1] + resp = self.ecr_client.batch_delete_image( + repositoryName=repository, + imageIds=[ + {"imageTag": image_tag}, + ], + ) + if resp["failures"]: + # Image not found + image_details = resp["failures"][0] + if image_details["failureCode"] == "ImageNotFound": + LOG.debug("ImageNotFound Exception : ") + raise ImageNotFoundError(resource_id, property_name) + + click.echo(f"- Deleting ECR image {image_tag} in repository {repository}") + + except botocore.exceptions.ClientError as ex: + # Handle Client errors such as RepositoryNotFoundException or InvalidParameterException + raise DeleteArtifactFailedError(resource_id=resource_id, property_name=property_name, ex=ex) from ex + # TODO: move this to a generic class to allow for streaming logs back from docker. def _stream_progress(self, logs): """ diff --git a/samcli/lib/package/packageable_resources.py b/samcli/lib/package/packageable_resources.py index 937b451a28..cc2fe999d9 100644 --- a/samcli/lib/package/packageable_resources.py +++ b/samcli/lib/package/packageable_resources.py @@ -22,6 +22,7 @@ upload_local_image_artifacts, is_s3_protocol_url, is_path_value_valid, + is_ecr_url, ) from samcli.commands._utils.resources import ( @@ -79,6 +80,9 @@ def export(self, resource_id, resource_dict, parent_dir): def do_export(self, resource_id, resource_dict, parent_dir): pass + def delete(self, resource_id, resource_dict): + pass + class ResourceZip(Resource): """ @@ -154,6 +158,18 @@ def do_export(self, resource_id, resource_dict, parent_dir): ) set_value_from_jmespath(resource_dict, self.PROPERTY_NAME, uploaded_url) + def delete(self, resource_id, resource_dict): + """ + Delete the S3 artifact using S3 url referenced by PROPERTY_NAME + """ + if resource_dict is None: + return + resource_path = resource_dict[self.PROPERTY_NAME] + parsed_s3_url = self.uploader.parse_s3_url(resource_path) + if not self.uploader.bucket_name: + self.uploader.bucket_name = parsed_s3_url["Bucket"] + self.uploader.delete_artifact(parsed_s3_url["Key"], True) + class ResourceImageDict(Resource): """ @@ -197,6 +213,19 @@ def do_export(self, resource_id, resource_dict, parent_dir): ) set_value_from_jmespath(resource_dict, self.PROPERTY_NAME, {self.EXPORT_PROPERTY_CODE_KEY: uploaded_url}) + def delete(self, resource_id, resource_dict): + """ + Delete the ECR artifact using ECR url in PROPERTY_NAME referenced by EXPORT_PROPERTY_CODE_KEY + """ + if resource_dict is None: + return + + remote_path = resource_dict[self.PROPERTY_NAME][self.EXPORT_PROPERTY_CODE_KEY] + if is_ecr_url(remote_path): + self.uploader.delete_artifact( + image_uri=remote_path, resource_id=resource_id, property_name=self.PROPERTY_NAME + ) + class ResourceImage(Resource): """ @@ -238,6 +267,19 @@ def do_export(self, resource_id, resource_dict, parent_dir): ) set_value_from_jmespath(resource_dict, self.PROPERTY_NAME, uploaded_url) + def delete(self, resource_id, resource_dict): + """ + Delete the ECR artifact using ECR url referenced by property_name + """ + if resource_dict is None: + return + + remote_path = resource_dict[self.PROPERTY_NAME] + if is_ecr_url(remote_path): + self.uploader.delete_artifact( + image_uri=remote_path, resource_id=resource_id, property_name=self.PROPERTY_NAME + ) + class ResourceWithS3UrlDict(ResourceZip): """ @@ -269,6 +311,21 @@ def do_export(self, resource_id, resource_dict, parent_dir): ) set_value_from_jmespath(resource_dict, self.PROPERTY_NAME, parsed_url) + def delete(self, resource_id, resource_dict): + """ + Delete the S3 artifact using S3 url in the dict PROPERTY_NAME + using the bucket at BUCKET_NAME_PROPERTY and key at OBJECT_KEY_PROPERTY + """ + if resource_dict is None: + return + resource_path = resource_dict[self.PROPERTY_NAME] + s3_bucket = resource_path[self.BUCKET_NAME_PROPERTY] + key = resource_path[self.OBJECT_KEY_PROPERTY] + + if not self.uploader.bucket_name: + self.uploader.bucket_name = s3_bucket + self.uploader.delete_artifact(remote_path=key, is_key=True) + class ServerlessFunctionResource(ResourceZip): RESOURCE_TYPE = AWS_SERVERLESS_FUNCTION diff --git a/samcli/lib/package/s3_uploader.py b/samcli/lib/package/s3_uploader.py index 61a6988416..76b7ff1ec7 100644 --- a/samcli/lib/package/s3_uploader.py +++ b/samcli/lib/package/s3_uploader.py @@ -86,7 +86,7 @@ def upload(self, file_name: str, remote_path: str) -> str: # Check if a file with same data exists if not self.force_upload and self.file_exists(remote_path): - LOG.debug("File with same data is already exists at %s. " "Skipping upload", remote_path) + LOG.info("File with same data already exists at %s, skipping upload", remote_path) return self.make_url(remote_path) try: diff --git a/samcli/local/apigw/local_apigw_service.py b/samcli/local/apigw/local_apigw_service.py index cc2684c200..5a6d397d54 100644 --- a/samcli/local/apigw/local_apigw_service.py +++ b/samcli/local/apigw/local_apigw_service.py @@ -333,7 +333,7 @@ def _request_handler(self, **kwargs): ) else: (status_code, headers, body) = self._parse_v1_payload_format_lambda_output( - lambda_response, self.api.binary_media_types, request + lambda_response, self.api.binary_media_types, request, route.event_type ) except LambdaResponseParseException as ex: LOG.error("Invalid lambda response received: %s", ex) @@ -379,13 +379,14 @@ def get_request_methods_endpoints(flask_request): # Consider moving this out to its own class. Logic is started to get dense and looks messy @jfuss @staticmethod - def _parse_v1_payload_format_lambda_output(lambda_output: str, binary_types, flask_request): + def _parse_v1_payload_format_lambda_output(lambda_output: str, binary_types, flask_request, event_type): """ Parses the output from the Lambda Container :param str lambda_output: Output from Lambda Invoke :param binary_types: list of binary types :param flask_request: flash request object + :param event_type: determines the route event type :return: Tuple(int, dict, str, bool) """ # pylint: disable-msg=too-many-statements @@ -397,6 +398,9 @@ def _parse_v1_payload_format_lambda_output(lambda_output: str, binary_types, fla if not isinstance(json_output, dict): raise LambdaResponseParseException(f"Lambda returned {type(json_output)} instead of dict") + if event_type == Route.HTTP and json_output.get("statusCode") is None: + raise LambdaResponseParseException(f"Invalid API Gateway Response Key: statusCode is not in {json_output}") + status_code = json_output.get("statusCode") or 200 headers = LocalApigwService._merge_response_headers( json_output.get("headers") or {}, json_output.get("multiValueHeaders") or {} @@ -405,7 +409,8 @@ def _parse_v1_payload_format_lambda_output(lambda_output: str, binary_types, fla body = json_output.get("body") if body is None: LOG.warning("Lambda returned empty body!") - is_base_64_encoded = json_output.get("isBase64Encoded") or False + + is_base_64_encoded = LocalApigwService.get_base_64_encoded(event_type, json_output) try: status_code = int(status_code) @@ -422,8 +427,10 @@ def _parse_v1_payload_format_lambda_output(lambda_output: str, binary_types, fla f"Non null response bodies should be able to convert to string: {body}" ) from ex - invalid_keys = LocalApigwService._invalid_apig_response_keys(json_output) - if invalid_keys: + invalid_keys = LocalApigwService._invalid_apig_response_keys(json_output, event_type) + # HTTP API Gateway just skip the non allowed lambda response fields, but Rest API gateway fail on + # the non allowed fields + if event_type == Route.API and invalid_keys: raise LambdaResponseParseException(f"Invalid API Gateway Response Keys: {invalid_keys} in {json_output}") # If the customer doesn't define Content-Type default to application/json @@ -432,17 +439,51 @@ def _parse_v1_payload_format_lambda_output(lambda_output: str, binary_types, fla headers["Content-Type"] = "application/json" try: - if LocalApigwService._should_base64_decode_body(binary_types, flask_request, headers, is_base_64_encoded): + # HTTP API Gateway always decode the lambda response only if isBase64Encoded field in response is True + # regardless the response content-type + # Rest API Gateway depends on the response content-type and the API configured BinaryMediaTypes to decide + # if it will decode the response or not + if (event_type == Route.HTTP and is_base_64_encoded) or ( + event_type == Route.API + and LocalApigwService._should_base64_decode_body( + binary_types, flask_request, headers, is_base_64_encoded + ) + ): body = base64.b64decode(body) except ValueError as ex: LambdaResponseParseException(str(ex)) return status_code, headers, body + @staticmethod + def get_base_64_encoded(event_type, json_output): + # The following behaviour is undocumented behaviour, and based on some trials + # Http API gateway checks lambda response for isBase64Encoded field, and ignore base64Encoded + # Rest API gateway checks first the field base64Encoded field, if not exist, it checks isBase64Encoded field + + if event_type == Route.API and json_output.get("base64Encoded") is not None: + is_base_64_encoded = json_output.get("base64Encoded") + field_name = "base64Encoded" + elif json_output.get("isBase64Encoded") is not None: + is_base_64_encoded = json_output.get("isBase64Encoded") + field_name = "isBase64Encoded" + else: + is_base_64_encoded = False + field_name = "isBase64Encoded" + + if isinstance(is_base_64_encoded, str) and is_base_64_encoded in ["true", "True", "false", "False"]: + is_base_64_encoded = is_base_64_encoded in ["true", "True"] + elif not isinstance(is_base_64_encoded, bool): + raise LambdaResponseParseException( + f"Invalid API Gateway Response Key: {is_base_64_encoded} is not a valid" f"{field_name}" + ) + + return is_base_64_encoded + @staticmethod def _parse_v2_payload_format_lambda_output(lambda_output: str, binary_types, flask_request): """ - Parses the output from the Lambda Container + Parses the output from the Lambda Container. V2 Payload Format means that the event_type is only HTTP :param str lambda_output: Output from Lambda Invoke :param binary_types: list of binary types @@ -487,21 +528,15 @@ def _parse_v2_payload_format_lambda_output(lambda_output: str, binary_types, fla f"Non null response bodies should be able to convert to string: {body}" ) from ex - # API Gateway only accepts statusCode, body, headers, and isBase64Encoded in - # a response shape. - # Don't check the response keys when inferring a response, see - # https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.v2. - invalid_keys = LocalApigwService._invalid_apig_response_keys(json_output) - if "statusCode" in json_output and invalid_keys: - raise LambdaResponseParseException(f"Invalid API Gateway Response Keys: {invalid_keys} in {json_output}") - # If the customer doesn't define Content-Type default to application/json if "Content-Type" not in headers: LOG.info("No Content-Type given. Defaulting to 'application/json'.") headers["Content-Type"] = "application/json" try: - if LocalApigwService._should_base64_decode_body(binary_types, flask_request, headers, is_base_64_encoded): + # HTTP API Gateway always decode the lambda response only if isBase64Encoded field in response is True + # regardless the response content-type + if is_base_64_encoded: # Note(xinhol): here in this method we change the type of the variable body multiple times # and confused mypy, we might want to avoid this and use multiple variables here. body = base64.b64decode(body) # type: ignore @@ -511,8 +546,10 @@ def _parse_v2_payload_format_lambda_output(lambda_output: str, binary_types, fla return status_code, headers, body @staticmethod - def _invalid_apig_response_keys(output): + def _invalid_apig_response_keys(output, event_type): allowable = {"statusCode", "body", "headers", "multiValueHeaders", "isBase64Encoded", "cookies"} + if event_type == Route.API: + allowable.add("base64Encoded") invalid_keys = output.keys() - allowable return invalid_keys diff --git a/tests/integration/deploy/test_deploy_command.py b/tests/integration/deploy/test_deploy_command.py index 834bcf6e38..3e4bd53f87 100644 --- a/tests/integration/deploy/test_deploy_command.py +++ b/tests/integration/deploy/test_deploy_command.py @@ -699,7 +699,7 @@ def test_deploy_with_invalid_config(self, template_file, config_file): @parameterized.expand([("aws-serverless-function.yaml", "samconfig-tags-list.toml")]) def test_deploy_with_valid_config_tags_list(self, template_file, config_file): stack_name = self._method_to_stack_name(self.id()) - self.stack_names.append(stack_name) + self.stacks.append({"name": stack_name}) template_path = self.test_data_path.joinpath(template_file) config_path = self.test_data_path.joinpath(config_file) @@ -718,7 +718,7 @@ def test_deploy_with_valid_config_tags_list(self, template_file, config_file): @parameterized.expand([("aws-serverless-function.yaml", "samconfig-tags-string.toml")]) def test_deploy_with_valid_config_tags_string(self, template_file, config_file): stack_name = self._method_to_stack_name(self.id()) - self.stack_names.append(stack_name) + self.stacks.append({"name": stack_name}) template_path = self.test_data_path.joinpath(template_file) config_path = self.test_data_path.joinpath(config_file) diff --git a/tests/integration/local/start_api/test_start_api.py b/tests/integration/local/start_api/test_start_api.py index e7e5ad59a1..0ddb8d5a31 100644 --- a/tests/integration/local/start_api/test_start_api.py +++ b/tests/integration/local/start_api/test_start_api.py @@ -1,3 +1,4 @@ +import base64 import uuid import random @@ -382,14 +383,14 @@ def test_valid_v2_lambda_integer_response(self): @pytest.mark.flaky(reruns=3) @pytest.mark.timeout(timeout=600, method="thread") - def test_invalid_v2_lambda_response(self): + def test_v2_lambda_response_skip_unexpected_fields(self): """ Patch Request to a path that was defined as ANY in SAM through AWS::Serverless::Function Events """ response = requests.get(self.url + "/invalidv2response", timeout=300) - self.assertEqual(response.status_code, 502) - self.assertEqual(response.json(), {"message": "Internal server error"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"hello": "world"}) @pytest.mark.flaky(reruns=3) @pytest.mark.timeout(timeout=600, method="thread") @@ -538,6 +539,48 @@ def test_binary_response(self): self.assertEqual(response.headers.get("Content-Type"), "image/gif") self.assertEqual(response.content, expected) + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_non_decoded_binary_response(self): + """ + Binary data is returned correctly + """ + expected = base64.b64encode(self.get_binary_data(self.binary_data_file)) + + response = requests.get(self.url + "/nondecodedbase64response", timeout=300) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get("Content-Type"), "image/gif") + self.assertEqual(response.content, expected) + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_decoded_binary_response_base64encoded_field(self): + """ + Binary data is returned correctly + """ + expected = self.get_binary_data(self.binary_data_file) + + response = requests.get(self.url + "/decodedbase64responsebas64encoded", timeout=300) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get("Content-Type"), "image/gif") + self.assertEqual(response.content, expected) + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_decoded_binary_response_base64encoded_field_is_priority(self): + """ + Binary data is returned correctly + """ + expected = base64.b64encode(self.get_binary_data(self.binary_data_file)) + + response = requests.get(self.url + "/decodedbase64responsebas64encodedpriority", timeout=300) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get("Content-Type"), "image/gif") + self.assertEqual(response.content, expected) + class TestStartApiWithSwaggerHttpApis(StartApiIntegBaseClass): template_path = "/testdata/start_api/swagger-template-http-api.yaml" diff --git a/tests/integration/testdata/start_api/binarydata.gif b/tests/integration/testdata/start_api/binarydata.gif index 855b404179..3f40c2073d 100644 Binary files a/tests/integration/testdata/start_api/binarydata.gif and b/tests/integration/testdata/start_api/binarydata.gif differ diff --git a/tests/integration/testdata/start_api/image_package_type/main.py b/tests/integration/testdata/start_api/image_package_type/main.py index 452d91fd12..eba4c12408 100644 --- a/tests/integration/testdata/start_api/image_package_type/main.py +++ b/tests/integration/testdata/start_api/image_package_type/main.py @@ -70,7 +70,7 @@ def invalid_hash_response(event, context): def base64_response(event, context): - gifImageBase64 = "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" # NOQA + gifImageBase64 = "R0lGODdhAQABAJEAAAAAAP///wAAAAAAACH5BAkAAAIALAAAAAABAAEAAAICRAEAOw==" # NOQA return { "statusCode": 200, diff --git a/tests/integration/testdata/start_api/main.py b/tests/integration/testdata/start_api/main.py index 452d91fd12..19dc6f51c2 100644 --- a/tests/integration/testdata/start_api/main.py +++ b/tests/integration/testdata/start_api/main.py @@ -2,6 +2,8 @@ import sys import time +GIF_IMAGE_BASE64 = "R0lGODdhAQABAJEAAAAAAP///wAAAAAAACH5BAkAAAIALAAAAAABAAEAAAICRAEAOw==" + def handler(event, context): return {"statusCode": 200, "body": json.dumps({"hello": "world"})} @@ -70,11 +72,41 @@ def invalid_hash_response(event, context): def base64_response(event, context): - gifImageBase64 = "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" # NOQA return { "statusCode": 200, - "body": gifImageBase64, + "body": GIF_IMAGE_BASE64, + "isBase64Encoded": True, + "headers": {"Content-Type": "image/gif"}, + } + + +def base64_with_False_isBase64Encoded_response(event, context): + + return { + "statusCode": 200, + "body": GIF_IMAGE_BASE64, + "isBase64Encoded": False, + "headers": {"Content-Type": "image/gif"}, + } + + +def base64_with_True_Base64Encoded_response(event, context): + + return { + "statusCode": 200, + "body": GIF_IMAGE_BASE64, + "base64Encoded": True, + "headers": {"Content-Type": "image/gif"}, + } + + +def base64_with_Base64Encoded_priority_response(event, context): + + return { + "statusCode": 200, + "body": GIF_IMAGE_BASE64, + "base64Encoded": False, "isBase64Encoded": True, "headers": {"Content-Type": "image/gif"}, } diff --git a/tests/integration/testdata/start_api/swagger-template.yaml b/tests/integration/testdata/start_api/swagger-template.yaml index 880115d2d7..3b29656f11 100644 --- a/tests/integration/testdata/start_api/swagger-template.yaml +++ b/tests/integration/testdata/start_api/swagger-template.yaml @@ -67,6 +67,30 @@ Resources: uri: Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${Base64ResponseFunction.Arn}/invocations + "/nondecodedbase64response": + get: + x-amazon-apigateway-integration: + httpMethod: POST + type: aws_proxy + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${Base64ResponseFunctionWithFalseIsBase64EncodedField.Arn}/invocations + + "/decodedbase64responsebas64encoded": + get: + x-amazon-apigateway-integration: + httpMethod: POST + type: aws_proxy + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${Base64ResponseFunctionWithTrueBase64EncodedField.Arn}/invocations + + "/decodedbase64responsebas64encodedpriority": + get: + x-amazon-apigateway-integration: + httpMethod: POST + type: aws_proxy + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${Base64ResponseFunctionWithBase64EncodedFieldPriority.Arn}/invocations + "/echobase64eventbody": post: x-amazon-apigateway-integration: @@ -124,6 +148,30 @@ Resources: CodeUri: . Timeout: 600 + Base64ResponseFunctionWithFalseIsBase64EncodedField: + Type: AWS::Serverless::Function + Properties: + Handler: main.base64_with_False_isBase64Encoded_response + Runtime: python3.6 + CodeUri: . + Timeout: 600 + + Base64ResponseFunctionWithTrueBase64EncodedField: + Type: AWS::Serverless::Function + Properties: + Handler: main.base64_with_True_Base64Encoded_response + Runtime: python3.6 + CodeUri: . + Timeout: 600 + + Base64ResponseFunctionWithBase64EncodedFieldPriority: + Type: AWS::Serverless::Function + Properties: + Handler: main.base64_with_Base64Encoded_priority_response + Runtime: python3.6 + CodeUri: . + Timeout: 600 + EchoBase64EventBodyFunction: Type: AWS::Serverless::Function Properties: diff --git a/tests/unit/lib/delete/test_cf_utils.py b/tests/unit/lib/delete/test_cf_utils.py index 9e80a00d4a..90d764a5c4 100644 --- a/tests/unit/lib/delete/test_cf_utils.py +++ b/tests/unit/lib/delete/test_cf_utils.py @@ -1,11 +1,23 @@ from unittest.mock import patch, MagicMock, ANY, call from unittest import TestCase + from samcli.commands.delete.exceptions import DeleteFailedError, FetchTemplateFailedError -from botocore.exceptions import ClientError, BotoCoreError +from botocore.exceptions import ClientError, BotoCoreError, WaiterError + from samcli.lib.delete.cf_utils import CfUtils +class MockDeleteWaiter: + def __init__(self, ex=None): + self.ex = ex + + def wait(self, StackName, WaiterConfig): + if self.ex: + raise self.ex + return + + class TestCfUtils(TestCase): def setUp(self): self.session = MagicMock() @@ -20,7 +32,7 @@ def test_cf_utils_has_no_stack(self): self.cf_utils._client.describe_stacks = MagicMock(return_value={"Stacks": []}) self.assertEqual(self.cf_utils.has_stack("test"), False) - def test_cf_utils_has_stack_exception_non_exsistent(self): + def test_cf_utils_has_stack_exception_non_existent(self): self.cf_utils._client.describe_stacks = MagicMock( side_effect=ClientError( error_response={"Error": {"Message": "Stack with id test does not exist"}}, @@ -84,3 +96,16 @@ def test_cf_utils_delete_stack_exception(self): self.cf_utils._client.delete_stack = MagicMock(side_effect=Exception()) with self.assertRaises(Exception): self.cf_utils.delete_stack("test") + + def test_cf_utils_wait_for_delete_exception(self): + self.cf_utils._client.get_waiter = MagicMock( + return_value=MockDeleteWaiter( + ex=WaiterError( + name="wait_for_delete", + reason="unit-test", + last_response={"Status": "Failed", "StatusReason": "It's a unit test"}, + ) + ) + ) + with self.assertRaises(DeleteFailedError): + self.cf_utils.wait_for_delete("test") diff --git a/tests/unit/lib/package/test_ecr_uploader.py b/tests/unit/lib/package/test_ecr_uploader.py index 91798d43f9..a66207efca 100644 --- a/tests/unit/lib/package/test_ecr_uploader.py +++ b/tests/unit/lib/package/test_ecr_uploader.py @@ -5,7 +5,13 @@ from docker.errors import APIError, BuildError from parameterized import parameterized -from samcli.commands.package.exceptions import DockerLoginFailedError, DockerPushFailedError, ECRAuthorizationError +from samcli.commands.package.exceptions import ( + DockerLoginFailedError, + DockerPushFailedError, + ECRAuthorizationError, + ImageNotFoundError, + DeleteArtifactFailedError, +) from samcli.lib.package.ecr_uploader import ECRUploader from samcli.lib.utils.stream_writer import StreamWriter @@ -23,6 +29,9 @@ def setUp(self): BuildError.__name__: {"reason": "mock_reason", "build_log": "mock_build_log"}, APIError.__name__: {"message": "mock message"}, } + self.image_uri = "900643008914.dkr.ecr.us-east-1.amazonaws.com/" + self.ecr_repo + ":" + self.tag + self.property_name = "AWS::Serverless::Function" + self.resource_id = "HelloWorldFunction" def test_ecr_uploader_init(self): ecr_uploader = ECRUploader( @@ -166,3 +175,39 @@ def test_upload_failure_while_streaming(self): ecr_uploader.login = MagicMock() with self.assertRaises(DockerPushFailedError): ecr_uploader.upload(image, resource_name="HelloWorldFunction") + + def test_delete_artifact_no_image_error(self): + ecr_uploader = ECRUploader( + docker_client=self.docker_client, + ecr_client=self.ecr_client, + ecr_repo=self.ecr_repo, + ecr_repo_multi=self.ecr_repo_multi, + tag=self.tag, + ) + ecr_uploader.ecr_client.batch_delete_image.return_value = { + "failures": [{"imageId": {"imageTag": self.tag}, "failureCode": "ImageNotFound"}] + } + + with self.assertRaises(ImageNotFoundError): + ecr_uploader.delete_artifact( + image_uri=self.image_uri, resource_id=self.resource_id, property_name=self.property_name + ) + + def test_delete_artifact_client_error(self): + ecr_uploader = ECRUploader( + docker_client=self.docker_client, + ecr_client=self.ecr_client, + ecr_repo=self.ecr_repo, + ecr_repo_multi=self.ecr_repo_multi, + tag=self.tag, + ) + ecr_uploader.ecr_client.batch_delete_image = MagicMock( + side_effect=ClientError( + error_response={"Error": {"Message": "mock client error"}}, operation_name="batch_delete_image" + ) + ) + + with self.assertRaises(DeleteArtifactFailedError): + ecr_uploader.delete_artifact( + image_uri=self.image_uri, resource_id=self.resource_id, property_name=self.property_name + ) diff --git a/tests/unit/local/apigw/test_local_apigw_service.py b/tests/unit/local/apigw/test_local_apigw_service.py index dc785936a5..a6ab380f7d 100644 --- a/tests/unit/local/apigw/test_local_apigw_service.py +++ b/tests/unit/local/apigw/test_local_apigw_service.py @@ -226,8 +226,11 @@ def test_request_handler_returns_process_stdout_when_making_response(self, lambd make_response_mock = Mock() request_mock.return_value = ("test", "test") self.api_service.service_response = make_response_mock + current_route = Mock() self.api_service._get_current_route = MagicMock() - self.api_service._get_current_route.methods = [] + self.api_service._get_current_route.return_value = current_route + current_route.methods = [] + current_route.event_type = Route.API self.api_service._construct_v_1_0_event = Mock() @@ -249,7 +252,7 @@ def test_request_handler_returns_process_stdout_when_making_response(self, lambd lambda_output_parser_mock.get_lambda_output.assert_called_with(ANY) # Make sure the parse method is called only on the returned response and not on the raw data from stdout - parse_output_mock.assert_called_with(lambda_response, ANY, ANY) + parse_output_mock.assert_called_with(lambda_response, ANY, ANY, Route.API) # Make sure the logs are written to stderr self.stderr.write.assert_called_with(lambda_logs) @@ -507,69 +510,105 @@ def test_merge_does_not_duplicate_values(self): class TestServiceParsingV1PayloadFormatLambdaOutput(TestCase): - def test_default_content_type_header_added_with_no_headers(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_default_content_type_header_added_with_no_headers(self, event_type): lambda_output = ( '{"statusCode": 200, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' '"isBase64Encoded": false}' ) (_, headers, _) = LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) self.assertIn("Content-Type", headers) self.assertEqual(headers["Content-Type"], "application/json") - def test_default_content_type_header_added_with_empty_headers(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_default_content_type_header_added_with_empty_headers(self, event_type): lambda_output = ( '{"statusCode": 200, "headers":{}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' '"isBase64Encoded": false}' ) (_, headers, _) = LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) self.assertIn("Content-Type", headers) self.assertEqual(headers["Content-Type"], "application/json") - def test_custom_content_type_header_is_not_modified(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_custom_content_type_header_is_not_modified(self, event_type): lambda_output = ( '{"statusCode": 200, "headers":{"Content-Type": "text/xml"}, "body": "{}", ' '"isBase64Encoded": false}' ) (_, headers, _) = LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) self.assertIn("Content-Type", headers) self.assertEqual(headers["Content-Type"], "text/xml") - def test_custom_content_type_multivalue_header_is_not_modified(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_custom_content_type_multivalue_header_is_not_modified(self, event_type): lambda_output = ( '{"statusCode": 200, "multiValueHeaders":{"Content-Type": ["text/xml"]}, "body": "{}", ' '"isBase64Encoded": false}' ) (_, headers, _) = LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) self.assertIn("Content-Type", headers) self.assertEqual(headers["Content-Type"], "text/xml") - def test_multivalue_headers(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_multivalue_headers(self, event_type): lambda_output = ( '{"statusCode": 200, "multiValueHeaders":{"X-Foo": ["bar", "42"]}, ' '"body": "{\\"message\\":\\"Hello from Lambda\\"}", "isBase64Encoded": false}' ) (_, headers, _) = LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) self.assertEqual(headers, Headers({"Content-Type": "application/json", "X-Foo": ["bar", "42"]})) - def test_single_and_multivalue_headers(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_single_and_multivalue_headers(self, event_type): lambda_output = ( '{"statusCode": 200, "headers":{"X-Foo": "foo", "X-Bar": "bar"}, ' '"multiValueHeaders":{"X-Foo": ["bar", "42"]}, ' @@ -577,7 +616,7 @@ def test_single_and_multivalue_headers(self): ) (_, headers, _) = LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) self.assertEqual( @@ -587,29 +626,54 @@ def test_single_and_multivalue_headers(self): def test_extra_values_raise(self): lambda_output = ( '{"statusCode": 200, "headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' - '"isBase64Encoded": false, "another_key": "some value"}' + '"isBase64Encoded": false, "base64Encoded": false, "another_key": "some value"}' ) with self.assertRaises(LambdaResponseParseException): LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=Route.API ) - def test_parse_returns_correct_tuple(self): + def test_extra_values_skipped_http_api(self): + lambda_output = ( + '{"statusCode": 200, "headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' + '"isBase64Encoded": false, "another_key": "some value"}' + ) + + (status_code, headers, body) = LocalApigwService._parse_v1_payload_format_lambda_output( + lambda_output, binary_types=[], flask_request=Mock(), event_type=Route.HTTP + ) + self.assertEqual(status_code, 200) + self.assertEqual(headers, Headers({"Content-Type": "application/json"})) + self.assertEqual(body, '{"message":"Hello from Lambda"}') + + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_parse_returns_correct_tuple(self, event_type): lambda_output = ( '{"statusCode": 200, "headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' '"isBase64Encoded": false}' ) (status_code, headers, body) = LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) self.assertEqual(status_code, 200) self.assertEqual(headers, Headers({"Content-Type": "application/json"})) self.assertEqual(body, '{"message":"Hello from Lambda"}') - def test_parse_raises_when_invalid_mimetype(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_parse_raises_when_invalid_mimetype(self, event_type): lambda_output = ( '{"statusCode": 200, "headers": {\\"Content-Type\\": \\"text\\"}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' '"isBase64Encoded": false}' @@ -617,11 +681,92 @@ def test_parse_raises_when_invalid_mimetype(self): with self.assertRaises(LambdaResponseParseException): LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) + @parameterized.expand( + [ + param("isBase64Encoded", True, True), + param("base64Encoded", True, True), + param("isBase64Encoded", False, False), + param("base64Encoded", False, False), + param("isBase64Encoded", "True", True), + param("base64Encoded", "True", True), + param("isBase64Encoded", "true", True), + param("base64Encoded", "true", True), + param("isBase64Encoded", "False", False), + param("base64Encoded", "False", False), + param("isBase64Encoded", "false", False), + param("base64Encoded", "false", False), + ] + ) @patch("samcli.local.apigw.local_apigw_service.LocalApigwService._should_base64_decode_body") - def test_parse_returns_decodes_base64_to_binary(self, should_decode_body_patch): + def test_parse_returns_decodes_base64_to_binary_for_rest_api( + self, encoded_field_name, encoded_response_value, encoded_parsed_value, should_decode_body_patch + ): + should_decode_body_patch.return_value = True + + binary_body = b"011000100110100101101110011000010111001001111001" # binary in binary + base64_body = base64.b64encode(binary_body).decode("utf-8") + lambda_output = { + "statusCode": 200, + "headers": {"Content-Type": "application/octet-stream"}, + "body": base64_body, + encoded_field_name: encoded_response_value, + } + + flask_request_mock = Mock() + (status_code, headers, body) = LocalApigwService._parse_v1_payload_format_lambda_output( + json.dumps(lambda_output), binary_types=["*/*"], flask_request=flask_request_mock, event_type=Route.API + ) + + should_decode_body_patch.assert_called_with( + ["*/*"], flask_request_mock, Headers({"Content-Type": "application/octet-stream"}), encoded_parsed_value + ) + + self.assertEqual(status_code, 200) + self.assertEqual(headers, Headers({"Content-Type": "application/octet-stream"})) + self.assertEqual(body, binary_body) + + @parameterized.expand( + [ + param("isBase64Encoded", 0), + param("base64Encoded", 0), + param("isBase64Encoded", 1), + param("base64Encoded", 1), + param("isBase64Encoded", -1), + param("base64Encoded", -1), + param("isBase64Encoded", 10), + param("base64Encoded", 10), + param("isBase64Encoded", "TRue"), + param("base64Encoded", "TRue"), + param("isBase64Encoded", "Any Value"), + param("base64Encoded", "Any Value"), + ] + ) + @patch("samcli.local.apigw.local_apigw_service.LocalApigwService._should_base64_decode_body") + def test_parse_raise_exception_invalide_base64_encoded( + self, encoded_field_name, encoded_response_value, should_decode_body_patch + ): + should_decode_body_patch.return_value = True + + binary_body = b"011000100110100101101110011000010111001001111001" # binary in binary + base64_body = base64.b64encode(binary_body).decode("utf-8") + lambda_output = { + "statusCode": 200, + "headers": {"Content-Type": "application/octet-stream"}, + "body": base64_body, + encoded_field_name: encoded_response_value, + } + + flask_request_mock = Mock() + with self.assertRaises(LambdaResponseParseException): + LocalApigwService._parse_v1_payload_format_lambda_output( + json.dumps(lambda_output), binary_types=["*/*"], flask_request=flask_request_mock, event_type=Route.API + ) + + @patch("samcli.local.apigw.local_apigw_service.LocalApigwService._should_base64_decode_body") + def test_parse_base64Encoded_field_is_priority(self, should_decode_body_patch): should_decode_body_patch.return_value = True binary_body = b"011000100110100101101110011000010111001001111001" # binary in binary @@ -631,17 +776,136 @@ def test_parse_returns_decodes_base64_to_binary(self, should_decode_body_patch): "headers": {"Content-Type": "application/octet-stream"}, "body": base64_body, "isBase64Encoded": False, + "base64Encoded": True, } + flask_request_mock = Mock() (status_code, headers, body) = LocalApigwService._parse_v1_payload_format_lambda_output( - json.dumps(lambda_output), binary_types=["*/*"], flask_request=Mock() + json.dumps(lambda_output), binary_types=["*/*"], flask_request=flask_request_mock, event_type=Route.API + ) + + should_decode_body_patch.assert_called_with( + ["*/*"], flask_request_mock, Headers({"Content-Type": "application/octet-stream"}), True ) self.assertEqual(status_code, 200) self.assertEqual(headers, Headers({"Content-Type": "application/octet-stream"})) self.assertEqual(body, binary_body) - def test_status_code_not_int(self): + @parameterized.expand( + [ + param(True, True), + param(False, False), + param("True", True), + param("true", True), + param("False", False), + param("false", False), + ] + ) + def test_parse_returns_decodes_base64_to_binary_for_http_api(self, encoded_response_value, encoded_parsed_value): + binary_body = b"011000100110100101101110011000010111001001111001" # binary in binary + base64_body = base64.b64encode(binary_body).decode("utf-8") + lambda_output = { + "statusCode": 200, + "headers": {"Content-Type": "application/octet-stream"}, + "body": base64_body, + "isBase64Encoded": encoded_response_value, + } + + (status_code, headers, body) = LocalApigwService._parse_v1_payload_format_lambda_output( + json.dumps(lambda_output), binary_types=["*/*"], flask_request=Mock(), event_type=Route.HTTP + ) + + self.assertEqual(status_code, 200) + self.assertEqual(headers, Headers({"Content-Type": "application/octet-stream"})) + self.assertEqual(body, binary_body if encoded_parsed_value else base64_body) + + @parameterized.expand( + [ + param(0), + param(1), + param(-1), + param(10), + param("TRue"), + param("Any Value"), + ] + ) + def test_parse_raise_exception_invalide_base64_encoded_for_http_api(self, encoded_response_value): + + binary_body = b"011000100110100101101110011000010111001001111001" # binary in binary + base64_body = base64.b64encode(binary_body).decode("utf-8") + lambda_output = { + "statusCode": 200, + "headers": {"Content-Type": "application/octet-stream"}, + "body": base64_body, + "isBase64Encoded": encoded_response_value, + } + + flask_request_mock = Mock() + with self.assertRaises(LambdaResponseParseException): + LocalApigwService._parse_v1_payload_format_lambda_output( + json.dumps(lambda_output), binary_types=["*/*"], flask_request=flask_request_mock, event_type=Route.API + ) + + @parameterized.expand( + [ + param(True), + param(False), + param("True"), + param("true"), + param("False"), + param("false"), + param(0), + param(1), + param(-1), + param(10), + param("TRue"), + param("Any Value"), + ] + ) + def test_parse_skip_base_64_encoded_field_http_api(self, encoded_response_value): + binary_body = b"011000100110100101101110011000010111001001111001" # binary in binary + base64_body = base64.b64encode(binary_body).decode("utf-8") + lambda_output = { + "statusCode": 200, + "headers": {"Content-Type": "application/octet-stream"}, + "body": base64_body, + "base64Encoded": encoded_response_value, + } + + (status_code, headers, body) = LocalApigwService._parse_v1_payload_format_lambda_output( + json.dumps(lambda_output), binary_types=["*/*"], flask_request=Mock(), event_type=Route.HTTP + ) + + self.assertEqual(status_code, 200) + self.assertEqual(headers, Headers({"Content-Type": "application/octet-stream"})) + self.assertEqual(body, base64_body) + + def test_parse_returns_does_not_decodes_base64_to_binary_for_http_api(self): + binary_body = b"011000100110100101101110011000010111001001111001" # binary in binary + base64_body = base64.b64encode(binary_body).decode("utf-8") + lambda_output = { + "statusCode": 200, + "headers": {"Content-Type": "application/octet-stream"}, + "body": base64_body, + "isBase64Encoded": False, + } + + (status_code, headers, body) = LocalApigwService._parse_v1_payload_format_lambda_output( + json.dumps(lambda_output), binary_types=["*/*"], flask_request=Mock(), event_type=Route.HTTP + ) + + self.assertEqual(status_code, 200) + self.assertEqual(headers, Headers({"Content-Type": "application/octet-stream"})) + self.assertEqual(body, base64_body) + + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_status_code_not_int(self, event_type): lambda_output = ( '{"statusCode": "str", "headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' '"isBase64Encoded": false}' @@ -649,21 +913,33 @@ def test_status_code_not_int(self): with self.assertRaises(LambdaResponseParseException): LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) - def test_status_code_int_str(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_status_code_int_str(self, event_type): lambda_output = ( '{"statusCode": "200", "headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' '"isBase64Encoded": false}' ) (status_code, _, _) = LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) self.assertEqual(status_code, 200) - def test_status_code_negative_int(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_status_code_negative_int(self, event_type): lambda_output = ( '{"statusCode": -1, "headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' '"isBase64Encoded": false}' @@ -671,10 +947,39 @@ def test_status_code_negative_int(self): with self.assertRaises(LambdaResponseParseException): LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) - def test_status_code_negative_int_str(self): + def test_status_code_is_none_http_api(self): + lambda_output = ( + '{"headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' '"isBase64Encoded": false}' + ) + + with self.assertRaises(LambdaResponseParseException): + LocalApigwService._parse_v1_payload_format_lambda_output( + lambda_output, binary_types=[], flask_request=Mock(), event_type=Route.HTTP + ) + + def test_status_code_is_none_rest_api(self): + lambda_output = ( + '{"headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' '"isBase64Encoded": false}' + ) + + (status_code, headers, body) = LocalApigwService._parse_v1_payload_format_lambda_output( + lambda_output, binary_types=[], flask_request=Mock(), event_type=Route.API + ) + + self.assertEqual(status_code, 200) + self.assertEqual(headers, Headers({"Content-Type": "application/json"})) + self.assertEqual(body, '{"message":"Hello from Lambda"}') + + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_status_code_negative_int_str(self, event_type): lambda_output = ( '{"statusCode": "-1", "headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' '"isBase64Encoded": false}' @@ -682,44 +987,68 @@ def test_status_code_negative_int_str(self): with self.assertRaises(LambdaResponseParseException): LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) - def test_lambda_output_list_not_dict(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_lambda_output_list_not_dict(self, event_type): lambda_output = "[]" with self.assertRaises(LambdaResponseParseException): LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) - def test_lambda_output_not_json_serializable(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_lambda_output_not_json_serializable(self, event_type): lambda_output = "some str" with self.assertRaises(LambdaResponseParseException): LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) - def test_properties_are_null(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_properties_are_null(self, event_type): lambda_output = '{"statusCode": 0, "headers": null, "body": null, ' '"isBase64Encoded": null}' (status_code, headers, body) = LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) self.assertEqual(status_code, 200) self.assertEqual(headers, Headers({"Content-Type": "application/json"})) self.assertEqual(body, None) - def test_cookies_is_not_raise(self): + @parameterized.expand( + [ + param(Route.API), + param(Route.HTTP), + ] + ) + def test_cookies_is_not_raise(self, event_type): lambda_output = ( '{"statusCode": 200, "headers":{}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' '"isBase64Encoded": false, "cookies":{}}' ) (_, headers, _) = LocalApigwService._parse_v1_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() + lambda_output, binary_types=[], flask_request=Mock(), event_type=event_type ) @@ -761,16 +1090,19 @@ def test_custom_content_type_header_is_not_modified(self): self.assertIn("Content-Type", headers) self.assertEqual(headers["Content-Type"], "text/xml") - def test_extra_values_raise(self): + def test_extra_values_skipped(self): lambda_output = ( '{"statusCode": 200, "headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' '"isBase64Encoded": false, "another_key": "some value"}' ) - with self.assertRaises(LambdaResponseParseException): - LocalApigwService._parse_v2_payload_format_lambda_output( - lambda_output, binary_types=[], flask_request=Mock() - ) + (status_code, headers, body) = LocalApigwService._parse_v2_payload_format_lambda_output( + lambda_output, binary_types=[], flask_request=Mock() + ) + + self.assertEqual(status_code, 200) + self.assertEqual(headers, Headers({"Content-Type": "application/json"})) + self.assertEqual(body, '{"message":"Hello from Lambda"}') def test_parse_returns_correct_tuple(self): lambda_output = ( @@ -797,10 +1129,7 @@ def test_parse_raises_when_invalid_mimetype(self): lambda_output, binary_types=[], flask_request=Mock() ) - @patch("samcli.local.apigw.local_apigw_service.LocalApigwService._should_base64_decode_body") - def test_parse_returns_decodes_base64_to_binary(self, should_decode_body_patch): - should_decode_body_patch.return_value = True - + def test_parse_returns_does_not_decodes_base64_to_binary(self): binary_body = b"011000100110100101101110011000010111001001111001" # binary in binary base64_body = base64.b64encode(binary_body).decode("utf-8") lambda_output = { @@ -814,6 +1143,24 @@ def test_parse_returns_decodes_base64_to_binary(self, should_decode_body_patch): json.dumps(lambda_output), binary_types=["*/*"], flask_request=Mock() ) + self.assertEqual(status_code, 200) + self.assertEqual(headers, Headers({"Content-Type": "application/octet-stream"})) + self.assertEqual(body, base64_body) + + def test_parse_returns_decodes_base64_to_binary(self): + binary_body = b"011000100110100101101110011000010111001001111001" # binary in binary + base64_body = base64.b64encode(binary_body).decode("utf-8") + lambda_output = { + "statusCode": 200, + "headers": {"Content-Type": "application/octet-stream"}, + "body": base64_body, + "isBase64Encoded": True, + } + + (status_code, headers, body) = LocalApigwService._parse_v2_payload_format_lambda_output( + json.dumps(lambda_output), binary_types=["*/*"], flask_request=Mock() + ) + self.assertEqual(status_code, 200) self.assertEqual(headers, Headers({"Content-Type": "application/octet-stream"})) self.assertEqual(body, binary_body)