From 424bafd06258d462880743def54dc87540761611 Mon Sep 17 00:00:00 2001 From: Anton Kuznetsov <72220685+anton-aws@users.noreply.github.com> Date: Wed, 16 Dec 2020 12:04:25 -0800 Subject: [PATCH] Add support for AWS::IoT::AccountAuditConfiguration (#17) * Add support for AWS::IoT::AccountAuditConfiguration * Address PR comments * Fix additionalProperties placement * Use real resources in contract test inputs * Use ProgressEvents instead of Cfn exceptions --- aws-iot-accountauditconfiguration/.gitignore | 23 ++ .../.rpdk-config | 17 + aws-iot-accountauditconfiguration/README.md | 26 ++ .../aws-iot-accountauditconfiguration.json | 117 +++++++ .../inputs/inputs_1_create.json | 12 + .../inputs/inputs_1_invalid.json | 12 + .../inputs/inputs_1_update.json | 15 + .../lombok.config | 1 + .../contract_test_dependencies.yml | 28 ++ aws-iot-accountauditconfiguration/pom.xml | 210 ++++++++++++ .../resource-role.yaml | 34 ++ .../CallbackContext.java | 10 + .../Configuration.java | 8 + .../CreateHandler.java | 166 ++++++++++ .../DeleteHandler.java | 83 +++++ .../ListHandler.java | 61 ++++ .../ReadHandler.java | 75 +++++ .../accountauditconfiguration/Translator.java | 145 ++++++++ .../UpdateHandler.java | 118 +++++++ .../CreateHandlerTest.java | 312 ++++++++++++++++++ .../DeleteHandlerTest.java | 121 +++++++ .../ListHandlerTest.java | 111 +++++++ .../ReadHandlerTest.java | 127 +++++++ .../TestConstants.java | 92 ++++++ .../TranslatorTest.java | 74 +++++ .../UpdateHandlerTest.java | 198 +++++++++++ .../template.yml | 23 ++ 27 files changed, 2219 insertions(+) create mode 100644 aws-iot-accountauditconfiguration/.gitignore create mode 100644 aws-iot-accountauditconfiguration/.rpdk-config create mode 100644 aws-iot-accountauditconfiguration/README.md create mode 100644 aws-iot-accountauditconfiguration/aws-iot-accountauditconfiguration.json create mode 100644 aws-iot-accountauditconfiguration/inputs/inputs_1_create.json create mode 100644 aws-iot-accountauditconfiguration/inputs/inputs_1_invalid.json create mode 100644 aws-iot-accountauditconfiguration/inputs/inputs_1_update.json create mode 100644 aws-iot-accountauditconfiguration/lombok.config create mode 100644 aws-iot-accountauditconfiguration/packaging_additional_published_artifacts/contract_test_dependencies.yml create mode 100644 aws-iot-accountauditconfiguration/pom.xml create mode 100644 aws-iot-accountauditconfiguration/resource-role.yaml create mode 100644 aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/CallbackContext.java create mode 100644 aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/Configuration.java create mode 100644 aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/CreateHandler.java create mode 100644 aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/DeleteHandler.java create mode 100644 aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/ListHandler.java create mode 100644 aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/ReadHandler.java create mode 100644 aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/Translator.java create mode 100644 aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/UpdateHandler.java create mode 100644 aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/CreateHandlerTest.java create mode 100644 aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/DeleteHandlerTest.java create mode 100644 aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/ListHandlerTest.java create mode 100644 aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/ReadHandlerTest.java create mode 100644 aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/TestConstants.java create mode 100644 aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/TranslatorTest.java create mode 100644 aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/UpdateHandlerTest.java create mode 100644 aws-iot-accountauditconfiguration/template.yml diff --git a/aws-iot-accountauditconfiguration/.gitignore b/aws-iot-accountauditconfiguration/.gitignore new file mode 100644 index 0000000..5eb6238 --- /dev/null +++ b/aws-iot-accountauditconfiguration/.gitignore @@ -0,0 +1,23 @@ +# macOS +.DS_Store +._* + +# Maven outputs +.classpath + +# IntelliJ +*.iml +.idea +out.java +out/ +.settings +.project + +# auto-generated files +target/ + +# our logs +rpdk.log + +# contains credentials +sam-tests/ diff --git a/aws-iot-accountauditconfiguration/.rpdk-config b/aws-iot-accountauditconfiguration/.rpdk-config new file mode 100644 index 0000000..601a439 --- /dev/null +++ b/aws-iot-accountauditconfiguration/.rpdk-config @@ -0,0 +1,17 @@ +{ + "typeName": "AWS::IoT::AccountAuditConfiguration", + "language": "java", + "runtime": "java8", + "entrypoint": "com.amazonaws.iot.accountauditconfiguration.HandlerWrapper::handleRequest", + "testEntrypoint": "com.amazonaws.iot.accountauditconfiguration.HandlerWrapper::testEntrypoint", + "settings": { + "namespace": [ + "com", + "amazonaws", + "iot", + "accountauditconfiguration" + ], + "codegen_template_path": "default", + "protocolVersion": "2.0.0" + } +} diff --git a/aws-iot-accountauditconfiguration/README.md b/aws-iot-accountauditconfiguration/README.md new file mode 100644 index 0000000..82c1a36 --- /dev/null +++ b/aws-iot-accountauditconfiguration/README.md @@ -0,0 +1,26 @@ +# AWS::IoT::AccountAuditConfiguration + +## Running Contract Tests + +You can execute the following commands to run the tests. +You will need to have docker installed and running. + +```bash +# Create a CloudFormation stack with contract test dependencies (an IAM Role) +aws cloudformation deploy \ +--stack-name cfn-contract-test-dependencies-account-audit-configuration \ +--template-file packaging_additional_published_artifacts/contract_test_dependencies.yml \ +--capabilities CAPABILITY_IAM \ +--region us-east-1 + +# 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-accountauditconfiguration/aws-iot-accountauditconfiguration.json b/aws-iot-accountauditconfiguration/aws-iot-accountauditconfiguration.json new file mode 100644 index 0000000..c967370 --- /dev/null +++ b/aws-iot-accountauditconfiguration/aws-iot-accountauditconfiguration.json @@ -0,0 +1,117 @@ +{ + "typeName": "AWS::IoT::AccountAuditConfiguration", + "description": "Configures the Device Defender audit settings for this account. Settings include how audit notifications are sent and which audit checks are enabled or disabled.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-iot.git", + "definitions": { + "AuditCheckConfiguration": { + "description": "The configuration for a specific audit check.", + "type": "object", + "properties": { + "Enabled": { + "description": "True if the check is enabled.", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "AuditNotificationTarget": { + "type": "object", + "properties": { + "TargetArn": { + "description": "The ARN of the target (SNS topic) to which audit notifications are sent.", + "type": "string", + "maxLength": 2048 + }, + "RoleArn": { + "description": "The ARN of the role that grants permission to send notifications to the target.", + "type": "string", + "minLength": 20, + "maxLength": 2048 + }, + "Enabled": { + "description": "True if notifications to the target are enabled.", + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "properties": { + "AccountId": { + "description": "Your 12-digit account ID (used as the primary identifier for the CloudFormation resource).", + "type": "string", + "minLength": 12, + "maxLength": 12 + }, + "AuditCheckConfigurations": { + "description": "Specifies which audit checks are enabled and disabled for this account.", + "type": "object", + "patternProperties": { + "[A-Z_]+": { + "$ref": "#/definitions/AuditCheckConfiguration" + } + }, + "additionalProperties": false + }, + "AuditNotificationTargetConfigurations": { + "description": "Information about the targets to which audit notifications are sent.", + "type": "object", + "patternProperties": { + "[a-zA-Z0-9:_-]+": { + "$ref": "#/definitions/AuditNotificationTarget" + } + }, + "additionalProperties": false + }, + "RoleArn": { + "description": "The ARN of the role that grants permission to AWS IoT to access information about your devices, policies, certificates and other items as required when performing an audit.", + "type": "string", + "minLength": 20, + "maxLength": 2048 + } + }, + "additionalProperties": false, + "primaryIdentifier": [ + "/properties/AccountId" + ], + "required": [ + "AccountId", + "AuditCheckConfigurations", + "RoleArn" + ], + "createOnlyProperties": [ + "/properties/AccountId" + ], + "handlers": { + "create": { + "permissions": [ + "iot:UpdateAccountAuditConfiguration", + "iot:DescribeAccountAuditConfiguration", + "iam:PassRole" + ] + }, + "read": { + "permissions": [ + "iot:DescribeAccountAuditConfiguration" + ] + }, + "update": { + "permissions": [ + "iot:UpdateAccountAuditConfiguration", + "iot:DescribeAccountAuditConfiguration", + "iam:PassRole" + ] + }, + "delete": { + "permissions": [ + "iot:DescribeAccountAuditConfiguration", + "iot:DeleteAccountAuditConfiguration" + ] + }, + "list": { + "permissions": [ + "iot:DescribeAccountAuditConfiguration" + ] + } + } +} diff --git a/aws-iot-accountauditconfiguration/inputs/inputs_1_create.json b/aws-iot-accountauditconfiguration/inputs/inputs_1_create.json new file mode 100644 index 0000000..1fc5022 --- /dev/null +++ b/aws-iot-accountauditconfiguration/inputs/inputs_1_create.json @@ -0,0 +1,12 @@ +{ + "AccountId": "{{AccountId}}", + "AuditCheckConfigurations": { + "LOGGING_DISABLED_CHECK": { + "Enabled": true + }, + "CA_CERTIFICATE_EXPIRING_CHECK": { + "Enabled": true + } + }, + "RoleArn": "{{RoleForDeviceDefenderAuditArn}}" +} diff --git a/aws-iot-accountauditconfiguration/inputs/inputs_1_invalid.json b/aws-iot-accountauditconfiguration/inputs/inputs_1_invalid.json new file mode 100644 index 0000000..920bd2c --- /dev/null +++ b/aws-iot-accountauditconfiguration/inputs/inputs_1_invalid.json @@ -0,0 +1,12 @@ +{ + "AccountId": "{{AccountId}}", + "AuditCheckConfigurations": { + "NON_EXISTENT_CHECK": { + "Enabled": true + }, + "CA_CERTIFICATE_EXPIRING_CHECK": { + "Enabled": true + } + }, + "RoleArn": "{{RoleForDeviceDefenderAuditArn}}" +} diff --git a/aws-iot-accountauditconfiguration/inputs/inputs_1_update.json b/aws-iot-accountauditconfiguration/inputs/inputs_1_update.json new file mode 100644 index 0000000..05e20f9 --- /dev/null +++ b/aws-iot-accountauditconfiguration/inputs/inputs_1_update.json @@ -0,0 +1,15 @@ +{ + "AccountId": "{{AccountId}}", + "AuditCheckConfigurations": { + "LOGGING_DISABLED_CHECK": { + "Enabled": true + }, + "CA_CERTIFICATE_EXPIRING_CHECK": { + "Enabled": true + }, + "DEVICE_CERTIFICATE_EXPIRING_CHECK": { + "Enabled": true + } + }, + "RoleArn": "{{RoleForDeviceDefenderAuditArn}}" +} diff --git a/aws-iot-accountauditconfiguration/lombok.config b/aws-iot-accountauditconfiguration/lombok.config new file mode 100644 index 0000000..7a21e88 --- /dev/null +++ b/aws-iot-accountauditconfiguration/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/aws-iot-accountauditconfiguration/packaging_additional_published_artifacts/contract_test_dependencies.yml b/aws-iot-accountauditconfiguration/packaging_additional_published_artifacts/contract_test_dependencies.yml new file mode 100644 index 0000000..3156a1e --- /dev/null +++ b/aws-iot-accountauditconfiguration/packaging_additional_published_artifacts/contract_test_dependencies.yml @@ -0,0 +1,28 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: 'Resources for running the AccountAuditConfiguration contract tests' +Resources: + RoleForDeviceDefenderAudit: + Type: "AWS::IAM::Role" + Properties: + Path: "/" + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Principal: + Service: + - "iot.amazonaws.com" + Action: + - "sts:AssumeRole" + +Outputs: + RoleForDeviceDefenderAuditOutput: + Value: + Fn::GetAtt: [RoleForDeviceDefenderAudit, Arn] + Export: + Name: RoleForDeviceDefenderAuditArn + AccountIdOutput: + Value: + Fn::Sub: "${AWS::AccountId}" + Export: + Name: AccountId diff --git a/aws-iot-accountauditconfiguration/pom.xml b/aws-iot-accountauditconfiguration/pom.xml new file mode 100644 index 0000000..6053165 --- /dev/null +++ b/aws-iot-accountauditconfiguration/pom.xml @@ -0,0 +1,210 @@ + + + 4.0.0 + + com.amazonaws.iot.accountauditconfiguration + aws-iot-accountauditconfiguration-handler + aws-iot-accountauditconfiguration-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.1 + + + INSTRUCTION + COVEREDRATIO + 0.1 + + + + + + + + + + + + ${project.basedir} + + aws-iot-accountauditconfiguration.json + + + + + diff --git a/aws-iot-accountauditconfiguration/resource-role.yaml b/aws-iot-accountauditconfiguration/resource-role.yaml new file mode 100644 index 0000000..cfc7788 --- /dev/null +++ b/aws-iot-accountauditconfiguration/resource-role.yaml @@ -0,0 +1,34 @@ +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: + - "iam:PassRole" + - "iot:DeleteAccountAuditConfiguration" + - "iot:DescribeAccountAuditConfiguration" + - "iot:UpdateAccountAuditConfiguration" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/CallbackContext.java b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/CallbackContext.java new file mode 100644 index 0000000..5211971 --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/CallbackContext.java @@ -0,0 +1,10 @@ +package com.amazonaws.iot.accountauditconfiguration; + +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-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/Configuration.java b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/Configuration.java new file mode 100644 index 0000000..ca30463 --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/Configuration.java @@ -0,0 +1,8 @@ +package com.amazonaws.iot.accountauditconfiguration; + +class Configuration extends BaseConfiguration { + + public Configuration() { + super("aws-iot-accountauditconfiguration.json"); + } +} diff --git a/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/CreateHandler.java b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/CreateHandler.java new file mode 100644 index 0000000..a34f83e --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/CreateHandler.java @@ -0,0 +1,166 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import java.util.Map; +import java.util.Set; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.AuditNotificationTarget; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationRequest; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationResponse; +import software.amazon.awssdk.services.iot.model.UpdateAccountAuditConfigurationRequest; +import software.amazon.awssdk.utils.CollectionUtils; +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 CreateHandler extends BaseHandler { + + private final IotClient iotClient; + + public CreateHandler() { + iotClient = IotClient.builder().build(); + } + + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + Logger logger) { + + ResourceModel model = request.getDesiredResourceState(); + + // We ask customers to specify the Account ID as part of the model, + // because there must be a primary identifier. + // Having it in the model helps CFN prevent cases like 2 stacks from managing the same resource. + String accountIdFromTemplate = model.getAccountId(); + String accountId = request.getAwsAccountId(); + if (!accountIdFromTemplate.equals(accountId)) { + String message = String.format("AccountId in the template (%s) doesn't match actual: %s.", + accountIdFromTemplate, accountId); + logger.log(message); + return ProgressEvent.failed(model, callbackContext, HandlerErrorCode.InvalidRequest, message); + } + + // Call Describe to see whether the customer has a configuration already. + // Note that this API never throws ResourceNotFoundException. + // If the configuration doesn't exist, it returns an object with all checks disabled, + // other fields equal to null. + DescribeAccountAuditConfigurationResponse describeResponse; + try { + describeResponse = proxy.injectCredentialsAndInvokeV2( + DescribeAccountAuditConfigurationRequest.builder().build(), + iotClient::describeAccountAuditConfiguration); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(model, e, logger); + } + logger.log("Called DescribeAccountAuditConfiguration for " + accountId); + + // We judge whether the configuration exists by the RoleArn field. + // A customer cannot create a configuration without specifying a RoleArn. + // For an existing configuration, the RoleArn can never be nullified, + // unless the whole configuration is deleted. + boolean roleArnAlreadyExists = !StringUtils.isEmpty(describeResponse.roleArn()); + if (roleArnAlreadyExists) { + // Note: we don't fail with AlreadyExists as soon as we see an existing configuration. + // We return success if the existing configuration is identical. + // We do this to not break the customer's template in case of a transient exception. + // Consider a scenario where our CreateHandler succeeds, but our response doesn't reach CFN. + // CFN would retry. If we returned AlreadyExists, CFN would throw "CREATE_FAILED: resource already exists" + // to the customer. + // We do know that this configuration could've been created manually rather than through + // CFN, but, unlike for other resources, we don't have a way to tell how + // the configuration was created. + boolean areEquivalent = areEquivalent(model, describeResponse, logger); + logger.log("An AccountAuditConfiguration already existed, areEquivalent=" + areEquivalent); + if (areEquivalent) { + return ProgressEvent.defaultSuccessHandler(model); + } else { + return ProgressEvent.failed(model, callbackContext, HandlerErrorCode.AlreadyExists, + "A configuration with different properties already exists."); + } + } + logger.log("DescribeAccountAuditConfiguration for " + accountId + + " returned a blank config, updating now."); + + // 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 (e.g. non-existent check name), we'll translate the service's + // InvalidRequestException and include a readable message. + UpdateAccountAuditConfigurationRequest updateRequest = UpdateAccountAuditConfigurationRequest.builder() + .auditCheckConfigurations(Translator.translateChecksFromCfnToIot(model)) + .auditNotificationTargetConfigurationsWithStrings(Translator.translateNotificationsFromCfnToIot(model)) + .roleArn(model.getRoleArn()) + .build(); + try { + proxy.injectCredentialsAndInvokeV2( + updateRequest, iotClient::updateAccountAuditConfiguration); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(model, e, logger); + } + + logger.log("Created AccountAuditConfiguration for " + accountId); + return ProgressEvent.defaultSuccessHandler(model); + } + + private boolean areEquivalent( + ResourceModel model, + DescribeAccountAuditConfigurationResponse describeResponse, + Logger logger) { + + if (!describeResponse.roleArn().equals(model.getRoleArn())) { + logger.log("AccountAuditConfiguration already exists with a different role ARN: " + + describeResponse.roleArn()); + return false; + } + + if (!areCheckConfigurationsEquivalent(model, describeResponse)) { + logger.log("AccountAuditConfiguration already exists with different check configurations enabled."); + return false; + } + + if (!areNotificationTargetsEquivalent(model, describeResponse)) { + logger.log("AccountAuditConfiguration already exists with different notification " + + "target configurations enabled."); + return false; + } + return true; + } + + boolean areCheckConfigurationsEquivalent( + ResourceModel model, + DescribeAccountAuditConfigurationResponse describeResponse) { + + // We can't simply compare the request and response maps, because DescribeResponse + // contains all the available checks in disabled state, even if the customer never touched them. + Set checksEnabledInDescribeResponse = Translator.getEnabledChecksSetFromIotMap( + describeResponse.auditCheckConfigurations()); + + Set checksEnabledInTemplate = Translator.getEnabledChecksSetFromIotMap( + Translator.translateChecksFromCfnToIot(model)); + + return checksEnabledInDescribeResponse.equals(checksEnabledInTemplate); + } + + boolean areNotificationTargetsEquivalent( + ResourceModel model, + DescribeAccountAuditConfigurationResponse describeResponse) { + + // Unlike CheckConfigurations, the default state for Notifications is null, not disabled. + // This allows us to simply check if the maps are equal. + Map + notificationTargetConfigurationsFromTemplate = Translator.translateNotificationsFromCfnToIot(model); + + Map notificationTargetConfigurationsFromDescribe = + describeResponse.auditNotificationTargetConfigurationsAsStrings(); + if (CollectionUtils.isNullOrEmpty(notificationTargetConfigurationsFromDescribe)) { + return CollectionUtils.isNullOrEmpty(notificationTargetConfigurationsFromTemplate); + } else { + return notificationTargetConfigurationsFromTemplate.equals( + notificationTargetConfigurationsFromDescribe); + } + } +} diff --git a/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/DeleteHandler.java b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/DeleteHandler.java new file mode 100644 index 0000000..d05b2f8 --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/DeleteHandler.java @@ -0,0 +1,83 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.DeleteAccountAuditConfigurationRequest; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationRequest; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationResponse; +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.OperationStatus; +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(); + + String accountIdFromTemplate = model.getAccountId(); + String accountId = request.getAwsAccountId(); + if (!accountIdFromTemplate.equals(accountId)) { + // This case can only happen in CreateRollback after caller tried creating a Config with wrong AccountID. + // Returning HandlerErrorCode.NotFound is the right thing to do - it'll allow CFN to succeed + // idempotently. Otherwise it'd get stuck trying to delete. + logger.log("Returning NotFound from DeleteHandler due to account ID mismatch, " + accountIdFromTemplate + + " from template instead of real " + accountId); + return ProgressEvent.builder() + .resourceModel(model) + .status(OperationStatus.FAILED) + .errorCode(HandlerErrorCode.NotFound) + .build(); + } + + // Call Describe to see whether the configuration was already deleted, as that's + // what the CFN contracts advise. + // Note that this API never throws ResourceNotFoundException. + // If the configuration doesn't exist, it returns an object with all checks disabled, + // other fields equal to null. + DescribeAccountAuditConfigurationResponse describeResponse; + try { + describeResponse = proxy.injectCredentialsAndInvokeV2( + DescribeAccountAuditConfigurationRequest.builder().build(), + iotClient::describeAccountAuditConfiguration); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(model, e, logger); + } + logger.log("Called DescribeAccountAuditConfiguration for " + accountId); + + // We judge whether the configuration exists by the RoleArn field. + // For an existing configuration, the RoleArn can never be nullified, + // unless the whole configuration is deleted. + if (StringUtils.isEmpty(describeResponse.roleArn())) { + // CFN swallows this NotFound failure, the customer will see success. + return ProgressEvent.failed(model, callbackContext, + HandlerErrorCode.NotFound, + "The configuration for your account has not been set up or was deleted."); + } + + try { + proxy.injectCredentialsAndInvokeV2( + DeleteAccountAuditConfigurationRequest.builder().build(), + iotClient::deleteAccountAuditConfiguration); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(model, e, logger); + } + + logger.log("Deleted AccountAuditConfiguration for " + accountId); + return ProgressEvent.defaultSuccessHandler(null); + } +} diff --git a/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/ListHandler.java b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/ListHandler.java new file mode 100644 index 0000000..52e4fc9 --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/ListHandler.java @@ -0,0 +1,61 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import java.util.Collections; +import java.util.List; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationRequest; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationResponse; +import software.amazon.awssdk.services.iot.model.IotException; +import software.amazon.awssdk.utils.StringUtils; +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) { + + String accountId = request.getAwsAccountId(); + + // There can only be one configuration per account, there's no List API. + DescribeAccountAuditConfigurationResponse describeResponse; + try { + describeResponse = proxy.injectCredentialsAndInvokeV2( + DescribeAccountAuditConfigurationRequest.builder().build(), + iotClient::describeAccountAuditConfiguration); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(request.getDesiredResourceState(), e, logger); + } + logger.log("Called DescribeAccountAuditConfiguration for " + accountId); + + // We judge whether the configuration exists by the RoleArn field. + // For an existing configuration, the RoleArn can never be nullified, + // unless the whole configuration is deleted. + List models; + if (StringUtils.isEmpty(describeResponse.roleArn())) { + models = Collections.emptyList(); + } else { + ResourceModel model = ResourceModel.builder().accountId(accountId).build(); + models = Collections.singletonList(model); + } + + return ProgressEvent.builder() + .resourceModels(models) + .status(OperationStatus.SUCCESS) + .build(); + } +} diff --git a/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/ReadHandler.java b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/ReadHandler.java new file mode 100644 index 0000000..6b19f6c --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/ReadHandler.java @@ -0,0 +1,75 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import java.util.Map; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationRequest; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationResponse; +import software.amazon.awssdk.services.iot.model.IotException; +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.OperationStatus; +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) { + + String accountId = request.getAwsAccountId(); + + DescribeAccountAuditConfigurationResponse describeResponse; + try { + describeResponse = proxy.injectCredentialsAndInvokeV2( + DescribeAccountAuditConfigurationRequest.builder().build(), + iotClient::describeAccountAuditConfiguration); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(request.getDesiredResourceState(), e, logger); + } + logger.log("Called DescribeAccountAuditConfiguration for " + accountId); + + // The Describe API never throws ResourceNotFoundException. + // If the configuration doesn't exist, it returns an object with all checks disabled, + // other fields equal to null. + // We judge whether the configuration exists by the RoleArn field. + // A customer cannot create a configuration without specifying a RoleArn. + // For an existing configuration, the RoleArn can never be nullified, + // unless the whole configuration is deleted. + if (StringUtils.isEmpty(describeResponse.roleArn())) { + return ProgressEvent.failed(request.getDesiredResourceState(), callbackContext, + HandlerErrorCode.NotFound, + "The configuration for your account has not been set up or was deleted."); + } + + Map auditCheckConfigurationsCfn = Translator.translateChecksFromIotToCfn( + describeResponse.auditCheckConfigurations()); + + Map notificationTargetsCfn = Translator.translateNotificationsFromIotToCfn( + describeResponse.auditNotificationTargetConfigurationsAsStrings()); + + ResourceModel model = ResourceModel.builder() + .accountId(accountId) + .auditCheckConfigurations(auditCheckConfigurationsCfn) + .auditNotificationTargetConfigurations(notificationTargetsCfn) + .roleArn(describeResponse.roleArn()) + .build(); + + return ProgressEvent.builder() + .resourceModel(model) + .status(OperationStatus.SUCCESS) + .build(); + } +} diff --git a/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/Translator.java b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/Translator.java new file mode 100644 index 0000000..fa711aa --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/Translator.java @@ -0,0 +1,145 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import java.util.Collections; +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.IotException; +import software.amazon.awssdk.services.iot.model.ThrottlingException; +import software.amazon.awssdk.services.iot.model.UnauthorizedException; +import software.amazon.cloudformation.exceptions.BaseHandlerException; +import software.amazon.cloudformation.exceptions.CfnAccessDeniedException; +import software.amazon.cloudformation.exceptions.CfnInternalFailureException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnThrottlingException; +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 translateExceptionToProgressEvent( + 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_UpdateAccountAuditConfiguration.html (+similar + // pages for other APIs) + // For Throttling and InternalFailure, we want CFN to retry, and it will do so based on the exception type. + // Reference with Retriable/Terminal in comments for each: https://tinyurl.com/y378qdno + if (e instanceof InvalidRequestException) { + return HandlerErrorCode.InvalidRequest; + } 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 { + 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 Map translateChecksFromCfnToIot(ResourceModel model) { + + return model.getAuditCheckConfigurations() + .entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> translateCheckConfigurationFromCfnToIot(e.getValue()))); + } + + static software.amazon.awssdk.services.iot.model.AuditCheckConfiguration translateCheckConfigurationFromCfnToIot( + AuditCheckConfiguration auditCheckConfiguration) { + + return software.amazon.awssdk.services.iot.model.AuditCheckConfiguration.builder() + .enabled(auditCheckConfiguration.getEnabled()) + .build(); + } + + static Map translateChecksFromIotToCfn( + Map iotMap) { + + return iotMap.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> AuditCheckConfiguration.builder().enabled(e.getValue().enabled()).build())); + } + + static Set getEnabledChecksSetFromIotMap( + Map checkConfigurationMap) { + + return checkConfigurationMap + .entrySet() + .stream() + .filter(e -> e.getValue().enabled()) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } + + static Map + translateNotificationsFromCfnToIot(ResourceModel model) { + + if (model.getAuditNotificationTargetConfigurations() == null) { + return Collections.emptyMap(); + } + return model.getAuditNotificationTargetConfigurations() + .entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> translateNotificationTargetFromCfnToIot(e.getValue()))); + } + + private static software.amazon.awssdk.services.iot.model.AuditNotificationTarget + translateNotificationTargetFromCfnToIot(AuditNotificationTarget notificationTarget) { + return software.amazon.awssdk.services.iot.model.AuditNotificationTarget.builder() + .enabled(notificationTarget.getEnabled()) + .roleArn(notificationTarget.getRoleArn()) + .targetArn(notificationTarget.getTargetArn()) + .build(); + } + + static Map translateNotificationsFromIotToCfn( + Map iotMap) { + + if (iotMap == null) { + return Collections.emptyMap(); + } + + return iotMap.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> AuditNotificationTarget.builder() + .targetArn(e.getValue().targetArn()) + .roleArn(e.getValue().roleArn()) + .enabled(e.getValue().enabled()) + .build())); + } +} diff --git a/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/UpdateHandler.java b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/UpdateHandler.java new file mode 100644 index 0000000..55aa2c2 --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/main/java/com/amazonaws/iot/accountauditconfiguration/UpdateHandler.java @@ -0,0 +1,118 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import java.util.HashMap; +import java.util.Map; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.AuditCheckConfiguration; +import software.amazon.awssdk.services.iot.model.AuditNotificationTarget; +import software.amazon.awssdk.services.iot.model.AuditNotificationType; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationRequest; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationResponse; +import software.amazon.awssdk.services.iot.model.UpdateAccountAuditConfigurationRequest; +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() { + this.iotClient = IotClient.builder().build(); + } + + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + Logger logger) { + + ResourceModel model = request.getDesiredResourceState(); + + // Note: Unlike CreateHandler, there's no need to verify the accountID in the model vs the request. + // Since it's a primaryIdentifier, upon change, CFN will issue Create+Delete rather than just Update. + // The Create will fail in that case because it does have the account ID check. + String accountId = request.getAwsAccountId(); + + // We need to call Describe to overwrite out of band updates. + // For example, a customer can enable a check that this template doesn't mention. + // The UpdateAccountAuditConfiguration API has a PATCH behavior. + DescribeAccountAuditConfigurationResponse describeResponse; + try { + describeResponse = proxy.injectCredentialsAndInvokeV2( + DescribeAccountAuditConfigurationRequest.builder().build(), + iotClient::describeAccountAuditConfiguration); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(model, e, logger); + } + logger.log(String.format("Called DescribeAccountAuditConfiguration for %s.", accountId)); + + // If RoleArn is null, the configuration must've been deleted. The RoleArn can never be nullified. + if (StringUtils.isEmpty(describeResponse.roleArn())) { + String message = "The configuration for your account has not been set up or was deleted."; + logger.log(message); + return ProgressEvent.failed(request.getDesiredResourceState(), callbackContext, + HandlerErrorCode.NotFound, message); + } + + Map checkConfigurationsForUpdate = + buildCheckConfigurationsForUpdate(model, describeResponse); + + Map notificationTargetConfigurationsForUpdate = + buildNotificationTargetConfigurationsForUpdate(model); + + UpdateAccountAuditConfigurationRequest updateRequest = UpdateAccountAuditConfigurationRequest.builder() + .auditCheckConfigurations(checkConfigurationsForUpdate) + .auditNotificationTargetConfigurationsWithStrings(notificationTargetConfigurationsForUpdate) + .roleArn(model.getRoleArn()) + .build(); + try { + proxy.injectCredentialsAndInvokeV2( + updateRequest, iotClient::updateAccountAuditConfiguration); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(model, e, logger); + } + + logger.log(String.format("Updated AccountAuditConfiguration for %s.", accountId)); + return ProgressEvent.defaultSuccessHandler(request.getDesiredResourceState()); + } + + Map buildCheckConfigurationsForUpdate( + ResourceModel model, + DescribeAccountAuditConfigurationResponse describeResponse) { + + // The UpdateAccountAuditConfiguration API has a PATCH behavior, so we can't just + // translate the map from the model. + // A customer can enable a check out of band that this template doesn't mention. + // We create the map in 2 steps. First, create a map with all supported checks disabled. + // Second, copy all the entries from the model. + Map checkConfigurationsForUpdate = new HashMap<>(); + describeResponse.auditCheckConfigurations().keySet().forEach(k -> + checkConfigurationsForUpdate.put(k, AuditCheckConfiguration.builder().enabled(false).build())); + model.getAuditCheckConfigurations().forEach((key, value) -> + checkConfigurationsForUpdate.put(key, Translator.translateCheckConfigurationFromCfnToIot(value))); + return checkConfigurationsForUpdate; + } + + Map buildNotificationTargetConfigurationsForUpdate( + ResourceModel model) { + + // The UpdateAccountAuditConfiguration API has a PATCH behavior, so we can't just + // translate the map from the model. + // A customer can enable a notification target out of band that this template doesn't mention. + // We create the map in 2 steps. First, create a map with all supported notification target types. + // Second, copy all the entries from the model. + Map notificationTargetConfigurationsForUpdate = new HashMap<>(); + AuditNotificationType.knownValues().forEach(type -> notificationTargetConfigurationsForUpdate.put( + type.toString(), AuditNotificationTarget.builder().enabled(false).build())); + Translator.translateNotificationsFromCfnToIot(model) + .forEach(notificationTargetConfigurationsForUpdate::put); + return notificationTargetConfigurationsForUpdate; + } + +} diff --git a/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/CreateHandlerTest.java b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/CreateHandlerTest.java new file mode 100644 index 0000000..b22478f --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/CreateHandlerTest.java @@ -0,0 +1,312 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.ACCOUNT_ID; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.AUDIT_CHECK_CONFIGURATIONS_V1_CFN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.AUDIT_NOTIFICATION_TARGET_CFN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.AUDIT_NOTIFICATION_TARGET_IOT; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_REQUEST; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_V1_STATE; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_ZERO_STATE; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_ZERO_STATE_CHECKS; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DISABLED_IOT; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.ENABLED_CFN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.ENABLED_IOT; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.ROLE_ARN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.createCfnRequest; +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.google.common.collect.ImmutableMap; + +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.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationRequest; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationResponse; +import software.amazon.awssdk.services.iot.model.InvalidRequestException; +import software.amazon.awssdk.services.iot.model.IotRequest; +import software.amazon.awssdk.services.iot.model.UnauthorizedException; +import software.amazon.awssdk.services.iot.model.UpdateAccountAuditConfigurationRequest; +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 { + + Map + CHECK_CONFIGURATION_FROM_EXPECTED_UPDATE_REQUEST = ImmutableMap.of( + "LOGGING_DISABLED_CHECK", ENABLED_IOT, + "CA_CERTIFICATE_EXPIRING_CHECK", ENABLED_IOT); + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + private CreateHandler handler; + + @BeforeEach + public void setup() { + handler = new CreateHandler(); + } + + @Test + public void handleRequest_AllPropertiesFilledOut_FirstTimeCreate_VerifyInteractions() { + + ResourceModel model = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn(ROLE_ARN) + .build(); + ResourceHandlerRequest cfnRequest = createCfnRequest(model); + + when(proxy.injectCredentialsAndInvokeV2(eq(DESCRIBE_REQUEST), any())) + .thenReturn(DESCRIBE_RESPONSE_ZERO_STATE); + + ProgressEvent handlerResponse = + handler.handleRequest(proxy, cfnRequest, null, logger); + + UpdateAccountAuditConfigurationRequest expectedUpdateRequest = UpdateAccountAuditConfigurationRequest.builder() + .auditCheckConfigurations(CHECK_CONFIGURATION_FROM_EXPECTED_UPDATE_REQUEST) + .auditNotificationTargetConfigurationsWithStrings(AUDIT_NOTIFICATION_TARGET_IOT) + .roleArn(ROLE_ARN) + .build(); + ArgumentCaptor iotRequestCaptor = ArgumentCaptor.forClass(IotRequest.class); + verify(proxy, times(2)).injectCredentialsAndInvokeV2(iotRequestCaptor.capture(), any()); + assertThat(iotRequestCaptor.getAllValues().get(1)).isEqualTo(expectedUpdateRequest); + + assertThat(handlerResponse).isNotNull(); + assertThat(handlerResponse.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(handlerResponse.getCallbackContext()).isNull(); + assertThat(handlerResponse.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(handlerResponse.getResourceModels()).isNull(); + assertThat(handlerResponse.getMessage()).isNull(); + assertThat(handlerResponse.getErrorCode()).isNull(); + assertThat(handlerResponse.getResourceModel()).isEqualTo(model); + } + + @Test + public void handleRequest_DescribeShowsIdenticalConfig_ExpectSuccess_NoUpdateCall() { + + ResourceModel model = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn(ROLE_ARN) + .build(); + ResourceHandlerRequest cfnRequest = createCfnRequest(model); + + when(proxy.injectCredentialsAndInvokeV2(eq(DESCRIBE_REQUEST), any())) + .thenReturn(DESCRIBE_RESPONSE_V1_STATE); + + ProgressEvent handlerResponse = + handler.handleRequest(proxy, cfnRequest, null, logger); + + ArgumentCaptor iotRequestCaptor = ArgumentCaptor.forClass(IotRequest.class); + verify(proxy).injectCredentialsAndInvokeV2(iotRequestCaptor.capture(), any()); + assertThat(iotRequestCaptor.getAllValues()).isEqualTo( + Collections.singletonList(DescribeAccountAuditConfigurationRequest.builder().build())); + + assertThat(handlerResponse).isNotNull(); + assertThat(handlerResponse.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(handlerResponse.getCallbackContext()).isNull(); + assertThat(handlerResponse.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(handlerResponse.getResourceModels()).isNull(); + assertThat(handlerResponse.getMessage()).isNull(); + assertThat(handlerResponse.getErrorCode()).isNull(); + assertThat(handlerResponse.getResourceModel()).isEqualTo(model); + } + + @Test + public void handleRequest_DescribeShowsDifferentRoleArn_ExpectRAE() { + + ResourceModel model = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn(ROLE_ARN) + .build(); + ResourceHandlerRequest cfnRequest = createCfnRequest(model); + + DescribeAccountAuditConfigurationResponse describeResponseWithDifferentRoleArn = + DESCRIBE_RESPONSE_V1_STATE.toBuilder().roleArn("differentRoleArn").build(); + when(proxy.injectCredentialsAndInvokeV2(eq(DESCRIBE_REQUEST), any())) + .thenReturn(describeResponseWithDifferentRoleArn); + + ProgressEvent actualResult = + handler.handleRequest(proxy, cfnRequest, null, logger); + ProgressEvent expectedResult = ProgressEvent.failed( + model, null, + HandlerErrorCode.AlreadyExists, + "A configuration with different properties already exists."); + assertThat(actualResult).isEqualTo(expectedResult); + } + + @Test + public void areCheckConfigurationsEquivalent_DescribeHasMoreDisabledChecks_ExpectTrue() { + + ResourceModel model = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn(ROLE_ARN) + .build(); + Map mapWithMoreChecksDisabled = + new HashMap<>(CHECK_CONFIGURATION_FROM_EXPECTED_UPDATE_REQUEST); + mapWithMoreChecksDisabled.put("CONFLICTING_CLIENT_IDS_CHECK", DISABLED_IOT); + DescribeAccountAuditConfigurationResponse describeResponse = + DESCRIBE_RESPONSE_V1_STATE.toBuilder() + .auditCheckConfigurations(mapWithMoreChecksDisabled) + .build(); + + assertThat(handler.areCheckConfigurationsEquivalent(model, describeResponse)).isTrue(); + } + + @Test + public void areCheckConfigurationsEquivalent_DescribeHasOneMoreEnabled_ExpectFalse() { + + ResourceModel model = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn(ROLE_ARN) + .build(); + Map mapWithMoreChecksEnabled = + new HashMap<>(DESCRIBE_RESPONSE_ZERO_STATE_CHECKS); + mapWithMoreChecksEnabled.put("CONFLICTING_CLIENT_IDS_CHECK", ENABLED_IOT); + DescribeAccountAuditConfigurationResponse describeResponse = + DESCRIBE_RESPONSE_V1_STATE.toBuilder() + .auditCheckConfigurations(mapWithMoreChecksEnabled) + .build(); + + assertThat(handler.areCheckConfigurationsEquivalent(model, describeResponse)).isFalse(); + } + + @Test + public void areCheckConfigurationsEquivalent_ModelHasOneMoreEnabled_ExpectFalse() { + + Map mapWithOneMoreCheckEnabled = + new HashMap<>(AUDIT_CHECK_CONFIGURATIONS_V1_CFN); + mapWithOneMoreCheckEnabled.put("CA_CERTIFICATE_KEY_QUALITY_CHECK", ENABLED_CFN); + ResourceModel model = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(mapWithOneMoreCheckEnabled) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn(ROLE_ARN) + .build(); + + assertThat(handler.areCheckConfigurationsEquivalent(model, DESCRIBE_RESPONSE_V1_STATE)).isFalse(); + } + + @Test + public void areNotificationTargetsEquivalent_DescribeHasNull_ExpectFalse() { + ResourceModel model = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn(ROLE_ARN) + .build(); + assertThat(handler.areNotificationTargetsEquivalent(model, DESCRIBE_RESPONSE_ZERO_STATE)).isFalse(); + } + + @Test + public void areNotificationTargetsEquivalent_ModelHasNull_ExpectFalse() { + ResourceModel model = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(null) + .roleArn(ROLE_ARN) + .build(); + assertThat(handler.areNotificationTargetsEquivalent(model, DESCRIBE_RESPONSE_V1_STATE)).isFalse(); + } + + @Test + public void areNotificationTargetsEquivalent_DifferentTargetArns_ExpectFalse() { + Map mapWithDifferentTargetArn = ImmutableMap.of( + "SNS", AuditNotificationTarget.builder().enabled(true) + .targetArn("differentTargetArn").roleArn(ROLE_ARN).build()); + ResourceModel model = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(mapWithDifferentTargetArn) + .roleArn(ROLE_ARN) + .build(); + assertThat(handler.areNotificationTargetsEquivalent(model, DESCRIBE_RESPONSE_V1_STATE)).isFalse(); + } + + @Test + public void handleRequest_WrongAccountId_ExpectIRE() { + + ResourceModel model = ResourceModel.builder() + .accountId("000111222333") + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn(ROLE_ARN) + .build(); + ResourceHandlerRequest cfnRequest = createCfnRequest(model); + + ProgressEvent actualResult = + handler.handleRequest(proxy, cfnRequest, null, logger); + ProgressEvent expectedResult = ProgressEvent.failed( + model, null, + HandlerErrorCode.InvalidRequest, + "AccountId in the template (000111222333) doesn't match actual: 123456789012."); + assertThat(actualResult).isEqualTo(expectedResult); + } + + @Test + public void handleRequest_ExceptionFromDescribe_Translated() { + + ResourceModel model = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn(ROLE_ARN) + .build(); + ResourceHandlerRequest cfnRequest = createCfnRequest(model); + + when(proxy.injectCredentialsAndInvokeV2(eq(DESCRIBE_REQUEST), any())) + .thenThrow(UnauthorizedException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, cfnRequest, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.AccessDenied); + } + + @Test + public void handleRequest_ExceptionFromCreate_Translated() { + + ResourceModel model = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn(ROLE_ARN) + .build(); + ResourceHandlerRequest cfnRequest = createCfnRequest(model); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(DESCRIBE_RESPONSE_ZERO_STATE) + .thenThrow(InvalidRequestException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, cfnRequest, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.InvalidRequest); + } +} diff --git a/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/DeleteHandlerTest.java b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/DeleteHandlerTest.java new file mode 100644 index 0000000..e9e3ae3 --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/DeleteHandlerTest.java @@ -0,0 +1,121 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.ACCOUNT_ID; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_REQUEST; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_V1_STATE; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_ZERO_STATE; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.createCfnRequest; +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.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.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.iot.model.DeleteAccountAuditConfigurationRequest; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationRequest; +import software.amazon.awssdk.services.iot.model.InternalFailureException; +import software.amazon.awssdk.services.iot.model.IotRequest; +import software.amazon.awssdk.services.iot.model.UnauthorizedException; +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 { + + private static final ResourceModel MODEL_FOR_REQUEST = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .roleArn("doesn't matter") + .build(); + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + private DeleteHandler handler; + + @BeforeEach + public void setup() { + handler = new DeleteHandler(); + } + + @Test + public void handleRequest_DescribeShowsExistingConfig_VerifyInteractions() { + + ResourceHandlerRequest cfnRequest = createCfnRequest(MODEL_FOR_REQUEST); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(DESCRIBE_RESPONSE_V1_STATE); + + ProgressEvent response + = handler.handleRequest(proxy, cfnRequest, null, logger); + + ArgumentCaptor iotRequestCaptor = ArgumentCaptor.forClass(IotRequest.class); + verify(proxy, times(2)).injectCredentialsAndInvokeV2(iotRequestCaptor.capture(), any()); + assertThat(iotRequestCaptor.getAllValues().get(0)) + .isEqualTo(DescribeAccountAuditConfigurationRequest.builder().build()); + assertThat(iotRequestCaptor.getAllValues().get(1)) + .isEqualTo(DeleteAccountAuditConfigurationRequest.builder().build()); + + 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(); + } + + @Test + public void handleRequest_DescribeShowsZeroState_ExpectNotFound() { + + ResourceHandlerRequest cfnRequest = createCfnRequest(MODEL_FOR_REQUEST); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(DESCRIBE_RESPONSE_ZERO_STATE); + + ProgressEvent actualResult = + handler.handleRequest(proxy, cfnRequest, null, logger); + ProgressEvent expectedResult = ProgressEvent.failed( + MODEL_FOR_REQUEST, null, + HandlerErrorCode.NotFound, + "The configuration for your account has not been set up or was deleted."); + assertThat(actualResult).isEqualTo(expectedResult); + } + + @Test + public void handleRequest_ExceptionFromDescribe_Translated() { + ResourceHandlerRequest cfnRequest = createCfnRequest(MODEL_FOR_REQUEST); + when(proxy.injectCredentialsAndInvokeV2(eq(DESCRIBE_REQUEST), any())) + .thenThrow(InternalFailureException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, cfnRequest, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.InternalFailure); + } + + @Test + public void handleRequest_ExceptionFromDelete_Translated() { + ResourceHandlerRequest cfnRequest = createCfnRequest(MODEL_FOR_REQUEST); + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(DESCRIBE_RESPONSE_V1_STATE) + .thenThrow(UnauthorizedException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, cfnRequest, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.AccessDenied); + } +} diff --git a/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/ListHandlerTest.java b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/ListHandlerTest.java new file mode 100644 index 0000000..ff0b477 --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/ListHandlerTest.java @@ -0,0 +1,111 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.ACCOUNT_ID; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_REQUEST; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_V1_STATE; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_ZERO_STATE; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.createCfnRequest; +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.Collections; +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.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.iot.model.InternalFailureException; +import software.amazon.cloudformation.exceptions.CfnInternalFailureException; +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() { + handler = new ListHandler(); + } + + @Test + public void handleRequest_DescribeShowsV1Config_ExpectSingletonList() { + + ResourceModel model = ResourceModel.builder() + .accountId("doesn't matter") + .build(); + ResourceHandlerRequest cfnRequest = createCfnRequest(model); + when(proxy.injectCredentialsAndInvokeV2(eq(DESCRIBE_REQUEST), any())) + .thenReturn(DESCRIBE_RESPONSE_V1_STATE); + + ProgressEvent actualResult = + handler.handleRequest(proxy, cfnRequest, null, logger); + + assertThat(actualResult).isNotNull(); + assertThat(actualResult.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(actualResult.getCallbackContext()).isNull(); + assertThat(actualResult.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(actualResult.getResourceModel()).isNull(); + assertThat(actualResult.getMessage()).isNull(); + assertThat(actualResult.getErrorCode()).isNull(); + assertThat(actualResult.getNextToken()).isNull(); + List expectedModels = Collections.singletonList( + ResourceModel.builder().accountId(ACCOUNT_ID).build()); + assertThat(actualResult.getResourceModels()).isEqualTo(expectedModels); + } + + @Test + public void handleRequest_DescribeShowsZeroState_ExpectEmptyList() { + + ResourceModel model = ResourceModel.builder() + .accountId("doesn't matter") + .build(); + ResourceHandlerRequest cfnRequest = createCfnRequest(model); + when(proxy.injectCredentialsAndInvokeV2(eq(DESCRIBE_REQUEST), any())) + .thenReturn(DESCRIBE_RESPONSE_ZERO_STATE); + + ProgressEvent actualResult = + handler.handleRequest(proxy, cfnRequest, null, logger); + + assertThat(actualResult).isNotNull(); + assertThat(actualResult.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(actualResult.getCallbackContext()).isNull(); + assertThat(actualResult.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(actualResult.getResourceModel()).isNull(); + assertThat(actualResult.getMessage()).isNull(); + assertThat(actualResult.getErrorCode()).isNull(); + assertThat(actualResult.getNextToken()).isNull(); + assertThat(actualResult.getResourceModels()).isEmpty(); + } + + @Test + public void handleRequest_ExceptionFromDescribe_Translated() { + + ResourceModel model = ResourceModel.builder() + .accountId("doesn't matter") + .build(); + ResourceHandlerRequest cfnRequest = createCfnRequest(model); + when(proxy.injectCredentialsAndInvokeV2(eq(DESCRIBE_REQUEST), any())) + .thenThrow(InternalFailureException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, cfnRequest, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.InternalFailure); + } +} diff --git a/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/ReadHandlerTest.java b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/ReadHandlerTest.java new file mode 100644 index 0000000..3434596 --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/ReadHandlerTest.java @@ -0,0 +1,127 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.ACCOUNT_ID; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.AUDIT_CHECK_CONFIGURATIONS_V1_CFN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.AUDIT_NOTIFICATION_TARGET_CFN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_REQUEST; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_V1_STATE; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_V1_STATE_CFN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_ZERO_STATE; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.ROLE_ARN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.TARGET_ARN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.createCfnRequest; +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.Map; + +import com.google.common.collect.ImmutableMap; + +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.junit.jupiter.MockitoExtension; +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 ReadHandlerTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + private ReadHandler handler; + + @BeforeEach + public void setup() { + handler = new ReadHandler(); + } + + @Test + public void handleRequest_DescribeHasFullConfig_VerifyTranslation() { + + ResourceModel model = ResourceModel.builder() + .accountId("doesn't matter") + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn("doesn't matter") + .build(); + ResourceHandlerRequest cfnRequest = createCfnRequest(model); + when(proxy.injectCredentialsAndInvokeV2(eq(DESCRIBE_REQUEST), any())) + .thenReturn(DESCRIBE_RESPONSE_V1_STATE); + + ProgressEvent actualResult = + handler.handleRequest(proxy, cfnRequest, null, logger); + + assertThat(actualResult).isNotNull(); + assertThat(actualResult.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(actualResult.getCallbackContext()).isNull(); + assertThat(actualResult.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(actualResult.getResourceModels()).isNull(); + assertThat(actualResult.getMessage()).isNull(); + assertThat(actualResult.getErrorCode()).isNull(); + + Map expectedNotificationConfigurations = ImmutableMap.of("SNS", + AuditNotificationTarget.builder().targetArn(TARGET_ARN).enabled(true).roleArn(ROLE_ARN).build()); + ResourceModel expectedResult = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(DESCRIBE_RESPONSE_V1_STATE_CFN) + .auditNotificationTargetConfigurations(expectedNotificationConfigurations) + .roleArn(ROLE_ARN) + .build(); + assertThat(actualResult.getResourceModel()).isEqualTo(expectedResult); + } + + @Test + public void handleRequest_DescribeReturnsZeroState_ExpectNFE() { + + ResourceModel model = ResourceModel.builder() + .accountId("doesn't matter") + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn("doesn't matter") + .build(); + ResourceHandlerRequest cfnRequest = createCfnRequest(model); + when(proxy.injectCredentialsAndInvokeV2(eq(DESCRIBE_REQUEST), any())) + .thenReturn(DESCRIBE_RESPONSE_ZERO_STATE); + + ProgressEvent actualResult = + handler.handleRequest(proxy, cfnRequest, null, logger); + ProgressEvent expectedResult = ProgressEvent.failed( + model, null, + HandlerErrorCode.NotFound, + "The configuration for your account has not been set up or was deleted."); + assertThat(actualResult).isEqualTo(expectedResult); + } + + @Test + public void handleRequest_ExceptionFromDescribe_Translated() { + + ResourceModel model = ResourceModel.builder() + .accountId("doesn't matter") + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V1_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN) + .roleArn("doesn't matter") + .build(); + ResourceHandlerRequest cfnRequest = createCfnRequest(model); + when(proxy.injectCredentialsAndInvokeV2(eq(DESCRIBE_REQUEST), any())) + .thenThrow(UnauthorizedException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, cfnRequest, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.AccessDenied); + } +} diff --git a/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/TestConstants.java b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/TestConstants.java new file mode 100644 index 0000000..b92cce2 --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/TestConstants.java @@ -0,0 +1,92 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import java.util.Map; + +import com.google.common.collect.ImmutableMap; + +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationRequest; +import software.amazon.awssdk.services.iot.model.DescribeAccountAuditConfigurationResponse; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class TestConstants { + + static final String ACCOUNT_ID = "123456789012"; + static final String ROLE_ARN = "testRoleArn"; + static final String TARGET_ARN = "testTargetArn"; + + static final AuditCheckConfiguration ENABLED_CFN = + AuditCheckConfiguration.builder().enabled(true).build(); + static final AuditCheckConfiguration DISABLED_CFN = + AuditCheckConfiguration.builder().enabled(false).build(); + static final Map AUDIT_CHECK_CONFIGURATIONS_V1_CFN = ImmutableMap.of( + "LOGGING_DISABLED_CHECK", ENABLED_CFN, + "CA_CERTIFICATE_EXPIRING_CHECK", ENABLED_CFN); + static final Map AUDIT_CHECK_CONFIGURATIONS_V2_CFN = ImmutableMap.of( + "LOGGING_DISABLED_CHECK", ENABLED_CFN, + "DEVICE_CERTIFICATE_EXPIRING_CHECK", ENABLED_CFN, + "CA_CERTIFICATE_EXPIRING_CHECK", DISABLED_CFN); + static final Map AUDIT_NOTIFICATION_TARGET_CFN = ImmutableMap.of( + "SNS", AuditNotificationTarget.builder().enabled(true) + .targetArn(TARGET_ARN).roleArn(ROLE_ARN).build()); + static final Map AUDIT_NOTIFICATION_TARGET_V2_CFN = ImmutableMap.of( + "SNS", AuditNotificationTarget.builder().enabled(true) + .targetArn(TARGET_ARN + "_v2").roleArn(ROLE_ARN).build()); + + static final software.amazon.awssdk.services.iot.model.AuditCheckConfiguration ENABLED_IOT = + software.amazon.awssdk.services.iot.model.AuditCheckConfiguration.builder().enabled(true).build(); + static final software.amazon.awssdk.services.iot.model.AuditCheckConfiguration DISABLED_IOT = + software.amazon.awssdk.services.iot.model.AuditCheckConfiguration.builder().enabled(false).build(); + static final Map + AUDIT_NOTIFICATION_TARGET_IOT = ImmutableMap.of( + "SNS", getNotificationBuilderIot().enabled(true) + .targetArn(TARGET_ARN).roleArn(ROLE_ARN).build()); + static final Map + AUDIT_NOTIFICATION_TARGET_V2_IOT = ImmutableMap.of( + "SNS", getNotificationBuilderIot().enabled(true) + .targetArn(TARGET_ARN + "_v2").roleArn(ROLE_ARN).build()); + + static final DescribeAccountAuditConfigurationRequest DESCRIBE_REQUEST = + DescribeAccountAuditConfigurationRequest.builder().build(); + + static final Map + DESCRIBE_RESPONSE_ZERO_STATE_CHECKS = ImmutableMap.of( + "CONFLICTING_CLIENT_IDS_CHECK", DISABLED_IOT, + "CA_CERTIFICATE_EXPIRING_CHECK", DISABLED_IOT, + "CA_CERTIFICATE_KEY_QUALITY_CHECK", DISABLED_IOT, + "DEVICE_CERTIFICATE_EXPIRING_CHECK", DISABLED_IOT, + "LOGGING_DISABLED_CHECK", DISABLED_IOT); + static final DescribeAccountAuditConfigurationResponse DESCRIBE_RESPONSE_ZERO_STATE = + DescribeAccountAuditConfigurationResponse.builder() + .auditCheckConfigurations(DESCRIBE_RESPONSE_ZERO_STATE_CHECKS) + .build(); + static final DescribeAccountAuditConfigurationResponse DESCRIBE_RESPONSE_V1_STATE = + DescribeAccountAuditConfigurationResponse.builder() + .auditCheckConfigurations(ImmutableMap.of( + "LOGGING_DISABLED_CHECK", ENABLED_IOT, + "CA_CERTIFICATE_EXPIRING_CHECK", ENABLED_IOT, + "CONFLICTING_CLIENT_IDS_CHECK", DISABLED_IOT, + "CA_CERTIFICATE_KEY_QUALITY_CHECK", DISABLED_IOT, + "DEVICE_CERTIFICATE_EXPIRING_CHECK", DISABLED_IOT)) + .auditNotificationTargetConfigurationsWithStrings(AUDIT_NOTIFICATION_TARGET_IOT) + .roleArn(ROLE_ARN) + .build(); + static final Map DESCRIBE_RESPONSE_V1_STATE_CFN = ImmutableMap.of( + "LOGGING_DISABLED_CHECK", ENABLED_CFN, + "CA_CERTIFICATE_EXPIRING_CHECK", ENABLED_CFN, + "CONFLICTING_CLIENT_IDS_CHECK", DISABLED_CFN, + "CA_CERTIFICATE_KEY_QUALITY_CHECK", DISABLED_CFN, + "DEVICE_CERTIFICATE_EXPIRING_CHECK", DISABLED_CFN); + + static software.amazon.awssdk.services.iot.model.AuditNotificationTarget.Builder + getNotificationBuilderIot() { + return software.amazon.awssdk.services.iot.model.AuditNotificationTarget.builder(); + } + + static ResourceHandlerRequest createCfnRequest(ResourceModel model) { + return ResourceHandlerRequest.builder() + .desiredResourceState(model) + .logicalResourceIdentifier("doesn't matter") + .awsAccountId(ACCOUNT_ID) + .build(); + } +} diff --git a/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/TranslatorTest.java b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/TranslatorTest.java new file mode 100644 index 0000000..8f8c1dc --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/TranslatorTest.java @@ -0,0 +1,74 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.AUDIT_NOTIFICATION_TARGET_CFN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.AUDIT_NOTIFICATION_TARGET_IOT; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_V1_STATE; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_V1_STATE_CFN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.iot.model.InvalidRequestException; +import software.amazon.awssdk.services.iot.model.ResourceNotFoundException; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; + +public class TranslatorTest { + + @Test + public void translateExceptionToErrorCode_IRE_Translated() { + HandlerErrorCode result = Translator.translateExceptionToErrorCode( + InvalidRequestException.builder().build(), mock(Logger.class)); + assertThat(result).isEqualTo(HandlerErrorCode.InvalidRequest); + } + + @Test + public void translateExceptionToErrorCode_UnexpectedRNFE_TranslatedToInternalError() { + HandlerErrorCode result = Translator.translateExceptionToErrorCode( + ResourceNotFoundException.builder().build(), mock(Logger.class)); + assertThat(result).isEqualTo(HandlerErrorCode.InternalFailure); + } + + @Test + void translateChecksFromCfnToIot_NonNull_VerifyTranslation() { + Map expectedResult = + DESCRIBE_RESPONSE_V1_STATE.auditCheckConfigurations(); + ResourceModel input = ResourceModel.builder() + .auditCheckConfigurations(DESCRIBE_RESPONSE_V1_STATE_CFN).build(); + assertThat(Translator.translateChecksFromCfnToIot(input)).isEqualTo(expectedResult); + } + + @Test + void translateChecksFromIotToCfn_NonNull_VerifyTranslation() { + Map input = + DESCRIBE_RESPONSE_V1_STATE.auditCheckConfigurations(); + assertThat(Translator.translateChecksFromIotToCfn(input)).isEqualTo(DESCRIBE_RESPONSE_V1_STATE_CFN); + } + + @Test + void translateNotificationsFromCfnToIot_NonEmpty() { + ResourceModel input = ResourceModel.builder() + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_CFN).build(); + assertThat(Translator.translateNotificationsFromCfnToIot(input)).isEqualTo(AUDIT_NOTIFICATION_TARGET_IOT); + } + + @Test + void translateNotificationsFromCfnToIot_NullIn_EmptyOut() { + ResourceModel input = ResourceModel.builder() + .auditNotificationTargetConfigurations(null).build(); + assertThat(Translator.translateNotificationsFromCfnToIot(input)).isEmpty(); + } + + @Test + void translateNotificationsFromIotToCfn_NonEmpty() { + assertThat(Translator.translateNotificationsFromIotToCfn(AUDIT_NOTIFICATION_TARGET_IOT)) + .isEqualTo(AUDIT_NOTIFICATION_TARGET_CFN); + } + + @Test + void translateNotificationsFromIotToCfn_NullIn_EmptyOut() { + assertThat(Translator.translateNotificationsFromIotToCfn(null)).isEmpty(); + } +} diff --git a/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/UpdateHandlerTest.java b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/UpdateHandlerTest.java new file mode 100644 index 0000000..5b1db44 --- /dev/null +++ b/aws-iot-accountauditconfiguration/src/test/java/com/amazonaws/iot/accountauditconfiguration/UpdateHandlerTest.java @@ -0,0 +1,198 @@ +package com.amazonaws.iot.accountauditconfiguration; + +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.ACCOUNT_ID; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.AUDIT_CHECK_CONFIGURATIONS_V2_CFN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.AUDIT_NOTIFICATION_TARGET_V2_CFN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.AUDIT_NOTIFICATION_TARGET_V2_IOT; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_REQUEST; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_V1_STATE; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DESCRIBE_RESPONSE_ZERO_STATE; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DISABLED_CFN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.DISABLED_IOT; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.ENABLED_IOT; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.ROLE_ARN; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.createCfnRequest; +import static com.amazonaws.iot.accountauditconfiguration.TestConstants.getNotificationBuilderIot; +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import com.google.common.collect.ImmutableMap; + +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.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.iot.model.InternalFailureException; +import software.amazon.awssdk.services.iot.model.IotRequest; +import software.amazon.awssdk.services.iot.model.ThrottlingException; +import software.amazon.awssdk.services.iot.model.UpdateAccountAuditConfigurationRequest; +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 { + + private static final Map CHECK_CONFIGURATION_FROM_EXPECTED_UPDATE_REQUEST = + ImmutableMap.of( + // enable 1, disable 1 + "LOGGING_DISABLED_CHECK", ENABLED_IOT, + "DEVICE_CERTIFICATE_EXPIRING_CHECK", ENABLED_IOT, + "CA_CERTIFICATE_EXPIRING_CHECK", DISABLED_IOT, + "CONFLICTING_CLIENT_IDS_CHECK", DISABLED_IOT, + "CA_CERTIFICATE_KEY_QUALITY_CHECK", DISABLED_IOT); + private static final ResourceModel MODEL_V2 = ResourceModel.builder() + .accountId(ACCOUNT_ID) + .auditCheckConfigurations(AUDIT_CHECK_CONFIGURATIONS_V2_CFN) + .auditNotificationTargetConfigurations(AUDIT_NOTIFICATION_TARGET_V2_CFN) + .roleArn(ROLE_ARN + "_v2") + .build(); + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + private UpdateHandler handler; + + @BeforeEach + public void setup() { + handler = new UpdateHandler(); + } + + @Test + public void handleRequest_DescribeShowsDifferentConfig_UpdateOverwritesEverything() { + + ResourceHandlerRequest cfnRequest = createCfnRequest(MODEL_V2); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(DESCRIBE_RESPONSE_V1_STATE); + + ProgressEvent response = + handler.handleRequest(proxy, cfnRequest, null, logger); + + UpdateAccountAuditConfigurationRequest expectedUpdateRequest = UpdateAccountAuditConfigurationRequest.builder() + .auditCheckConfigurations(CHECK_CONFIGURATION_FROM_EXPECTED_UPDATE_REQUEST) + .auditNotificationTargetConfigurationsWithStrings(AUDIT_NOTIFICATION_TARGET_V2_IOT) + .roleArn(ROLE_ARN + "_v2") + .build(); + ArgumentCaptor iotRequestCaptor = ArgumentCaptor.forClass(IotRequest.class); + verify(proxy, times(2)).injectCredentialsAndInvokeV2(iotRequestCaptor.capture(), any()); + assertThat(iotRequestCaptor.getAllValues().get(1)).isEqualTo(expectedUpdateRequest); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(cfnRequest.getDesiredResourceState()); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void handleRequest_DescribeShowsZeroState_ExpectNFE() { + + ResourceHandlerRequest cfnRequest = createCfnRequest(MODEL_V2); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(DESCRIBE_RESPONSE_ZERO_STATE); + + ProgressEvent actualResult = + handler.handleRequest(proxy, cfnRequest, null, logger); + ProgressEvent expectedResult = ProgressEvent.failed( + MODEL_V2, null, + HandlerErrorCode.NotFound, + "The configuration for your account has not been set up or was deleted."); + assertThat(actualResult).isEqualTo(expectedResult); + } + + @Test + public void buildCheckConfigurationsForUpdate_RequestHasNonExistentCheck_ExpectRetention() { + + Map checks = new HashMap<>(AUDIT_CHECK_CONFIGURATIONS_V2_CFN); + checks.put("NonExistentCheck", DISABLED_CFN); + ResourceModel model = ResourceModel.builder() + .auditCheckConfigurations(checks) + .build(); + + Map actualResult = + handler.buildCheckConfigurationsForUpdate(model, DESCRIBE_RESPONSE_V1_STATE); + + Map expected = + new HashMap<>(CHECK_CONFIGURATION_FROM_EXPECTED_UPDATE_REQUEST); + expected.put("NonExistentCheck", DISABLED_IOT); + + assertThat(actualResult).isEqualTo(expected); + } + + @Test + public void buildNotificationTargetConfigurationsForUpdate_NoneInModel_DisabledInResult() { + + ResourceModel model = ResourceModel.builder() + .auditNotificationTargetConfigurations(null) + .build(); + + Map expectedResult = + ImmutableMap.of("SNS", getNotificationBuilderIot().enabled(false).build()); + Map actualResult = + handler.buildNotificationTargetConfigurationsForUpdate(model); + assertThat(actualResult).isEqualTo(expectedResult); + } + + @Test + public void buildNotificationTargetConfigurationsForUpdate_NonExistentTarget_ExpectRetention() { + + ImmutableMap notifications = ImmutableMap.of( + "NonExistent", AuditNotificationTarget.builder().enabled(false).build()); + ResourceModel model = ResourceModel.builder() + .auditNotificationTargetConfigurations(notifications) + .build(); + + Map expectedResult = + ImmutableMap.of( + "SNS", getNotificationBuilderIot().enabled(false).build(), + "NonExistent", getNotificationBuilderIot().enabled(false).build()); + Map actualResult = + handler.buildNotificationTargetConfigurationsForUpdate(model); + assertThat(actualResult).isEqualTo(expectedResult); + } + + @Test + public void handleRequest_ExceptionFromDescribe_Translated() { + + ResourceHandlerRequest cfnRequest = createCfnRequest(MODEL_V2); + when(proxy.injectCredentialsAndInvokeV2(eq(DESCRIBE_REQUEST), any())) + .thenThrow(InternalFailureException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, cfnRequest, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.InternalFailure); + } + + @Test + public void handleRequest_ExceptionFromUpdate_Translated() { + + ResourceHandlerRequest cfnRequest = createCfnRequest(MODEL_V2); + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(DESCRIBE_RESPONSE_V1_STATE) + .thenThrow(ThrottlingException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, cfnRequest, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.Throttling); + } +} diff --git a/aws-iot-accountauditconfiguration/template.yml b/aws-iot-accountauditconfiguration/template.yml new file mode 100644 index 0000000..374de88 --- /dev/null +++ b/aws-iot-accountauditconfiguration/template.yml @@ -0,0 +1,23 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the AWS::IoT::AccountAuditConfiguration 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.accountauditconfiguration.HandlerWrapper::handleRequest + Runtime: java8 + CodeUri: ./target/aws-iot-accountauditconfiguration-handler-1.0-SNAPSHOT.jar + + TestEntrypoint: + Type: AWS::Serverless::Function + Properties: + Handler: com.amazonaws.iot.accountauditconfiguration.HandlerWrapper::testEntrypoint + Runtime: java8 + CodeUri: ./target/aws-iot-accountauditconfiguration-handler-1.0-SNAPSHOT.jar