diff --git a/aws-iot-dimension/.gitignore b/aws-iot-dimension/.gitignore new file mode 100644 index 0000000..3ee2477 --- /dev/null +++ b/aws-iot-dimension/.gitignore @@ -0,0 +1,24 @@ +# macOS +.DS_Store +._* + +# Maven outputs +.classpath + +# IntelliJ +*.iml +.idea +out.java +out/ +.settings +.project + +# auto-generated files +target/ +.hypothesis/ + +# our logs +rpdk.log + +# contains credentials +sam-tests/ diff --git a/aws-iot-dimension/.rpdk-config b/aws-iot-dimension/.rpdk-config new file mode 100644 index 0000000..28e6780 --- /dev/null +++ b/aws-iot-dimension/.rpdk-config @@ -0,0 +1,17 @@ +{ + "typeName": "AWS::IoT::Dimension", + "language": "java", + "runtime": "java8", + "entrypoint": "com.amazonaws.iot.dimension.HandlerWrapper::handleRequest", + "testEntrypoint": "com.amazonaws.iot.dimension.HandlerWrapper::testEntrypoint", + "settings": { + "namespace": [ + "com", + "amazonaws", + "iot", + "dimension" + ], + "codegen_template_path": "default", + "protocolVersion": "2.0.0" + } +} diff --git a/aws-iot-dimension/README.md b/aws-iot-dimension/README.md new file mode 100644 index 0000000..a07ad30 --- /dev/null +++ b/aws-iot-dimension/README.md @@ -0,0 +1,19 @@ +# AWS::IoT::Dimension + +## Running Contract Tests + +You can execute the following commands to run the tests. +You will need to have docker installed and running. + +```bash +# Package the code with Maven +mvn package +# Start SAM which will execute lambdas in Docker +sam local start-lambda + +# In a separate terminal, run the contract tests +cfn test --enforce-timeout 240 + +# Execute a single test +cfn test --enforce-timeout 240 -- -k +``` diff --git a/aws-iot-dimension/aws-iot-dimension.json b/aws-iot-dimension/aws-iot-dimension.json new file mode 100644 index 0000000..fe5691f --- /dev/null +++ b/aws-iot-dimension/aws-iot-dimension.json @@ -0,0 +1,120 @@ +{ + "typeName": "AWS::IoT::Dimension", + "description": "A dimension can be used to limit the scope of a metric used in a security profile for AWS IoT Device Defender.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-iot.git", + "definitions": { + "Tag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "properties": { + "Key": { + "type": "string", + "description": "The tag's key.", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "description": "The tag's value.", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "Value", + "Key" + ], + "additionalProperties": false + } + }, + "properties": { + "Name": { + "description": "A unique identifier for the dimension.", + "type": "string", + "pattern": "[a-zA-Z0-9:_-]+", + "minLength": 1, + "maxLength": 128 + }, + "Type": { + "description": "Specifies the type of the dimension.", + "type": "string", + "enum": [ + "TOPIC_FILTER" + ] + }, + "StringValues": { + "description": "Specifies the value or list of values for the dimension.", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "minItems": 1, + "maxItems": 5 + }, + "Tags": { + "description": "Metadata that can be used to manage the dimension.", + "type": "array", + "maxItems": 50, + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "Arn": { + "description": "The ARN (Amazon resource name) of the created dimension.", + "type": "string" + } + }, + "additionalProperties": false, + "primaryIdentifier": [ + "/properties/Name" + ], + "required": [ + "Type", + "StringValues" + ], + "createOnlyProperties": [ + "/properties/Name", + "/properties/Type" + ], + "readOnlyProperties": [ + "/properties/Arn" + ], + "handlers": { + "create": { + "permissions": [ + "iot:CreateDimension" + ] + }, + "read": { + "permissions": [ + "iot:DescribeDimension", + "iot:ListTagsForResource" + ] + }, + "update": { + "permissions": [ + "iot:UpdateDimension", + "iot:ListTagsForResource", + "iot:UntagResource", + "iot:TagResource" + ] + }, + "delete": { + "permissions": [ + "iot:DescribeDimension", + "iot:DeleteDimension" + ] + }, + "list": { + "permissions": [ + "iot:ListDimensions" + ] + } + } +} diff --git a/aws-iot-dimension/inputs/inputs_1_create.json b/aws-iot-dimension/inputs/inputs_1_create.json new file mode 100644 index 0000000..d2a0946 --- /dev/null +++ b/aws-iot-dimension/inputs/inputs_1_create.json @@ -0,0 +1,13 @@ +{ + "Name": "CfnContractTest", + "StringValues": [ + "device/+/auth" + ], + "Type": "TOPIC_FILTER", + "Tags": [ + { + "Key": "testTagKey", + "Value": "tagValue" + } + ] +} diff --git a/aws-iot-dimension/inputs/inputs_1_invalid.json b/aws-iot-dimension/inputs/inputs_1_invalid.json new file mode 100644 index 0000000..e861be3 --- /dev/null +++ b/aws-iot-dimension/inputs/inputs_1_invalid.json @@ -0,0 +1,8 @@ +{ + "Name": "CfnContractTest-Invalid", + "StringValues": [ + "device/+/auth" + ], + "Type": "TOPIC_FILTER", + "Arn": "Arn is a read-only property" +} diff --git a/aws-iot-dimension/inputs/inputs_1_update.json b/aws-iot-dimension/inputs/inputs_1_update.json new file mode 100644 index 0000000..2577744 --- /dev/null +++ b/aws-iot-dimension/inputs/inputs_1_update.json @@ -0,0 +1,13 @@ +{ + "Name": "CfnContractTest", + "StringValues": [ + "device/+/authV2" + ], + "Type": "TOPIC_FILTER", + "Tags": [ + { + "Key": "cfnTestTagKey2", + "Value": "tagValue2" + } + ] +} diff --git a/aws-iot-dimension/lombok.config b/aws-iot-dimension/lombok.config new file mode 100644 index 0000000..7a21e88 --- /dev/null +++ b/aws-iot-dimension/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/aws-iot-dimension/pom.xml b/aws-iot-dimension/pom.xml new file mode 100644 index 0000000..716cbff --- /dev/null +++ b/aws-iot-dimension/pom.xml @@ -0,0 +1,210 @@ + + + 4.0.0 + + com.amazonaws.iot.dimension + aws-iot-dimension-handler + aws-iot-dimension-handler + 1.0-SNAPSHOT + jar + + + 1.8 + 1.8 + UTF-8 + UTF-8 + + + + + + software.amazon.awssdk + iot + 2.15.7 + + + + software.amazon.cloudformation + aws-cloudformation-rpdk-java-plugin + [2.0.0,3.0.0) + + + + org.projectlombok + lombok + 1.18.4 + provided + + + + + org.assertj + assertj-core + 3.12.2 + test + + + + org.junit.jupiter + junit-jupiter + 5.5.0-M1 + test + + + + org.mockito + mockito-core + 2.26.0 + test + + + + org.mockito + mockito-junit-jupiter + 2.26.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all,-options,-processing + -Werror + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.3 + + false + + + + package + + shade + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + + generate + generate-sources + + exec + + + cfn + generate + ${project.basedir} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.0.0 + + + add-source + generate-sources + + add-source + + + + ${project.basedir}/target/generated-sources/rpdk + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 2.4 + + + maven-surefire-plugin + 3.0.0-M3 + + + org.jacoco + jacoco-maven-plugin + 0.8.4 + + + **/BaseConfiguration* + **/BaseHandler* + **/HandlerWrapper* + **/ResourceModel* + + + + + + prepare-agent + + + + report + test + + report + + + + jacoco-check + + check + + + + + PACKAGE + + + BRANCH + COVEREDRATIO + 0.8 + + + INSTRUCTION + COVEREDRATIO + 0.8 + + + + + + + + + + + + ${project.basedir} + + aws-iot-dimension.json + + + + + diff --git a/aws-iot-dimension/resource-role.yaml b/aws-iot-dimension/resource-role.yaml new file mode 100644 index 0000000..293691b --- /dev/null +++ b/aws-iot-dimension/resource-role.yaml @@ -0,0 +1,38 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: > + This CloudFormation template creates a role assumed by CloudFormation + during CRUDL operations to mutate resources on behalf of the customer. + +Resources: + ExecutionRole: + Type: AWS::IAM::Role + Properties: + MaxSessionDuration: 8400 + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: resources.cloudformation.amazonaws.com + Action: sts:AssumeRole + Path: "/" + Policies: + - PolicyName: ResourceTypePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - "iot:CreateDimension" + - "iot:DeleteDimension" + - "iot:DescribeDimension" + - "iot:ListDimensions" + - "iot:ListTagsForResource" + - "iot:TagResource" + - "iot:UntagResource" + - "iot:UpdateDimension" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/CallbackContext.java b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/CallbackContext.java new file mode 100644 index 0000000..762b482 --- /dev/null +++ b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/CallbackContext.java @@ -0,0 +1,10 @@ +package com.amazonaws.iot.dimension; + +import software.amazon.cloudformation.proxy.StdCallbackContext; + +@lombok.Getter +@lombok.Setter +@lombok.ToString +@lombok.EqualsAndHashCode(callSuper = true) +public class CallbackContext extends StdCallbackContext { +} diff --git a/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/Configuration.java b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/Configuration.java new file mode 100644 index 0000000..ae7a234 --- /dev/null +++ b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/Configuration.java @@ -0,0 +1,21 @@ +package com.amazonaws.iot.dimension; + +import java.util.Map; +import java.util.stream.Collectors; + +class Configuration extends BaseConfiguration { + + public Configuration() { + super("aws-iot-dimension.json"); + } + + @Override + public Map resourceDefinedTags(ResourceModel resourceModel) { + if (resourceModel.getTags() == null) { + return null; + } else { + return resourceModel.getTags().stream() + .collect(Collectors.toMap(Tag::getKey, Tag::getValue)); + } + } +} diff --git a/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/CreateHandler.java b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/CreateHandler.java new file mode 100644 index 0000000..681f1fd --- /dev/null +++ b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/CreateHandler.java @@ -0,0 +1,93 @@ +package com.amazonaws.iot.dimension; + +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.CreateDimensionRequest; +import software.amazon.awssdk.services.iot.model.CreateDimensionResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.cloudformation.resource.IdentifierUtils; + +public class CreateHandler extends BaseHandler { + + // Copied value from software.amazon.cloudformation.resource.IdentifierUtils + private static final int GENERATED_NAME_MAX_LENGTH = 40; + + private final IotClient iotClient; + + public CreateHandler() { + iotClient = IotClient.builder().build(); + } + + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + Logger logger) { + + CreateDimensionRequest createRequest = translateToCreateRequest(request); + + ResourceModel model = request.getDesiredResourceState(); + if (!StringUtils.isEmpty(model.getArn())) { + logger.log(String.format("Arn is read-only, but the caller passed %s.", model.getArn())); + // Note: this is necessary even though Arn is marked readOnly in the schema. + return ProgressEvent.failed(model, callbackContext, HandlerErrorCode.InvalidRequest, + "Arn is a read-only property and cannot be set."); + } + + CreateDimensionResponse createDimensionResponse; + try { + createDimensionResponse = proxy.injectCredentialsAndInvokeV2( + createRequest, iotClient::createDimension); + } catch (Exception e) { + return Translator.translateExceptionToErrorCode(model, e, logger); + } + + model.setArn(createDimensionResponse.arn()); + logger.log(String.format("Created %s.", createDimensionResponse.arn())); + + return ProgressEvent.defaultSuccessHandler(model); + } + + private CreateDimensionRequest translateToCreateRequest(ResourceHandlerRequest request) { + + ResourceModel model = request.getDesiredResourceState(); + + // Like most services, we don't require an explicit resource name in the template, + // and, if it's not provided, generate one based on the stack ID and logical ID. + if (StringUtils.isBlank(model.getName())) { + model.setName(IdentifierUtils.generateResourceIdentifier( + request.getStackId(), request.getLogicalResourceIdentifier(), + request.getClientRequestToken(), GENERATED_NAME_MAX_LENGTH)); + } + + // getDesiredResourceTags combines the model and stack-level tags. + // Reference: https://tinyurl.com/yyxtd7w6 + Map tags = request.getDesiredResourceTags(); + // TODO: uncomment this after we update the service to allow these (only from CFN) + // SystemTags are the default stack-level tags with aws:cloudformation prefix + // tags.putAll(request.getSystemTags()); + + // Note that the handlers act as pass-through in terms of input validation. + // We have some validations in the json model, but we delegate deeper checks to the service. + // If there's invalid input, we'll translate the service's InvalidRequestException, + // keeping the readable message. + return CreateDimensionRequest.builder() + .name(model.getName()) + .type(model.getType()) + .stringValues(model.getStringValues()) + .tags(Translator.translateTagsToSdk(tags)) + // Note: using CFN's token here. Motivation: suppose CFN calls this handler, create call succeeds, + // but the handler dies right before returning success. Then CFN retries. The retry will contain the + // same token. If we don't set the clientRequestToken, the Create + // API would throw AlreadyExistsException because the token would be different. + .clientRequestToken(request.getClientRequestToken()) + .build(); + } +} diff --git a/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/DeleteHandler.java b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/DeleteHandler.java new file mode 100644 index 0000000..26a093d --- /dev/null +++ b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/DeleteHandler.java @@ -0,0 +1,61 @@ +package com.amazonaws.iot.dimension; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.DeleteDimensionRequest; +import software.amazon.awssdk.services.iot.model.DescribeDimensionRequest; +import software.amazon.awssdk.services.iot.model.IotException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class DeleteHandler extends BaseHandler { + + private final IotClient iotClient; + + public DeleteHandler() { + iotClient = IotClient.builder().build(); + } + + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + Logger logger) { + + ResourceModel model = request.getDesiredResourceState(); + + // From https://docs.aws.amazon.com/cloudformation-cli/latest/userguide/resource-type-test-contract.html + // "A delete handler MUST return FAILED with a NotFound error code if the + // resource did not exist prior to the delete request." + // DeleteDimension API is idempotent, so we have to call Describe first. + DescribeDimensionRequest describeRequest = DescribeDimensionRequest.builder() + .name(model.getName()) + .build(); + try { + proxy.injectCredentialsAndInvokeV2(describeRequest, iotClient::describeDimension); + } catch (Exception e) { + // If the resource doesn't exist, DescribeDimension will throw NotFoundException, + // which we'll translate to NotFound Failure - that's all we need to do. + // CFN (the caller) will swallow this failure and the customer will see success. + return Translator.translateExceptionToErrorCode(model, e, logger); + } + logger.log(String.format("Called Describe for %s with name %s, accountId %s.", + ResourceModel.TYPE_NAME, model.getName(), request.getAwsAccountId())); + + DeleteDimensionRequest deleteRequest = DeleteDimensionRequest.builder() + .name(model.getName()) + .build(); + try { + proxy.injectCredentialsAndInvokeV2(deleteRequest, iotClient::deleteDimension); + } catch (Exception e) { + return Translator.translateExceptionToErrorCode(model, e, logger); + } + + logger.log(String.format("Deleted %s with name %s, accountId %s.", + ResourceModel.TYPE_NAME, model.getName(), request.getAwsAccountId())); + + return ProgressEvent.defaultSuccessHandler(null); + } +} diff --git a/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/HandlerUtils.java b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/HandlerUtils.java new file mode 100644 index 0000000..5705b69 --- /dev/null +++ b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/HandlerUtils.java @@ -0,0 +1,40 @@ +package com.amazonaws.iot.dimension; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.IotException; +import software.amazon.awssdk.services.iot.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.iot.model.ListTagsForResourceResponse; +import software.amazon.awssdk.services.iot.model.Tag; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; + +public class HandlerUtils { + + public static List listTags( + IotClient iotClient, + AmazonWebServicesClientProxy proxy, + String resourceArn, + Logger logger) { + + String nextToken = null; + List result = new ArrayList<>(); + do { + ListTagsForResourceRequest listTagsRequest = ListTagsForResourceRequest.builder() + .resourceArn(resourceArn) + .nextToken(nextToken) + .build(); + ListTagsForResourceResponse listTagsForResourceResponse = proxy.injectCredentialsAndInvokeV2( + listTagsRequest, iotClient::listTagsForResource); + result.addAll(listTagsForResourceResponse.tags()); + nextToken = listTagsForResourceResponse.nextToken(); + } while (nextToken != null); + + logger.log(String.format("Listed tags for %s.", resourceArn)); + return result; + } +} diff --git a/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/ListHandler.java b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/ListHandler.java new file mode 100644 index 0000000..164d0b1 --- /dev/null +++ b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/ListHandler.java @@ -0,0 +1,56 @@ +package com.amazonaws.iot.dimension; + +import java.util.List; +import java.util.stream.Collectors; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.IotException; +import software.amazon.awssdk.services.iot.model.ListDimensionsRequest; +import software.amazon.awssdk.services.iot.model.ListDimensionsResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ListHandler extends BaseHandler { + + private final IotClient iotClient; + + public ListHandler() { + iotClient = IotClient.builder().build(); + } + + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + Logger logger) { + + ListDimensionsRequest listRequest = ListDimensionsRequest.builder() + .nextToken(request.getNextToken()) + .build(); + + ListDimensionsResponse listDimensionsResponse; + try { + listDimensionsResponse = proxy.injectCredentialsAndInvokeV2( + listRequest, iotClient::listDimensions); + } catch (Exception e) { + return Translator.translateExceptionToErrorCode(request.getDesiredResourceState(), e, logger); + } + + List models = listDimensionsResponse.dimensionNames().stream() + .map(dimName -> ResourceModel.builder().name(dimName).build()) + .collect(Collectors.toList()); + + logger.log(String.format("Listed %s resources for accountId %s.", + ResourceModel.TYPE_NAME, request.getAwsAccountId())); + + return ProgressEvent.builder() + .resourceModels(models) + .nextToken(listDimensionsResponse.nextToken()) + .status(OperationStatus.SUCCESS) + .build(); + } +} diff --git a/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/ReadHandler.java b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/ReadHandler.java new file mode 100644 index 0000000..3754ea6 --- /dev/null +++ b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/ReadHandler.java @@ -0,0 +1,82 @@ +package com.amazonaws.iot.dimension; + + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.google.common.annotations.VisibleForTesting; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.DescribeDimensionRequest; +import software.amazon.awssdk.services.iot.model.DescribeDimensionResponse; +import software.amazon.awssdk.services.iot.model.IotException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ReadHandler extends BaseHandler { + + private final IotClient iotClient; + + public ReadHandler() { + iotClient = IotClient.builder().build(); + } + + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + Logger logger) { + + ResourceModel model = request.getDesiredResourceState(); + + DescribeDimensionRequest describeRequest = DescribeDimensionRequest.builder() + .name(model.getName()) + .build(); + + DescribeDimensionResponse describeDimensionResponse; + try { + describeDimensionResponse = proxy.injectCredentialsAndInvokeV2( + describeRequest, iotClient::describeDimension); + } catch (Exception e) { + return Translator.translateExceptionToErrorCode(model, e, logger); + } + + String dimensionArn = describeDimensionResponse.arn(); + logger.log(String.format("Called Describe for %s.", dimensionArn)); + + // Now call ListTagsForResource, because DescribeDimension doesn't provide the tags. + List iotTags; + try { + iotTags = listTags(proxy, dimensionArn, logger); + } catch (Exception e) { + return Translator.translateExceptionToErrorCode(model, e, logger); + } + logger.log(String.format("Called ListTags for %s.", dimensionArn)); + + Set responseTags = Translator.translateTagsToCfn(iotTags); + + logger.log(String.format("Successfully described %s.", dimensionArn)); + + return ProgressEvent.defaultSuccessHandler( + ResourceModel.builder() + .name(describeDimensionResponse.name()) + .type(describeDimensionResponse.type().name()) + .stringValues(new HashSet<>(describeDimensionResponse.stringValues())) + .arn(describeDimensionResponse.arn()) + .tags(responseTags) + .build()); + } + + // This facilitates mocking in the unit tests. + // It would be nicer to instead pass HandlerUtils (which we can mock) + // to the constructor, but the framework requires the constructor to have 0 args. + @VisibleForTesting + List listTags(AmazonWebServicesClientProxy proxy, + String resourceArn, Logger logger) { + return HandlerUtils.listTags(iotClient, proxy, resourceArn, logger); + } +} diff --git a/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/Translator.java b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/Translator.java new file mode 100644 index 0000000..9eb9cca --- /dev/null +++ b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/Translator.java @@ -0,0 +1,106 @@ +package com.amazonaws.iot.dimension; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import software.amazon.awssdk.services.iot.model.InternalFailureException; +import software.amazon.awssdk.services.iot.model.InvalidRequestException; +import software.amazon.awssdk.services.iot.model.LimitExceededException; +import software.amazon.awssdk.services.iot.model.ResourceAlreadyExistsException; +import software.amazon.awssdk.services.iot.model.ResourceNotFoundException; +import software.amazon.awssdk.services.iot.model.Tag; +import software.amazon.awssdk.services.iot.model.ThrottlingException; +import software.amazon.awssdk.services.iot.model.UnauthorizedException; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; + +public class Translator { + + static ProgressEvent translateExceptionToErrorCode( + ResourceModel model, Exception e, Logger logger) { + + HandlerErrorCode errorCode = translateExceptionToErrorCode(e, logger); + ProgressEvent progressEvent = + ProgressEvent.builder() + .resourceModel(model) + .status(OperationStatus.FAILED) + .errorCode(errorCode) + .build(); + if (errorCode != HandlerErrorCode.InternalFailure) { + progressEvent.setMessage(e.getMessage()); + } + return progressEvent; + } + + static HandlerErrorCode translateExceptionToErrorCode(Exception e, Logger logger) { + + logger.log(String.format("Translating exception \"%s\", stack trace: %s", + e.getMessage(), ExceptionUtils.getStackTrace(e))); + + // We're handling all the exceptions documented in API docs + // https://docs.aws.amazon.com/iot/latest/apireference/API_CreateSecurityProfile.html + // (+similar pages for other APIs) + // For Throttling and InternalFailure, we want CFN to retry, and it will do so based on the error code. + // Reference with Retriable/Terminal in comments for each: https://tinyurl.com/y378qdno + if (e instanceof ResourceAlreadyExistsException) { + // Note regarding idempotency: + // CreateSecurityProfile API allows tags. CFN attaches its own stack level tags with the request. If a + // SecurityProfile is created out of band and then the same request is sent via CFN, the API will throw + // AlreadyExists because the CFN request will contain the stack level tags. + // This behavior satisfies the CreateHandler contract. + return HandlerErrorCode.AlreadyExists; + } else if (e instanceof InvalidRequestException) { + return HandlerErrorCode.InvalidRequest; + } else if (e instanceof LimitExceededException) { + return HandlerErrorCode.ServiceLimitExceeded; + } else if (e instanceof UnauthorizedException) { + return HandlerErrorCode.AccessDenied; + } else if (e instanceof InternalFailureException) { + return HandlerErrorCode.InternalFailure; + } else if (e instanceof ThrottlingException) { + return HandlerErrorCode.Throttling; + } else if (e instanceof ResourceNotFoundException) { + return HandlerErrorCode.NotFound; + } else { + logger.log(String.format("Unexpected exception \"%s\", stack trace: %s", + e.getMessage(), ExceptionUtils.getStackTrace(e))); + // Any other exception at this point is unexpected. + return HandlerErrorCode.InternalFailure; + } + } + + static Set translateTagsToSdk(Map tags) { + + if (tags == null) { + return Collections.emptySet(); + } + + return tags.keySet().stream() + .map(key -> Tag.builder() + .key(key) + .value(tags.get(key)) + .build()) + .collect(Collectors.toSet()); + } + + static Set translateTagsToCfn( + List tags) { + + if (tags == null) { + return Collections.emptySet(); + } + + return tags.stream() + .map(tag -> com.amazonaws.iot.dimension.Tag.builder() + .key(tag.key()) + .value(tag.value()) + .build()) + .collect(Collectors.toSet()); + } +} diff --git a/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/UpdateHandler.java b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/UpdateHandler.java new file mode 100644 index 0000000..ba29f85 --- /dev/null +++ b/aws-iot-dimension/src/main/java/com/amazonaws/iot/dimension/UpdateHandler.java @@ -0,0 +1,133 @@ +package com.amazonaws.iot.dimension; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.common.annotations.VisibleForTesting; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.Tag; +import software.amazon.awssdk.services.iot.model.TagResourceRequest; +import software.amazon.awssdk.services.iot.model.UntagResourceRequest; +import software.amazon.awssdk.services.iot.model.UpdateDimensionRequest; +import software.amazon.awssdk.services.iot.model.UpdateDimensionResponse; +import software.amazon.awssdk.utils.StringUtils; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class UpdateHandler extends BaseHandler { + + private final IotClient iotClient; + + public UpdateHandler() { + iotClient = IotClient.builder().build(); + } + + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + Logger logger) { + + ResourceModel desiredModel = request.getDesiredResourceState(); + String desiredArn = desiredModel.getArn(); + if (!StringUtils.isEmpty(desiredArn)) { + logger.log("Arn cannot be updated, caller tried setting it to " + desiredArn); + return ProgressEvent.failed(desiredModel, callbackContext, HandlerErrorCode.InvalidRequest, + "Arn cannot be updated."); + } + + UpdateDimensionRequest updateDimensionRequest = UpdateDimensionRequest.builder() + .name(desiredModel.getName()) + .stringValues(desiredModel.getStringValues()) + .build(); + + UpdateDimensionResponse updateDimensionResponse; + try { + updateDimensionResponse = + proxy.injectCredentialsAndInvokeV2(updateDimensionRequest, iotClient::updateDimension); + } catch (Exception e) { + return Translator.translateExceptionToErrorCode(desiredModel, e, logger); + } + String resourceArn = updateDimensionResponse.arn(); + logger.log(String.format("Called UpdateDimension for %s.", resourceArn)); + + // For an exiting resource, we have to update via TagResource API, UpdateDimension API doesn't take tags. + try { + updateTags(proxy, request, resourceArn, logger); + } catch (Exception e) { + return Translator.translateExceptionToErrorCode(desiredModel, e, logger); + } + + logger.log(String.format("Successfully updated %s.", resourceArn)); + + desiredModel.setArn(resourceArn); + return ProgressEvent.defaultSuccessHandler(desiredModel); + } + + @VisibleForTesting + void updateTags(AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + String resourceArn, + Logger logger) { + + // Note: we're intentionally getting currentTags by calling ListTags rather than getting + // the previous state from CFN. This is in order to overwrite out-of-band changes. + // For example, if we used request.getPreviousResourceTags instead of ListTags, if a user added a new tag + // via TagResource and didn't add it to the template, we wouldn't know about it and wouldn't untag it. + // Yet we should, otherwise the resource wouldn't equate the template. + Set currentTags = listTags(proxy, resourceArn, logger); + + // getDesiredResourceTags includes model+stack-level tags, reference: https://tinyurl.com/y2p8medk + Set desiredTags = Translator.translateTagsToSdk(request.getDesiredResourceTags()); + // TODO: uncomment this after we update the service to allow these (only from CFN) + // SystemTags are the default stack-level tags with aws:cloudformation prefix + // desiredTags.addAll(Translator.translateTagsToSdk(request.getSystemTags())); + + Set desiredTagKeys = desiredTags.stream() + .map(Tag::key) + .collect(Collectors.toSet()); + + Set tagKeysToDetach = currentTags.stream() + .filter(tag -> !desiredTagKeys.contains(tag.key())) + .map(Tag::key) + .collect(Collectors.toSet()); + Set tagsToAttach = desiredTags.stream() + .filter(tag -> !currentTags.contains(tag)) + .collect(Collectors.toSet()); + + if (!tagsToAttach.isEmpty()) { + TagResourceRequest tagResourceRequest = TagResourceRequest.builder() + .resourceArn(resourceArn) + .tags(tagsToAttach) + .build(); + proxy.injectCredentialsAndInvokeV2(tagResourceRequest, iotClient::tagResource); + logger.log(String.format("Called TagResource for %s.", resourceArn)); + } + + if (!tagKeysToDetach.isEmpty()) { + UntagResourceRequest untagResourceRequest = UntagResourceRequest.builder() + .resourceArn(resourceArn) + .tagKeys(tagKeysToDetach) + .build(); + proxy.injectCredentialsAndInvokeV2(untagResourceRequest, iotClient::untagResource); + logger.log(String.format("Called UntagResource for %s.", resourceArn)); + } + } + + // This facilitates mocking in the unit tests. + // It would be nicer to instead pass HandlerUtils (which we can mock) + // to the constructor, but the framework requires the constructor to have 0 args. + @VisibleForTesting + Set listTags(AmazonWebServicesClientProxy proxy, + String resourceArn, Logger logger) { + List tags = HandlerUtils.listTags(iotClient, proxy, resourceArn, logger); + return new HashSet<>(tags); + } +} diff --git a/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/ConfigurationTest.java b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/ConfigurationTest.java new file mode 100644 index 0000000..8e912fe --- /dev/null +++ b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/ConfigurationTest.java @@ -0,0 +1,47 @@ +package com.amazonaws.iot.dimension; + +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_NAME; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class ConfigurationTest { + + private Configuration configuration; + + @BeforeEach + public void setup() { + configuration = new Configuration(); + } + + @Test + public void resourceDefinedTags_ModelWithTags_VerifyTranslation() { + + Set modelTags = ImmutableSet.of( + Tag.builder() + .key("resourceTagKey") + .value("resourceTagValue") + .build(), + Tag.builder() + .key("resourceTagKey2") + .value("resourceTagValue2") + .build()); + ResourceModel model = ResourceModel.builder() + .name(DIMENSION_NAME) + .tags(modelTags) + .build(); + + Map result = configuration.resourceDefinedTags(model); + + assertThat(result).isEqualTo(ImmutableMap.of( + "resourceTagKey", "resourceTagValue", + "resourceTagKey2", "resourceTagValue2")); + } +} diff --git a/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/CreateHandlerTest.java b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/CreateHandlerTest.java new file mode 100644 index 0000000..5c78109 --- /dev/null +++ b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/CreateHandlerTest.java @@ -0,0 +1,171 @@ +package com.amazonaws.iot.dimension; + +import static com.amazonaws.iot.dimension.TestConstants.CLIENT_REQUEST_TOKEN; +import static com.amazonaws.iot.dimension.TestConstants.DESIRED_TAGS; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_ARN; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_NAME; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_TYPE; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_VALUE_CFN; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_VALUE_IOT; +import static com.amazonaws.iot.dimension.TestConstants.MODEL_TAGS; +import static com.amazonaws.iot.dimension.TestConstants.SDK_MODEL_TAG; +import static com.amazonaws.iot.dimension.TestConstants.SDK_SYSTEM_TAG; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.iot.model.CreateDimensionRequest; +import software.amazon.awssdk.services.iot.model.CreateDimensionResponse; +import software.amazon.awssdk.services.iot.model.DimensionType; +import software.amazon.awssdk.services.iot.model.ResourceAlreadyExistsException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +public class CreateHandlerTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + private CreateHandler handler; + + @BeforeEach + public void setup() { + MockitoAnnotations.initMocks(this); + handler = new CreateHandler(); + } + + @Test + public void handleRequest_HappyCase_VerifyRequestResponse() { + + ResourceModel model = ResourceModel.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(DIMENSION_VALUE_CFN) + .tags(MODEL_TAGS) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .logicalResourceIdentifier("TestDimensionIdentifier") + .clientRequestToken(CLIENT_REQUEST_TOKEN) + .desiredResourceTags(DESIRED_TAGS) + .build(); + + CreateDimensionRequest expectedRequest = CreateDimensionRequest.builder() + .name(DIMENSION_NAME) + .type(DimensionType.TOPIC_FILTER) + .stringValues(DIMENSION_VALUE_IOT) + .clientRequestToken(CLIENT_REQUEST_TOKEN) + .tags(SDK_MODEL_TAG, SDK_SYSTEM_TAG) + .build(); + CreateDimensionResponse createApiResponse = CreateDimensionResponse.builder() + .name(DIMENSION_NAME) + .arn(DIMENSION_ARN) + .build(); + when(proxy.injectCredentialsAndInvokeV2(eq(expectedRequest), any())) + .thenReturn(createApiResponse); + + ProgressEvent response + = handler.handleRequest(proxy, request, null, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + ResourceModel expectedModel = ResourceModel.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(DIMENSION_VALUE_CFN) + .tags(MODEL_TAGS) + .arn(DIMENSION_ARN) + .build(); + assertThat(response.getResourceModel()).isEqualTo(expectedModel); + } + + @Test + public void handleRequest_ProxyThrowsAlreadyExists_VerifyTranslation() { + + ResourceModel model = ResourceModel.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(DIMENSION_VALUE_CFN) + .tags(MODEL_TAGS) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .logicalResourceIdentifier("TestDimensionIdentifier") + .clientRequestToken(CLIENT_REQUEST_TOKEN) + .desiredResourceTags(DESIRED_TAGS) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(ResourceAlreadyExistsException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.AlreadyExists); + } + + @Test + public void handleRequest_NoName_GeneratedByHandler() { + + ResourceModel model = ResourceModel.builder() + .type(DIMENSION_TYPE) + .stringValues(DIMENSION_VALUE_CFN) + // Handler ignores these, uses desiredResourceTags which contain system tags + .tags(MODEL_TAGS) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .logicalResourceIdentifier("MyResourceName") + .clientRequestToken("MyToken") + .desiredResourceTags(DESIRED_TAGS) + .stackId("arn:aws:cloudformation:us-east-1:123456789012:stack/my-stack-name/" + + "084c0bd1-082b-11eb-afdc-0a2fadfa68a5") + .build(); + + CreateDimensionResponse createApiResponse = CreateDimensionResponse.builder() + .name(DIMENSION_NAME) + .arn(DIMENSION_ARN) + .build(); + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(createApiResponse); + + handler.handleRequest(proxy, request, null, logger); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CreateDimensionRequest.class); + verify(proxy).injectCredentialsAndInvokeV2(requestCaptor.capture(), any()); + + CreateDimensionRequest submittedRequest = requestCaptor.getValue(); + // Can't easily check the randomly generated name. Just making sure it contains part of + // the logical identifier and the stack name, and some more random characters + assertThat(submittedRequest.name()).contains("my-stack"); + assertThat(submittedRequest.name()).contains("MyRes"); + assertThat(submittedRequest.name().length() > 20).isTrue(); + } + + // TODO: test system tags when the src code is ready +} diff --git a/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/DeleteHandlerTest.java b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/DeleteHandlerTest.java new file mode 100644 index 0000000..7c7d1b2 --- /dev/null +++ b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/DeleteHandlerTest.java @@ -0,0 +1,96 @@ +package com.amazonaws.iot.dimension; + +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_NAME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.iot.model.DeleteDimensionRequest; +import software.amazon.awssdk.services.iot.model.DescribeDimensionRequest; +import software.amazon.awssdk.services.iot.model.IotRequest; +import software.amazon.awssdk.services.iot.model.ResourceNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +public class DeleteHandlerTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + private DeleteHandler handler; + + @BeforeEach + public void setup() { + MockitoAnnotations.initMocks(this); + handler = new DeleteHandler(); + } + + @Test + public void handleRequest_HappyCase_VerifyRequest() { + + ResourceModel model = ResourceModel.builder() + .name(DIMENSION_NAME) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .build(); + + ProgressEvent response + = handler.handleRequest(proxy, request, null, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(DeleteDimensionRequest.class); + verify(proxy, times(2)).injectCredentialsAndInvokeV2(requestCaptor.capture(), any()); + + DescribeDimensionRequest describeRequest = (DescribeDimensionRequest) requestCaptor.getAllValues().get(0); + assertThat(describeRequest.name()).isEqualTo(DIMENSION_NAME); + + DeleteDimensionRequest deleteRequest = (DeleteDimensionRequest) requestCaptor.getAllValues().get(1); + assertThat(deleteRequest.name()).isEqualTo(DIMENSION_NAME); + } + + @Test + public void handleRequest_DescribeThrowsRNFE_VerifyTranslation() { + + ResourceModel model = ResourceModel.builder() + .name(DIMENSION_NAME) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(ResourceNotFoundException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.NotFound); + } +} diff --git a/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/HandlerUtilsTest.java b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/HandlerUtilsTest.java new file mode 100644 index 0000000..ebb5256 --- /dev/null +++ b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/HandlerUtilsTest.java @@ -0,0 +1,68 @@ +package com.amazonaws.iot.dimension; + +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_ARN; +import static com.amazonaws.iot.dimension.TestConstants.SDK_MODEL_TAG; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.iot.model.ListTagsForResourceResponse; +import software.amazon.awssdk.services.iot.model.Tag; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; + +public class HandlerUtilsTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + private IotClient iotClient; + + @BeforeEach + public void setup() { + proxy = mock(AmazonWebServicesClientProxy.class); + logger = mock(Logger.class); + iotClient = IotClient.builder().build(); + } + + @Test + public void listTags_WithNextToken_VerifyPagination() { + + ListTagsForResourceRequest expectedRequest1 = ListTagsForResourceRequest.builder() + .resourceArn(DIMENSION_ARN) + .build(); + ListTagsForResourceResponse listTagsForResourceResponse1 = ListTagsForResourceResponse.builder() + .tags(SDK_MODEL_TAG) + .nextToken("testToken") + .build(); + when(proxy.injectCredentialsAndInvokeV2(eq(expectedRequest1), any())) + .thenReturn(listTagsForResourceResponse1); + + ListTagsForResourceRequest expectedRequest2 = ListTagsForResourceRequest.builder() + .resourceArn(DIMENSION_ARN) + .nextToken("testToken") + .build(); + software.amazon.awssdk.services.iot.model.Tag tag2 = SDK_MODEL_TAG.toBuilder().key("key2").build(); + ListTagsForResourceResponse listTagsForResourceResponse2 = ListTagsForResourceResponse.builder() + .tags(tag2) + .build(); + when(proxy.injectCredentialsAndInvokeV2(eq(expectedRequest2), any())) + .thenReturn(listTagsForResourceResponse2); + + List currentTags = HandlerUtils.listTags(iotClient, proxy, DIMENSION_ARN, logger); + assertThat(currentTags).isEqualTo(Arrays.asList(SDK_MODEL_TAG, tag2)); + } +} diff --git a/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/ListHandlerTest.java b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/ListHandlerTest.java new file mode 100644 index 0000000..646bb70 --- /dev/null +++ b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/ListHandlerTest.java @@ -0,0 +1,97 @@ +package com.amazonaws.iot.dimension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.iot.model.ListDimensionsRequest; +import software.amazon.awssdk.services.iot.model.ListDimensionsResponse; +import software.amazon.awssdk.services.iot.model.UnauthorizedException; +import software.amazon.cloudformation.exceptions.CfnAccessDeniedException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +public class ListHandlerTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + private ListHandler handler; + + @BeforeEach + public void setup() { + MockitoAnnotations.initMocks(this); + handler = new ListHandler(); + } + + @Test + public void handleRequest_HappyCase_VerifyRequestResponse() { + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .nextToken("nextToken1") + .build(); + + ListDimensionsRequest expectedRequest = ListDimensionsRequest.builder() + .nextToken(request.getNextToken()) + .build(); + + ListDimensionsResponse listResponse = ListDimensionsResponse.builder() + .dimensionNames("dimension1", "dimension2") + .nextToken("nextToken2") + .build(); + + when(proxy.injectCredentialsAndInvokeV2(eq(expectedRequest), any())) + .thenReturn(listResponse); + + ProgressEvent response = + handler.handleRequest(proxy, request, null, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + assertThat(response.getNextToken()).isEqualTo("nextToken2"); + List expectedModels = Arrays.asList( + ResourceModel.builder().name("dimension1").build(), + ResourceModel.builder().name("dimension2").build()); + assertThat(response.getResourceModels()).isEqualTo(expectedModels); + } + + @Test + public void handleRequest_ThrowUnauthorized_VerifyTranslation() { + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .nextToken("nextToken1") + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(UnauthorizedException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.AccessDenied); + } +} diff --git a/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/ReadHandlerTest.java b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/ReadHandlerTest.java new file mode 100644 index 0000000..d182896 --- /dev/null +++ b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/ReadHandlerTest.java @@ -0,0 +1,109 @@ +package com.amazonaws.iot.dimension; + +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_ARN; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_NAME; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_TYPE; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_VALUE_CFN; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_VALUE_IOT; +import static com.amazonaws.iot.dimension.TestConstants.MODEL_TAGS; +import static com.amazonaws.iot.dimension.TestConstants.SDK_MODEL_TAG; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.iot.model.DescribeDimensionRequest; +import software.amazon.awssdk.services.iot.model.DescribeDimensionResponse; +import software.amazon.awssdk.services.iot.model.ThrottlingException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +public class ReadHandlerTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + @Spy + private ReadHandler handler; + + @Test + public void handleRequest_HappyCase_VerifyRequestResponse() { + + ResourceModel model = ResourceModel.builder() + .name(DIMENSION_NAME) + .build(); + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .build(); + + DescribeDimensionRequest expectedDescribeRequest = DescribeDimensionRequest.builder() + .name(DIMENSION_NAME) + .build(); + DescribeDimensionResponse describeResponse = DescribeDimensionResponse.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(DIMENSION_VALUE_IOT) + .arn(DIMENSION_ARN) + .build(); + when(proxy.injectCredentialsAndInvokeV2(eq(expectedDescribeRequest), any())) + .thenReturn(describeResponse); + + doReturn(Collections.singletonList(SDK_MODEL_TAG)) + .when(handler) + .listTags(proxy, DIMENSION_ARN, logger); + + ProgressEvent response + = handler.handleRequest(proxy, request, null, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + ResourceModel expectedModel = ResourceModel.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(DIMENSION_VALUE_CFN) + .arn(DIMENSION_ARN) + .tags(MODEL_TAGS) + .build(); + assertThat(response.getResourceModel()).isEqualTo(expectedModel); + } + + @Test + public void handleRequest_ThrowThrottling_VerifyTranslation() { + + ResourceModel model = ResourceModel.builder() + .name(DIMENSION_NAME) + .build(); + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(ThrottlingException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.Throttling); + } +} diff --git a/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/TestConstants.java b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/TestConstants.java new file mode 100644 index 0000000..72a9faa --- /dev/null +++ b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/TestConstants.java @@ -0,0 +1,38 @@ +package com.amazonaws.iot.dimension; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; + +public class TestConstants { + + protected static final String DIMENSION_NAME = "TestDimensionName"; + protected static final String DIMENSION_TYPE = "TOPIC_FILTER"; + protected static final List DIMENSION_VALUE_IOT = Arrays.asList("value1", "value2"); + protected static final Set DIMENSION_VALUE_CFN = ImmutableSet.of("value1", "value2"); + protected static final String CLIENT_REQUEST_TOKEN = "b99b5ee6"; + protected static final String DIMENSION_ARN = "arn:aws:iot:us-east-1:123456789012:dimension/TestDimensionName"; + + protected static final Set MODEL_TAGS = ImmutableSet.of( + Tag.builder() + .key("resourceTagKey") + .value("resourceTagValue") + .build()); + protected static final Map DESIRED_TAGS = ImmutableMap.of( + "resourceTagKey", "resourceTagValue", + "aws:cloudformation:stack-name", "UnitTestStack"); + protected static final software.amazon.awssdk.services.iot.model.Tag SDK_MODEL_TAG = + software.amazon.awssdk.services.iot.model.Tag.builder() + .key("resourceTagKey") + .value("resourceTagValue") + .build(); + protected static final software.amazon.awssdk.services.iot.model.Tag SDK_SYSTEM_TAG = + software.amazon.awssdk.services.iot.model.Tag.builder() + .key("aws:cloudformation:stack-name") + .value("UnitTestStack") + .build(); +} diff --git a/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/TranslatorTest.java b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/TranslatorTest.java new file mode 100644 index 0000000..1e72363 --- /dev/null +++ b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/TranslatorTest.java @@ -0,0 +1,37 @@ +package com.amazonaws.iot.dimension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.iot.model.IndexNotReadyException; +import software.amazon.awssdk.services.iot.model.LimitExceededException; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; + +public class TranslatorTest { + + @Test + public void translateIotExceptionToCfn_LimitExceeded_Translated() { + HandlerErrorCode result = Translator.translateExceptionToErrorCode( + LimitExceededException.builder().build(), mock(Logger.class)); + assertThat(result).isEqualTo(HandlerErrorCode.ServiceLimitExceeded); + } + + @Test + public void translateIotExceptionToCfn_UnexpectedException_Rethrown() { + HandlerErrorCode result = Translator.translateExceptionToErrorCode( + IndexNotReadyException.builder().build(), mock(Logger.class)); + assertThat(result).isEqualTo(HandlerErrorCode.InternalFailure); + } + + @Test + void translateTagsToSdk_InputNull_ReturnsEmpty() { + assertThat(Translator.translateTagsToSdk(null)).isEmpty(); + } + + @Test + void translateTagsToCfn_InputNull_ReturnsEmpty() { + assertThat(Translator.translateTagsToCfn(null)).isEmpty(); + } +} diff --git a/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/UpdateHandlerTest.java b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/UpdateHandlerTest.java new file mode 100644 index 0000000..ca07c3f --- /dev/null +++ b/aws-iot-dimension/src/test/java/com/amazonaws/iot/dimension/UpdateHandlerTest.java @@ -0,0 +1,290 @@ +package com.amazonaws.iot.dimension; + +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_ARN; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_NAME; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_TYPE; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_VALUE_CFN; +import static com.amazonaws.iot.dimension.TestConstants.DIMENSION_VALUE_IOT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.iot.model.InternalFailureException; +import software.amazon.awssdk.services.iot.model.InvalidRequestException; +import software.amazon.awssdk.services.iot.model.IotRequest; +import software.amazon.awssdk.services.iot.model.ResourceNotFoundException; +import software.amazon.awssdk.services.iot.model.TagResourceRequest; +import software.amazon.awssdk.services.iot.model.UntagResourceRequest; +import software.amazon.awssdk.services.iot.model.UpdateDimensionRequest; +import software.amazon.awssdk.services.iot.model.UpdateDimensionResponse; +import software.amazon.cloudformation.exceptions.CfnInternalFailureException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +public class UpdateHandlerTest { + + protected static final software.amazon.awssdk.services.iot.model.Tag PREVIOUS_SDK_RESOURCE_TAG = + software.amazon.awssdk.services.iot.model.Tag.builder() + .key("PreviousTagKey") + .value("PreviousTagValue") + .build(); + protected static final software.amazon.awssdk.services.iot.model.Tag DESIRED_SDK_RESOURCE_TAG = + software.amazon.awssdk.services.iot.model.Tag.builder() + .key("DesiredTagKey") + .value("DesiredTagValue") + .build(); + protected static final Set PREVIOUS_DIMENSION_VALUE = ImmutableSet.of("previousValue"); + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + @Spy + private UpdateHandler handler; + + @Test + public void handleRequest_BothValueAndTagsAreUpdated_VerifyRequests() { + + ResourceModel previousModel = ResourceModel.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(PREVIOUS_DIMENSION_VALUE) + .arn(DIMENSION_ARN) + .build(); + ResourceModel desiredModel = ResourceModel.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(DIMENSION_VALUE_CFN) + .build(); + Map desiredTags = ImmutableMap.of("DesiredTagKey", "DesiredTagValue"); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .previousResourceTags(ImmutableMap.of("doesn't", "matter")) + .desiredResourceState(desiredModel) + .desiredResourceTags(desiredTags) + .build(); + + doReturn(Collections.singleton(PREVIOUS_SDK_RESOURCE_TAG)) + .when(handler) + .listTags(proxy, DIMENSION_ARN, logger); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(UpdateDimensionResponse.builder().arn(DIMENSION_ARN).build()); + + ProgressEvent response + = handler.handleRequest(proxy, request, null, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + desiredModel.setArn(DIMENSION_ARN); + assertThat(response.getResourceModel()).isEqualTo(desiredModel); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IotRequest.class); + verify(proxy, times(3)).injectCredentialsAndInvokeV2(requestCaptor.capture(), any()); + List submittedIotRequests = requestCaptor.getAllValues(); + + UpdateDimensionRequest submittedUpdateRequest = (UpdateDimensionRequest) submittedIotRequests.get(0); + assertThat(submittedUpdateRequest.name()).isEqualTo(DIMENSION_NAME); + assertThat(submittedUpdateRequest.stringValues()).isEqualTo(DIMENSION_VALUE_IOT); + + TagResourceRequest submittedTagRequest = (TagResourceRequest) submittedIotRequests.get(1); + assertThat(submittedTagRequest.tags()).isEqualTo(Collections.singletonList(DESIRED_SDK_RESOURCE_TAG)); + assertThat(submittedTagRequest.resourceArn()).isEqualTo(DIMENSION_ARN); + + UntagResourceRequest submittedUntagRequest = (UntagResourceRequest) submittedIotRequests.get(2); + assertThat(submittedUntagRequest.tagKeys()).isEqualTo(Collections.singletonList("PreviousTagKey")); + assertThat(submittedUntagRequest.resourceArn()).isEqualTo(DIMENSION_ARN); + } + + @Test + public void updateTags_SameKeyDifferentValue_OnlyTagCall() { + + software.amazon.awssdk.services.iot.model.Tag previousTag = + software.amazon.awssdk.services.iot.model.Tag.builder() + .key("DesiredTagKey") + .value("PreviousTagValue") + .build(); + Map desiredTags = ImmutableMap.of("DesiredTagKey", "DesiredTagValue"); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(ResourceModel.builder().build()) + .previousResourceTags(ImmutableMap.of("doesn't", "matter")) + .desiredResourceTags(desiredTags) + .build(); + + doReturn(Collections.singleton(previousTag)) + .when(handler) + .listTags(proxy, DIMENSION_ARN, logger); + + handler.updateTags(proxy, request, DIMENSION_ARN, logger); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(TagResourceRequest.class); + verify(proxy).injectCredentialsAndInvokeV2(requestCaptor.capture(), any()); + TagResourceRequest submittedTagRequest = requestCaptor.getValue(); + assertThat(submittedTagRequest.tags()).isEqualTo(Collections.singletonList(DESIRED_SDK_RESOURCE_TAG)); + } + + @Test + public void updateTags_NoDesiredTags_OnlyUntagCall() { + + Map desiredTags = Collections.emptyMap(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(ResourceModel.builder().build()) + .previousResourceTags(ImmutableMap.of("doesn't", "matter")) + .desiredResourceTags(desiredTags) + .build(); + + doReturn(Collections.singleton(PREVIOUS_SDK_RESOURCE_TAG)) + .when(handler) + .listTags(proxy, DIMENSION_ARN, logger); + + handler.updateTags(proxy, request, DIMENSION_ARN, logger); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(UntagResourceRequest.class); + verify(proxy).injectCredentialsAndInvokeV2(requestCaptor.capture(), any()); + UntagResourceRequest submittedUntagRequest = requestCaptor.getValue(); + assertThat(submittedUntagRequest.tagKeys()).isEqualTo(Collections.singletonList("PreviousTagKey")); + } + + @Test + public void handleRequest_UpdateThrowsIRE_VerifyTranslation() { + + ResourceModel desiredModel = ResourceModel.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(DIMENSION_VALUE_CFN) + .build(); + ResourceModel previousModel = ResourceModel.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(PREVIOUS_DIMENSION_VALUE) + .arn(DIMENSION_ARN) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceTags(ImmutableMap.of("doesn't", "matter")) + .previousResourceState(previousModel) + .desiredResourceState(desiredModel) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(InvalidRequestException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.InvalidRequest); + } + + @Test + public void updateTags_ApiThrowsIFE_BubbleUp() { + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(ResourceModel.builder().build()) + .previousResourceTags(ImmutableMap.of("doesn't", "matter")) + .desiredResourceTags(ImmutableMap.of("DesiredTagKey", "DesiredTagValue")) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(InternalFailureException.builder().build()); + + assertThatThrownBy(() -> + handler.updateTags(proxy, request, DIMENSION_ARN, logger)) + .isInstanceOf(InternalFailureException.class); + } + + @Test + void handleRequest_ResourceAlreadyDeleted_VerifyException() { + + ResourceModel desiredModel = ResourceModel.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(DIMENSION_VALUE_CFN) + .build(); + ResourceModel previousModel = ResourceModel.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(PREVIOUS_DIMENSION_VALUE) + .arn(DIMENSION_ARN) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceTags(ImmutableMap.of("doesn't", "matter")) + .previousResourceState(previousModel) + .desiredResourceState(desiredModel) + .build(); + + // If the resource is already deleted, the update API throws ResourceNotFoundException. Mocking that here. + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(ResourceNotFoundException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.NotFound); + } + + @Test + void handleRequest_DesiredArnIsPopulatedAndSame_ReturnFailed() { + + ResourceModel desiredModel = ResourceModel.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(DIMENSION_VALUE_CFN) + .arn(DIMENSION_ARN) + .build(); + ResourceModel previousModel = ResourceModel.builder() + .name(DIMENSION_NAME) + .type(DIMENSION_TYPE) + .stringValues(PREVIOUS_DIMENSION_VALUE) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceTags(ImmutableMap.of("doesn't", "matter")) + .previousResourceState(previousModel) + .desiredResourceState(desiredModel) + .build(); + + ProgressEvent result = + handler.handleRequest(proxy, request, null, logger); + + assertThat(result).isEqualTo(ProgressEvent.failed( + desiredModel, null, HandlerErrorCode.InvalidRequest, "Arn cannot be updated.")); + } + + // TODO: test system tags when the src code is ready +} diff --git a/aws-iot-dimension/template.yml b/aws-iot-dimension/template.yml new file mode 100644 index 0000000..453d2b9 --- /dev/null +++ b/aws-iot-dimension/template.yml @@ -0,0 +1,23 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the AWS::IoT::Dimension resource type + +Globals: + Function: + Timeout: 180 # docker start-up times can be long for SAM CLI + MemorySize: 256 + +Resources: + TypeFunction: + Type: AWS::Serverless::Function + Properties: + Handler: com.amazonaws.iot.dimension.HandlerWrapper::handleRequest + Runtime: java8 + CodeUri: ./target/aws-iot-dimension-handler-1.0-SNAPSHOT.jar + + TestEntrypoint: + Type: AWS::Serverless::Function + Properties: + Handler: com.amazonaws.iot.dimension.HandlerWrapper::testEntrypoint + Runtime: java8 + CodeUri: ./target/aws-iot-dimension-handler-1.0-SNAPSHOT.jar