From c86314c6beed04641e5f43e706957280222e1368 Mon Sep 17 00:00:00 2001 From: Will Fu <115735916+www-fu@users.noreply.github.com> Date: Tue, 11 Feb 2025 11:01:49 -0500 Subject: [PATCH] Add AWS::Transfer::WebApp handlers (#70) Add resource handler source code for new resource. --- aws-transfer-webapp/.gitignore | 25 ++ aws-transfer-webapp/.rpdk-config | 32 ++ aws-transfer-webapp/README.md | 12 + aws-transfer-webapp/aws-transfer-webapp.json | 229 +++++++++++++ aws-transfer-webapp/docs/README.md | 114 ++++++ .../docs/identityproviderdetails.md | 58 ++++ aws-transfer-webapp/docs/tag.md | 46 +++ .../docs/webappcustomization.md | 66 ++++ aws-transfer-webapp/docs/webappunits.md | 32 ++ aws-transfer-webapp/lombok.config | 1 + aws-transfer-webapp/pom.xml | 276 +++++++++++++++ aws-transfer-webapp/resource-role.yaml | 62 ++++ aws-transfer-webapp/spotbugs-exclude.xml | 15 + .../transfer/webapp/BaseHandlerStd.java | 229 +++++++++++++ .../transfer/webapp/CallbackContext.java | 9 + .../amazon/transfer/webapp/ClientBuilder.java | 10 + .../amazon/transfer/webapp/Configuration.java | 8 + .../amazon/transfer/webapp/Converter.java | 57 +++ .../amazon/transfer/webapp/CreateHandler.java | 163 +++++++++ .../amazon/transfer/webapp/DeleteHandler.java | 52 +++ .../amazon/transfer/webapp/ListHandler.java | 60 ++++ .../transfer/webapp/MockableBaseHandler.java | 24 ++ .../amazon/transfer/webapp/ReadHandler.java | 131 +++++++ .../amazon/transfer/webapp/UpdateHandler.java | 162 +++++++++ .../transfer/webapp/translators/BaseArn.java | 59 ++++ .../webapp/translators/TagHelper.java | 129 +++++++ .../webapp/translators/Translator.java | 144 ++++++++ .../webapp/translators/WebAppArn.java | 46 +++ aws-transfer-webapp/src/resources/log4j2.xml | 17 + .../transfer/webapp/AbstractTestBase.java | 271 +++++++++++++++ .../transfer/webapp/BaseHandlerStdTest.java | 56 +++ .../transfer/webapp/CreateHandlerTest.java | 141 ++++++++ .../transfer/webapp/DeleteHandlerTest.java | 49 +++ .../transfer/webapp/ListHandlerTest.java | 66 ++++ .../transfer/webapp/ReadHandlerTest.java | 85 +++++ .../transfer/webapp/UpdateHandlerTest.java | 324 ++++++++++++++++++ .../webapp/translators/ExtraCoverageTest.java | 42 +++ .../webapp/translators/WebAppArnTest.java | 21 ++ .../src/test/resources/favicon.jpeg | Bin 0 -> 745 bytes .../src/test/resources/favicon.png | Bin 0 -> 378 bytes .../src/test/resources/logo.jpeg | Bin 0 -> 745 bytes .../src/test/resources/logo.png | Bin 0 -> 378 bytes aws-transfer-webapp/template.yml | 24 ++ pom.xml | 1 + 44 files changed, 3348 insertions(+) create mode 100644 aws-transfer-webapp/.gitignore create mode 100644 aws-transfer-webapp/.rpdk-config create mode 100644 aws-transfer-webapp/README.md create mode 100644 aws-transfer-webapp/aws-transfer-webapp.json create mode 100644 aws-transfer-webapp/docs/README.md create mode 100644 aws-transfer-webapp/docs/identityproviderdetails.md create mode 100644 aws-transfer-webapp/docs/tag.md create mode 100644 aws-transfer-webapp/docs/webappcustomization.md create mode 100644 aws-transfer-webapp/docs/webappunits.md create mode 100644 aws-transfer-webapp/lombok.config create mode 100644 aws-transfer-webapp/pom.xml create mode 100644 aws-transfer-webapp/resource-role.yaml create mode 100644 aws-transfer-webapp/spotbugs-exclude.xml create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/BaseHandlerStd.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CallbackContext.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ClientBuilder.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Configuration.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Converter.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CreateHandler.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/DeleteHandler.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ListHandler.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/MockableBaseHandler.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ReadHandler.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/UpdateHandler.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/BaseArn.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/TagHelper.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/Translator.java create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/WebAppArn.java create mode 100644 aws-transfer-webapp/src/resources/log4j2.xml create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/AbstractTestBase.java create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/BaseHandlerStdTest.java create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/CreateHandlerTest.java create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/DeleteHandlerTest.java create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ListHandlerTest.java create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ReadHandlerTest.java create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/UpdateHandlerTest.java create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/ExtraCoverageTest.java create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/WebAppArnTest.java create mode 100644 aws-transfer-webapp/src/test/resources/favicon.jpeg create mode 100644 aws-transfer-webapp/src/test/resources/favicon.png create mode 100644 aws-transfer-webapp/src/test/resources/logo.jpeg create mode 100644 aws-transfer-webapp/src/test/resources/logo.png create mode 100644 aws-transfer-webapp/template.yml diff --git a/aws-transfer-webapp/.gitignore b/aws-transfer-webapp/.gitignore new file mode 100644 index 0000000..7ee414b --- /dev/null +++ b/aws-transfer-webapp/.gitignore @@ -0,0 +1,25 @@ +# macOS +.DS_Store +._* + +# Maven outputs +.classpath + +# IntelliJ +*.iml +.idea +out.java +out/ +.settings +.project + +# auto-generated files +target/ +.hypothesis/ +build/ + +# our logs +rpdk.log* + +# contains credentials +sam-tests/ diff --git a/aws-transfer-webapp/.rpdk-config b/aws-transfer-webapp/.rpdk-config new file mode 100644 index 0000000..47c0ca6 --- /dev/null +++ b/aws-transfer-webapp/.rpdk-config @@ -0,0 +1,32 @@ +{ + "artifact_type": "RESOURCE", + "typeName": "AWS::Transfer::WebApp", + "language": "java", + "runtime": "java17", + "entrypoint": "software.amazon.transfer.webapp.HandlerWrapper::handleRequest", + "testEntrypoint": "software.amazon.transfer.webapp.HandlerWrapper::testEntrypoint", + "settings": { + "version": false, + "subparser_name": null, + "verbose": 0, + "force": false, + "type_name": null, + "artifact_type": null, + "endpoint_url": null, + "region": null, + "target_schemas": [], + "profile": null, + "namespace": [ + "software", + "amazon", + "transfer", + "webapp" + ], + "codegen_template_path": "guided_aws", + "protocolVersion": "2.0.0" + }, + "logProcessorEnabled": "true", + "executableEntrypoint": "software.amazon.transfer.webapp.HandlerWrapperExecutable", + "contractSettings": {}, + "canarySettings": {} +} diff --git a/aws-transfer-webapp/README.md b/aws-transfer-webapp/README.md new file mode 100644 index 0000000..d80567e --- /dev/null +++ b/aws-transfer-webapp/README.md @@ -0,0 +1,12 @@ +# AWS::Transfer::WebApp + +Congratulations on starting development! Next steps: + +1. Write the JSON schema describing your resource, `aws-transfer-webapp.json` +1. Implement your resource handlers. + +The RPDK will automatically generate the correct resource model from the schema whenever the project is built via Maven. You can also do this manually with the following command: `cfn generate`. + +> Please don't modify files under `target/generated-sources/rpdk`, as they will be automatically overwritten. + +The code uses [Lombok](https://projectlombok.org/), and [you may have to install IDE integrations](https://projectlombok.org/setup/overview) to enable auto-complete for Lombok-annotated classes. diff --git a/aws-transfer-webapp/aws-transfer-webapp.json b/aws-transfer-webapp/aws-transfer-webapp.json new file mode 100644 index 0000000..1d390dc --- /dev/null +++ b/aws-transfer-webapp/aws-transfer-webapp.json @@ -0,0 +1,229 @@ +{ + "typeName": "AWS::Transfer::WebApp", + "description": "Resource Type definition for AWS::Transfer::WebApp", + "definitions": { + "IdentityProviderDetails": { + "type": "object", + "description": "You can provide a structure that contains the details for the identity provider to use with your web app.", + "properties": { + "ApplicationArn": { + "type": "string", + "maxLength": 1224, + "minLength": 10, + "pattern": "^arn:[\\w-]+:sso::\\d{12}:application/(sso)?ins-[a-zA-Z0-9-.]{16}/apl-[a-zA-Z0-9]{16}$" + }, + "InstanceArn": { + "type": "string", + "description": "The Amazon Resource Name (ARN) for the IAM Identity Center used for the web app.", + "maxLength": 1224, + "minLength": 10, + "pattern": "^arn:[\\w-]+:sso:::instance/(sso)?ins-[a-zA-Z0-9-.]{16}$" + }, + "Role": { + "type": "string", + "description": "The IAM role in IAM Identity Center used for the web app.", + "maxLength": 2048, + "minLength": 20, + "pattern": "^arn:[a-z-]+:iam::[0-9]{12}:role[:/]\\S+$" + } + }, + "additionalProperties": false + }, + "Tag": { + "type": "object", + "description": "Key-value pair that can be used to group and search for web apps.", + "properties": { + "Key": { + "type": "string", + "maxLength": 128, + "minLength": 0 + }, + "Value": { + "type": "string", + "maxLength": 256, + "minLength": 0 + } + }, + "required": [ + "Key", + "Value" + ], + "additionalProperties": false + }, + "WebAppCustomization": { + "type": "object", + "properties": { + "Title": { + "description": "Specifies a title to display on the web app.", + "type": "string", + "maxLength": 100, + "minLength": 0 + }, + "LogoFile": { + "description": "Specifies a logo to display on the web app.", + "type": "string", + "minLength": 1, + "maxLength": 51200 + }, + "FaviconFile": { + "description": "Specifies a favicon to display in the browser tab.", + "type": "string", + "minLength": 1, + "maxLength": 20960 + } + }, + "additionalProperties": false + }, + "WebAppUnits": { + "oneOf": [ + { + "type": "object", + "description": "A union that contains the value for number of concurrent connections or the user sessions on your web app.", + "title": "Provisioned", + "properties": { + "Provisioned": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "Provisioned" + ], + "additionalProperties": false + } + ] + } + }, + "properties": { + "Arn": { + "description": "Specifies the unique Amazon Resource Name (ARN) for the web app.", + "type": "string", + "pattern": "arn:.*", + "minLength": 20, + "maxLength": 1600 + }, + "WebAppId": { + "description": "A unique identifier for the web app.", + "type": "string", + "pattern": "^webapp-([0-9a-f]{17})$", + "minLength": 24, + "maxLength": 24 + }, + "IdentityProviderDetails": { + "$ref": "#/definitions/IdentityProviderDetails" + }, + "AccessEndpoint": { + "description": "The AccessEndpoint is the URL that you provide to your users for them to interact with the Transfer Family web app. You can specify a custom URL or use the default value.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "WebAppUnits": { + "$ref": "#/definitions/WebAppUnits" + }, + "WebAppCustomization": { + "$ref": "#/definitions/WebAppCustomization" + }, + "Tags": { + "type": "array", + "description": "Key-value pairs that can be used to group and search for web apps.", + "maxItems": 50, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "additionalProperties": false, + "required": [ + "IdentityProviderDetails" + ], + "readOnlyProperties": [ + "/properties/Arn", + "/properties/WebAppId", + "/properties/IdentityProviderDetails/ApplicationArn" + ], + "createOnlyProperties": [ + "/properties/IdentityProviderDetails/InstanceArn" + ], + "primaryIdentifier": [ + "/properties/Arn" + ], + "additionalIdentifiers": [ + [ + "/properties/WebAppId" + ] + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags", + "permissions": [ + "transfer:TagResource", + "transfer:UnTagResource", + "transfer:ListTagsForResource" + ] + }, + "handlers": { + "create": { + "permissions": [ + "transfer:CreateWebApp", + "transfer:DescribeWebApp", + "transfer:DescribeWebAppCustomization", + "transfer:TagResource", + "transfer:UpdateWebAppCustomization", + "iam:PassRole", + "sso:CreateApplication", + "sso:DescribeApplication", + "sso:ListApplications", + "sso:PutApplicationGrant", + "sso:GetApplicationGrant", + "sso:ListApplicationGrants", + "sso:PutApplicationAuthenticationMethod", + "sso:GetApplicationAuthenticationMethod", + "sso:ListApplicationAuthenticationMethods", + "sso:PutApplicationAccessScope", + "sso:GetApplicationAccessScope", + "sso:ListApplicationAccessScopes" + ] + }, + "read": { + "permissions": [ + "transfer:DescribeWebApp", + "transfer:DescribeWebAppCustomization" + ] + }, + "update": { + "permissions": [ + "transfer:DescribeWebApp", + "transfer:DescribeWebAppCustomization", + "transfer:UpdateWebApp", + "transfer:UpdateWebAppCustomization", + "transfer:DeleteWebAppCustomization", + "transfer:UnTagResource", + "transfer:TagResource", + "iam:PassRole", + "sso:PutApplicationGrant", + "sso:GetApplicationGrant", + "sso:ListApplicationGrants", + "sso:UpdateApplication", + "sso:DescribeApplication", + "sso:ListApplications" + ] + }, + "delete": { + "permissions": [ + "transfer:DeleteWebApp", + "sso:DescribeApplication", + "sso:DeleteApplication" + ] + }, + "list": { + "permissions": [ + "transfer:ListWebApps" + ] + } + } +} diff --git a/aws-transfer-webapp/docs/README.md b/aws-transfer-webapp/docs/README.md new file mode 100644 index 0000000..fa4a82a --- /dev/null +++ b/aws-transfer-webapp/docs/README.md @@ -0,0 +1,114 @@ +# AWS::Transfer::WebApp + +Resource Type definition for AWS::Transfer::WebApp + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Type" : "AWS::Transfer::WebApp",
+    "Properties" : {
+        "IdentityProviderDetails" : IdentityProviderDetails,
+        "AccessEndpoint" : String,
+        "WebAppUnits" : WebAppUnits,
+        "WebAppCustomization" : WebAppCustomization,
+        "Tags" : [ Tag, ... ]
+    }
+}
+
+ +### YAML + +
+Type: AWS::Transfer::WebApp
+Properties:
+    IdentityProviderDetails: IdentityProviderDetails
+    AccessEndpoint: String
+    WebAppUnits: WebAppUnits
+    WebAppCustomization: WebAppCustomization
+    Tags: 
+      - Tag
+
+ +## Properties + +#### IdentityProviderDetails + +You can provide a structure that contains the details for the identity provider to use with your web app. + +_Required_: Yes + +_Type_: IdentityProviderDetails + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### AccessEndpoint + +The AccessEndpoint is the URL that you provide to your users for them to interact with the Transfer Family web app. You can specify a custom URL or use the default value. + +_Required_: No + +_Type_: String + +_Minimum Length_: 1 + +_Maximum Length_: 1024 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### WebAppUnits + +A union that contains the value for number of concurrent connections or the user sessions on your web app. + +_Required_: No + +_Type_: WebAppUnits + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### WebAppCustomization + +_Required_: No + +_Type_: WebAppCustomization + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Tags + +Key-value pairs that can be used to group and search for web apps. + +_Required_: No + +_Type_: List of Tag + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +## Return Values + +### Ref + +When you pass the logical ID of this resource to the intrinsic `Ref` function, Ref returns the Arn. + +### Fn::GetAtt + +The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values. + +For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html). + +#### Arn + +Specifies the unique Amazon Resource Name (ARN) for the web app. + +#### WebAppId + +A unique identifier for the web app. + +#### ApplicationArn + +Returns the ApplicationArn value. + diff --git a/aws-transfer-webapp/docs/identityproviderdetails.md b/aws-transfer-webapp/docs/identityproviderdetails.md new file mode 100644 index 0000000..2805be8 --- /dev/null +++ b/aws-transfer-webapp/docs/identityproviderdetails.md @@ -0,0 +1,58 @@ +# AWS::Transfer::WebApp IdentityProviderDetails + +You can provide a structure that contains the details for the identity provider to use with your web app. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "InstanceArn" : String,
+    "Role" : String
+}
+
+ +### YAML + +
+InstanceArn: String
+Role: String
+
+ +## Properties + +#### InstanceArn + +The Amazon Resource Name (ARN) for the IAM Identity Center used for the web app. + +_Required_: No + +_Type_: String + +_Minimum Length_: 10 + +_Maximum Length_: 1224 + +_Pattern_: ^arn:[\w-]+:sso:::instance/(sso)?ins-[a-zA-Z0-9-.]{16}$ + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### Role + +The IAM role in IAM Identity Center used for the web app. + +_Required_: No + +_Type_: String + +_Minimum Length_: 20 + +_Maximum Length_: 2048 + +_Pattern_: ^arn:[a-z-]+:iam::[0-9]{12}:role[:/]\S+$ + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-transfer-webapp/docs/tag.md b/aws-transfer-webapp/docs/tag.md new file mode 100644 index 0000000..9331c70 --- /dev/null +++ b/aws-transfer-webapp/docs/tag.md @@ -0,0 +1,46 @@ +# AWS::Transfer::WebApp Tag + +Key-value pair that can be used to group and search for web apps. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Key" : String,
+    "Value" : String
+}
+
+ +### YAML + +
+Key: String
+Value: String
+
+ +## Properties + +#### Key + +_Required_: Yes + +_Type_: String + +_Maximum Length_: 128 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Value + +_Required_: Yes + +_Type_: String + +_Maximum Length_: 256 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-transfer-webapp/docs/webappcustomization.md b/aws-transfer-webapp/docs/webappcustomization.md new file mode 100644 index 0000000..daeeebc --- /dev/null +++ b/aws-transfer-webapp/docs/webappcustomization.md @@ -0,0 +1,66 @@ +# AWS::Transfer::WebApp WebAppCustomization + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Title" : String,
+    "LogoFile" : String,
+    "FaviconFile" : String
+}
+
+ +### YAML + +
+Title: String
+LogoFile: String
+FaviconFile: String
+
+ +## Properties + +#### Title + +Specifies a title to display on the web app. + +_Required_: No + +_Type_: String + +_Maximum Length_: 100 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### LogoFile + +Specifies a logo to display on the web app. + +_Required_: No + +_Type_: String + +_Minimum Length_: 1 + +_Maximum Length_: 51200 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### FaviconFile + +Specifies a favicon to display in the browser tab. + +_Required_: No + +_Type_: String + +_Minimum Length_: 1 + +_Maximum Length_: 20960 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-transfer-webapp/docs/webappunits.md b/aws-transfer-webapp/docs/webappunits.md new file mode 100644 index 0000000..c79a5e8 --- /dev/null +++ b/aws-transfer-webapp/docs/webappunits.md @@ -0,0 +1,32 @@ +# AWS::Transfer::WebApp WebAppUnits + +A union that contains the value for number of concurrent connections or the user sessions on your web app. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Provisioned" : Integer
+}
+
+ +### YAML + +
+Provisioned: Integer
+
+ +## Properties + +#### Provisioned + +_Required_: Yes + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-transfer-webapp/lombok.config b/aws-transfer-webapp/lombok.config new file mode 100644 index 0000000..7a21e88 --- /dev/null +++ b/aws-transfer-webapp/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/aws-transfer-webapp/pom.xml b/aws-transfer-webapp/pom.xml new file mode 100644 index 0000000..d81ceab --- /dev/null +++ b/aws-transfer-webapp/pom.xml @@ -0,0 +1,276 @@ + + + 4.0.0 + + software.amazon.transfer.webapp + aws-transfer-webapp-handler + aws-transfer-webapp-handler + 1.0-SNAPSHOT + jar + + + 17 + 17 + UTF-8 + UTF-8 + + + + + + + software.amazon.awssdk + transfer + 2.29.30 + + + + software.amazon.cloudformation + aws-cloudformation-rpdk-java-plugin + [2.0.0,3.0.0) + + + + org.projectlombok + lombok + 1.18.30 + provided + + + + org.apache.logging.log4j + log4j-api + 2.22.0 + + + + org.apache.logging.log4j + log4j-core + 2.22.0 + + + + org.apache.logging.log4j + log4j-slf4j-impl + 2.22.0 + + + + + org.assertj + assertj-core + 3.24.2 + test + + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + + org.mockito + mockito-core + 5.8.0 + test + + + + org.mockito + mockito-junit-jupiter + 5.8.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + -Xlint:all,-options,-processing + -Werror + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + false + + + *:* + + **/Log4j2Plugins.dat + + + + + + + package + + shade + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.1 + + + generate + generate-sources + + exec + + + cfn + generate ${cfn.generate.args} + ${project.basedir} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.5.0 + + + add-source + generate-sources + + add-source + + + + ${project.basedir}/target/generated-sources/rpdk + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + maven-surefire-plugin + 3.2.3 + + + true + + + + + org.jacoco + jacoco-maven-plugin + 0.8.11 + + + **/BaseConfiguration* + **/BaseHandler* + **/Configuration* + **/HandlerWrapper* + **/ResourceModel* + **/ClientBuilder* + **/CallBackContext* + **/Converter* + + + + + + prepare-agent + + + + report + test + + report + + + + jacoco-check + + check + + + + + PACKAGE + + + BRANCH + COVEREDRATIO + 0.75 + + + INSTRUCTION + COVEREDRATIO + 0.8 + + + + + + + + + + com.diffplug.spotless + spotless-maven-plugin + 2.43.0 + + + + check + + + + + + + + + + java,javax,com,org,amazon,software.amazon,com.amazon,com.amazonaws + + + + + + + + + + ${project.basedir} + + aws-transfer-webapp.json + + + + ${project.basedir}/target/loaded-target-schemas + + **/*.json + + + + + diff --git a/aws-transfer-webapp/resource-role.yaml b/aws-transfer-webapp/resource-role.yaml new file mode 100644 index 0000000..8b829dc --- /dev/null +++ b/aws-transfer-webapp/resource-role.yaml @@ -0,0 +1,62 @@ +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 + Condition: + StringEquals: + aws:SourceAccount: + Ref: AWS::AccountId + StringLike: + aws:SourceArn: + Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/AWS-Transfer-WebApp/* + Path: "/" + Policies: + - PolicyName: ResourceTypePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - "iam:PassRole" + - "sso:CreateApplication" + - "sso:DeleteApplication" + - "sso:DescribeApplication" + - "sso:GetApplicationAccessScope" + - "sso:GetApplicationAuthenticationMethod" + - "sso:GetApplicationGrant" + - "sso:ListApplicationAccessScopes" + - "sso:ListApplicationAuthenticationMethods" + - "sso:ListApplicationGrants" + - "sso:ListApplications" + - "sso:PutApplicationAccessScope" + - "sso:PutApplicationAuthenticationMethod" + - "sso:PutApplicationGrant" + - "sso:UpdateApplication" + - "transfer:CreateWebApp" + - "transfer:DeleteWebApp" + - "transfer:DeleteWebAppCustomization" + - "transfer:DescribeWebApp" + - "transfer:DescribeWebAppCustomization" + - "transfer:ListWebApps" + - "transfer:TagResource" + - "transfer:UnTagResource" + - "transfer:UpdateWebApp" + - "transfer:UpdateWebAppCustomization" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/aws-transfer-webapp/spotbugs-exclude.xml b/aws-transfer-webapp/spotbugs-exclude.xml new file mode 100644 index 0000000..adf9de4 --- /dev/null +++ b/aws-transfer-webapp/spotbugs-exclude.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/BaseHandlerStd.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/BaseHandlerStd.java new file mode 100644 index 0000000..13c4364 --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/BaseHandlerStd.java @@ -0,0 +1,229 @@ +package software.amazon.transfer.webapp; + +import java.util.Map; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.transfer.TransferClient; +import software.amazon.awssdk.services.transfer.model.AccessDeniedException; +import software.amazon.awssdk.services.transfer.model.InvalidNextTokenException; +import software.amazon.awssdk.services.transfer.model.InvalidRequestException; +import software.amazon.awssdk.services.transfer.model.ResourceExistsException; +import software.amazon.awssdk.services.transfer.model.ResourceNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.transfer.webapp.translators.TagHelper; +import software.amazon.transfer.webapp.translators.Translator; + +/** + * The purpose of this base class is to allow a simple calling pattern that + * makes testing Uluru handlers easier and avoids having to store context + * in class fields. The {@link MockableBaseHandler} interface is used to + * make isolation of the inner call such that in testing we guarantee that + * the caller will not pick the wrong method and avoid human error. + */ +public abstract class BaseHandlerStd extends BaseHandler { + protected static final String CREATE = "Create"; + protected static final String DELETE = "Delete"; + protected static final String READ = "Read"; + protected static final String LIST = "List"; + protected static final String UPDATE = "Update"; + private static final String FAILURE_LOG_MESSAGE = + "[ClientRequestToken: %s] Resource %s failed in %s operation, Error: %s%n"; + private static final String ACCESS_DENIED_ERROR_CODE = "AccessDenied"; + protected Logger logger; + + @Override + public final ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final Logger logger) { + this.logger = logger; + + return handleRequest( + proxy, + request, + callbackContext != null ? callbackContext : new CallbackContext(), + proxy.newProxy(ClientBuilder::getClient), + logger); + } + + protected abstract ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger); + + /** + * Handle any service operation errors here. + * + * @param operation the operation name + * @param exception the error + * @param model ResourceModel of Listener + * @param context Callback context from request + * @param clientRequestToken the request token + * @return ProgressEvent + */ + protected ProgressEvent handleError( + final String operation, + final Exception exception, + final ResourceModel model, + final CallbackContext context, + final String clientRequestToken) { + + if (exception instanceof ResourceExistsException) { + return translateToFailure(operation, exception, HandlerErrorCode.AlreadyExists, model, clientRequestToken); + } + + if (exception instanceof ResourceNotFoundException) { + return translateToFailure(operation, exception, HandlerErrorCode.NotFound, model, clientRequestToken); + } + + if (exception instanceof AccessDeniedException) { + return translateToFailure(operation, exception, HandlerErrorCode.AccessDenied, model, clientRequestToken); + } + + if (exception instanceof InvalidRequestException) { + return translateToFailure(operation, exception, HandlerErrorCode.InvalidRequest, model, clientRequestToken); + } + + if (exception instanceof InvalidNextTokenException) { + return translateToFailure(operation, exception, HandlerErrorCode.InvalidRequest, model, clientRequestToken); + } + + return translateToFailure( + operation, exception, HandlerErrorCode.GeneralServiceException, model, clientRequestToken); + } + + private ProgressEvent translateToFailure( + String operation, + Exception exception, + HandlerErrorCode errorCode, + ResourceModel model, + String clientRequestToken) { + logger.log(String.format( + FAILURE_LOG_MESSAGE, clientRequestToken, model.getPrimaryIdentifier(), operation, exception)); + return ProgressEvent.defaultFailureHandler(exception, errorCode); + } + + private String getErrorCode(Exception e) { + if (e instanceof AwsServiceException && ((AwsServiceException) e).awsErrorDetails() != null) { + return ((AwsServiceException) e).awsErrorDetails().errorCode(); + } + return e.getMessage(); + } + + protected void log(String message, Object identifier) { + logger.log(String.format("%s [%s] %s", ResourceModel.TYPE_NAME, identifier, message)); + } + + protected ProgressEvent addTags( + ProgressEvent progress, + ResourceHandlerRequest request, + ResourceModel resourceModel, + AmazonWebServicesClientProxy proxy, + ProxyClient proxyClient, + CallbackContext callbackContext) { + if (TagHelper.shouldUpdateTags(request)) { + + Map previousTags = TagHelper.getPreviouslyAttachedTags(request); + Map desiredTags = TagHelper.getNewDesiredTags(request); + Map tagsToAdd = TagHelper.generateTagsToAdd(previousTags, desiredTags); + + if (!tagsToAdd.isEmpty()) { + progress = tagResource(proxy, proxyClient, resourceModel, request, callbackContext, tagsToAdd); + } + } + return progress; + } + + private ProgressEvent tagResource( + final AmazonWebServicesClientProxy proxy, + final ProxyClient serviceClient, + final ResourceModel resourceModel, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final Map addedTags) { + logger.log(String.format( + "[UPDATE][IN PROGRESS] Going to add tags for web app: %s with AccountId: %s", + resourceModel.getWebAppId(), request.getAwsAccountId())); + final String clientRequestToken = request.getClientRequestToken(); + + return proxy.initiate("AWS-Transfer-Web-Apps::TagOps", serviceClient, resourceModel, callbackContext) + .translateToServiceRequest(model -> Translator.tagResourceRequest(model, addedTags)) + .makeServiceCall((tagRequest, client) -> { + try (TransferClient transferClient = client.client()) { + return client.injectCredentialsAndInvokeV2(tagRequest, transferClient::tagResource); + } + }) + .handleError((ignored, exception, proxyClient, model, context) -> { + if (isEnvironmentTaggingException(exception)) { + return ProgressEvent.failed( + model, context, HandlerErrorCode.UnauthorizedTaggingOperation, exception.getMessage()); + } + return handleError(UPDATE, exception, model, context, clientRequestToken); + }) + .progress(); + } + + protected ProgressEvent removeTags( + ProgressEvent progress, + ResourceHandlerRequest request, + ResourceModel resourceModel, + AmazonWebServicesClientProxy proxy, + ProxyClient proxyClient, + CallbackContext callbackContext) { + if (TagHelper.shouldUpdateTags(request)) { + + Map previousTags = TagHelper.getPreviouslyAttachedTags(request); + Map desiredTags = TagHelper.getNewDesiredTags(request); + Set tagsKeysToRemove = TagHelper.generateTagsToRemove(previousTags, desiredTags); + + if (!tagsKeysToRemove.isEmpty()) { + progress = untagResource(proxy, proxyClient, resourceModel, request, callbackContext, tagsKeysToRemove); + } + } + return progress; + } + + private ProgressEvent untagResource( + final AmazonWebServicesClientProxy proxy, + final ProxyClient serviceClient, + final ResourceModel resourceModel, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final Set removedTags) { + logger.log(String.format( + "[UPDATE][IN PROGRESS] Going to remove tags for web app: %s with AccountId: %s", + resourceModel.getWebAppId(), request.getAwsAccountId())); + final String clientRequestToken = request.getClientRequestToken(); + + return proxy.initiate("AWS-Transfer-Web-App::TagOps", serviceClient, resourceModel, callbackContext) + .translateToServiceRequest(model -> Translator.untagResourceRequest(model, removedTags)) + .makeServiceCall((untagRequest, client) -> { + try (TransferClient transferClient = client.client()) { + return client.injectCredentialsAndInvokeV2(untagRequest, transferClient::untagResource); + } + }) + .handleError((ignored, exception, proxyClient, model, context) -> { + if (isEnvironmentTaggingException(exception)) { + return ProgressEvent.failed( + model, context, HandlerErrorCode.UnauthorizedTaggingOperation, exception.getMessage()); + } + return handleError(UPDATE, exception, model, context, clientRequestToken); + }) + .progress(); + } + + private boolean isEnvironmentTaggingException(Exception e) { + return StringUtils.equals(ACCESS_DENIED_ERROR_CODE, getErrorCode(e)); + } +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CallbackContext.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CallbackContext.java new file mode 100644 index 0000000..f68055b --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CallbackContext.java @@ -0,0 +1,9 @@ +package software.amazon.transfer.webapp; + +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-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ClientBuilder.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ClientBuilder.java new file mode 100644 index 0000000..3d5c65b --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ClientBuilder.java @@ -0,0 +1,10 @@ +package software.amazon.transfer.webapp; + +import software.amazon.awssdk.services.transfer.TransferClient; +import software.amazon.cloudformation.LambdaWrapper; + +public class ClientBuilder { + public static TransferClient getClient() { + return TransferClient.builder().httpClient(LambdaWrapper.HTTP_CLIENT).build(); + } +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Configuration.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Configuration.java new file mode 100644 index 0000000..66fd479 --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Configuration.java @@ -0,0 +1,8 @@ +package software.amazon.transfer.webapp; + +class Configuration extends BaseConfiguration { + + public Configuration() { + super("aws-transfer-webapp.json"); + } +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Converter.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Converter.java new file mode 100644 index 0000000..eb4b37e --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Converter.java @@ -0,0 +1,57 @@ +package software.amazon.transfer.webapp; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import software.amazon.awssdk.services.transfer.model.Tag; + +public class Converter { + static class TagConverter { + static Tag toSdk(software.amazon.transfer.webapp.Tag tag) { + if (tag == null) { + return null; + } + return Tag.builder().key(tag.getKey()).value(tag.getValue()).build(); + } + + static List translateTagfromMap(Map tags) { + if (tags == null) { + return List.of(); + } + + return tags.entrySet().stream() + .map(tag -> software.amazon.transfer.webapp.Tag.builder() + .key(tag.getKey()) + .value(tag.getValue()) + .build()) + .toList(); + } + + static software.amazon.transfer.webapp.Tag fromSdk(Tag tag) { + if (tag == null) { + return null; + } + return software.amazon.transfer.webapp.Tag.builder() + .key(tag.key()) + .value(tag.value()) + .build(); + } + } + + static class DateConverter { + static Instant toSdk(String date) { + if (date == null) { + return null; + } + return Instant.parse(date); + } + + static String fromSdk(Instant date) { + if (date == null) { + return null; + } + return date.toString(); + } + } +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CreateHandler.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CreateHandler.java new file mode 100644 index 0000000..980398b --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CreateHandler.java @@ -0,0 +1,163 @@ +package software.amazon.transfer.webapp; + +import static software.amazon.transfer.webapp.translators.TagHelper.setDesiredTags; + +import java.util.Base64; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; + +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.transfer.TransferClient; +import software.amazon.awssdk.services.transfer.model.CreateWebAppRequest; +import software.amazon.awssdk.services.transfer.model.CreateWebAppResponse; +import software.amazon.awssdk.services.transfer.model.IdentityCenterConfig; +import software.amazon.awssdk.services.transfer.model.Tag; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationRequest; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationResponse; +import software.amazon.awssdk.services.transfer.model.WebAppIdentityProviderDetails; +import software.amazon.awssdk.services.transfer.model.WebAppUnits; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.transfer.webapp.translators.WebAppArn; + +import com.amazonaws.regions.Region; +import com.amazonaws.regions.RegionUtils; + +public class CreateHandler extends BaseHandlerStd { + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final String clientRequestToken = request.getClientRequestToken(); + ResourceModel newModel = request.getDesiredResourceState(); + + setDesiredTags(request, newModel); + + return ProgressEvent.progress(newModel, callbackContext) + .then(progress -> proxy.initiate( + "AwS-Transfer-Web-App::Create", + proxyClient, + progress.getResourceModel(), + progress.getCallbackContext()) + .translateToServiceRequest(this::translateToCreateRequest) + .makeServiceCall(this::createWebApp) + .stabilize((ignored, response, client, model, context) -> + stabilizeAfterCreate(request, response, client, model, context)) + .handleError((ignored, exception, client, model, context) -> + handleError(CREATE, exception, model, context, clientRequestToken)) + .progress()) + .then(progress -> { + // Only proceed with customization update if WebAppCustomization exists + if (progress.getResourceModel().getWebAppCustomization() != null) { + return proxy.initiate( + "AWS-Transfer-Web-App::UpdateCustomization", + proxyClient, + progress.getResourceModel(), + progress.getCallbackContext()) + .translateToServiceRequest(this::translateToUpdateRequest) + .makeServiceCall(this::updateWebAppCustomization) + .handleError((ignored, exception, client, model, context) -> + handleError(UPDATE, exception, model, context, clientRequestToken)) + .progress(); + } + return progress; + }) + .then(progress -> + new ReadHandler().handleRequest(proxy, request, callbackContext, proxyClient, logger)); + } + + private CreateWebAppRequest translateToCreateRequest(final ResourceModel model) { + return CreateWebAppRequest.builder() + .accessEndpoint(model.getAccessEndpoint()) + .identityProviderDetails(WebAppIdentityProviderDetails.builder() + .identityCenterConfig(IdentityCenterConfig.builder() + .instanceArn(model.getIdentityProviderDetails().getInstanceArn()) + .role(model.getIdentityProviderDetails().getRole()) + .build()) + .build()) + .webAppUnits(WebAppUnits.builder() + .provisioned( + model.getWebAppUnits() != null + ? model.getWebAppUnits().getProvisioned() + : 1) + .build()) + .tags( + model.getTags() == null + ? null + : model.getTags().stream() + .map(tag -> Tag.builder() + .key(tag.getKey()) + .value(tag.getValue()) + .build()) + .collect(Collectors.toList())) + .build(); + } + + private CreateWebAppResponse createWebApp(CreateWebAppRequest awsRequest, ProxyClient client) { + try (TransferClient transferClient = client.client()) { + CreateWebAppResponse response = + client.injectCredentialsAndInvokeV2(awsRequest, transferClient::createWebApp); + log("web app created successfully", response.webAppId()); + return response; + } + } + + private boolean stabilizeAfterCreate( + ResourceHandlerRequest request, + CreateWebAppResponse awsResponse, + ProxyClient ignored1, + ResourceModel model, + CallbackContext ignored2) { + String webAppId = awsResponse.webAppId(); + Region region = RegionUtils.getRegion(request.getRegion()); + WebAppArn webAppArn = new WebAppArn(region, request.getAwsAccountId(), webAppId); + model.setArn(webAppArn.getArn()); + model.setWebAppId(webAppId); + + return true; + } + + private UpdateWebAppCustomizationRequest translateToUpdateRequest(final ResourceModel model) { + if (StringUtils.isBlank(model.getWebAppId())) { + WebAppArn webappArn = WebAppArn.fromString(model.getArn()); + model.setWebAppId(webappArn.getWebAppId()); + } + + return UpdateWebAppCustomizationRequest.builder() + .webAppId(model.getWebAppId()) + .title(model.getWebAppCustomization().getTitle()) + .logoFile(convertFileToSdkBytes(model.getWebAppCustomization().getLogoFile())) + .faviconFile( + convertFileToSdkBytes(model.getWebAppCustomization().getFaviconFile())) + .build(); + } + + private UpdateWebAppCustomizationResponse updateWebAppCustomization( + UpdateWebAppCustomizationRequest awsRequest, ProxyClient client) { + try (TransferClient transferClient = client.client()) { + UpdateWebAppCustomizationResponse response = + client.injectCredentialsAndInvokeV2(awsRequest, transferClient::updateWebAppCustomization); + log("web app customization updated successfully", response.webAppId()); + return response; + } + } + + private SdkBytes convertFileToSdkBytes(String fileData) { + if (fileData == null) { + return null; + } + // Decode Base64 string to byte array, then convert to SdkBytes + return SdkBytes.fromByteArray(Base64.getDecoder().decode(fileData)); + } +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/DeleteHandler.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/DeleteHandler.java new file mode 100644 index 0000000..08c5e04 --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/DeleteHandler.java @@ -0,0 +1,52 @@ +package software.amazon.transfer.webapp; + +import software.amazon.awssdk.services.transfer.TransferClient; +import software.amazon.awssdk.services.transfer.model.DeleteWebAppRequest; +import software.amazon.awssdk.services.transfer.model.DeleteWebAppResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.transfer.webapp.translators.Translator; + +public class DeleteHandler extends BaseHandlerStd { + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + this.logger = logger; + + final String clientRequestToken = request.getClientRequestToken(); + final ResourceModel newModel = request.getDesiredResourceState(); + Translator.ensureWebAppIdInModel(newModel); + + return ProgressEvent.progress(newModel, callbackContext) + .then(progress -> proxy.initiate( + "AWS-Transfer-Web-App::Delete", + proxyClient, + progress.getResourceModel(), + progress.getCallbackContext()) + .translateToServiceRequest(DeleteHandler::translateToDeleteRequest) + .makeServiceCall(this::deleteWebApp) + .handleError((ignored, exception, client, model, context) -> + handleError(DELETE, exception, model, context, clientRequestToken)) + .progress()) + .then(progress -> ProgressEvent.defaultSuccessHandler(null)); + } + + private static DeleteWebAppRequest translateToDeleteRequest(final ResourceModel model) { + return DeleteWebAppRequest.builder().webAppId(model.getWebAppId()).build(); + } + + private DeleteWebAppResponse deleteWebApp(DeleteWebAppRequest request, ProxyClient client) { + try (TransferClient transferClient = client.client()) { + DeleteWebAppResponse response = client.injectCredentialsAndInvokeV2(request, transferClient::deleteWebApp); + log("successfully deleted.", request.webAppId()); + return response; + } + } +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ListHandler.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ListHandler.java new file mode 100644 index 0000000..f05f316 --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ListHandler.java @@ -0,0 +1,60 @@ +package software.amazon.transfer.webapp; + +import java.util.List; + +import software.amazon.awssdk.services.transfer.TransferClient; +import software.amazon.awssdk.services.transfer.model.ListWebAppsRequest; +import software.amazon.awssdk.services.transfer.model.ListWebAppsResponse; +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.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.transfer.webapp.translators.Translator; + +public class ListHandler extends BaseHandlerStd { + + @Override + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + this.logger = logger; + + final String clientRequestToken = request.getClientRequestToken(); + final ResourceModel resourceModel = request.getDesiredResourceState(); + + return proxy.initiate("AWS-Transfer-Web-App::List", proxyClient, resourceModel, callbackContext) + .translateToServiceRequest(m -> translateToListRequest(request.getNextToken())) + .makeServiceCall(this::listWebApps) + .handleError((ignored, exception, client, model, context) -> + handleError(LIST, exception, model, context, clientRequestToken)) + .done(response -> ProgressEvent.builder() + .resourceModels(translateFromListResponce(response)) + .nextToken(response.nextToken()) + .status(OperationStatus.SUCCESS) + .build()); + } + + private ListWebAppsRequest translateToListRequest(final String nextToken) { + return ListWebAppsRequest.builder().maxResults(10).nextToken(nextToken).build(); + } + + private ListWebAppsResponse listWebApps(ListWebAppsRequest awsRequest, ProxyClient client) { + try (TransferClient transferClient = client.client()) { + return client.injectCredentialsAndInvokeV2(awsRequest, transferClient::listWebApps); + } + } + + private List translateFromListResponce(final ListWebAppsResponse awsResponse) { + return Translator.streamOfOrEmpty(awsResponse.webApps()) + .map(resource -> ResourceModel.builder() + .arn(resource.arn()) + .webAppId(resource.webAppId()) + .build()) + .toList(); + } +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/MockableBaseHandler.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/MockableBaseHandler.java new file mode 100644 index 0000000..32ae4b6 --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/MockableBaseHandler.java @@ -0,0 +1,24 @@ +package software.amazon.transfer.webapp; + +import software.amazon.awssdk.services.transfer.TransferClient; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +/** + * Interface exposing the only handler method that should be used when + * testing Uluru handlers. This provides a mechanism to feed the call chain + * a mock client as needed without complex static mocking of the ClientBuilder. + * + * @param + */ +interface MockableBaseHandler { + ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackT callbackContext, + final ProxyClient proxyClient, + final Logger logger); +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ReadHandler.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ReadHandler.java new file mode 100644 index 0000000..025ea56 --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ReadHandler.java @@ -0,0 +1,131 @@ +package software.amazon.transfer.webapp; + +import static software.amazon.transfer.webapp.translators.Translator.translateFromSdkTags; + +import java.util.Base64; + +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.transfer.TransferClient; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppCustomizationRequest; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppCustomizationResponse; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppRequest; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppResponse; +import software.amazon.awssdk.services.transfer.model.DescribedWebApp; +import software.amazon.awssdk.services.transfer.model.DescribedWebAppCustomization; +import software.amazon.awssdk.services.transfer.model.ResourceNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.transfer.webapp.translators.Translator; + +public class ReadHandler extends BaseHandlerStd { + + @Override + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + this.logger = logger; + + final String clientRequestToken = request.getClientRequestToken(); + + final ResourceModel model = request.getDesiredResourceState(); + Translator.ensureWebAppIdInModel(model); + + return proxy.initiate("AWS-Transfer-Web-App::Read", proxyClient, model, callbackContext) + .translateToServiceRequest(this::translateToReadRequest) + .makeServiceCall(this::readWebApp) + .handleError((ignored, exception, client, resourceModel, context) -> + handleError(READ, exception, resourceModel, context, clientRequestToken)) + .done(awsResponse -> proxy.initiate( + "AWS-Transfer-Web-App::ReadCustomization", proxyClient, model, callbackContext) + .translateToServiceRequest(this::translateToReadCustomizationRequest) + .makeServiceCall(this::readWebAppCustomization) + .handleError((ignored, exception, client, resourceModel, context) -> + handleError(READ, exception, resourceModel, context, clientRequestToken)) + .done(customizationResponse -> ProgressEvent.defaultSuccessHandler( + translateFromReadResponse(awsResponse, customizationResponse)))); + } + + private ResourceModel translateFromReadResponse( + final DescribeWebAppResponse webAppResponse, + final DescribeWebAppCustomizationResponse customizationResponse) { + + DescribedWebApp webApp = webAppResponse.webApp(); + ResourceModel model = new ResourceModel(); + model.setArn(webApp.arn()); + model.setWebAppId(webApp.webAppId()); + + IdentityProviderDetails identityProviderDetails = new IdentityProviderDetails(); + identityProviderDetails.setApplicationArn( + webApp.describedIdentityProviderDetails().identityCenterConfig().applicationArn()); + identityProviderDetails.setInstanceArn( + webApp.describedIdentityProviderDetails().identityCenterConfig().instanceArn()); + identityProviderDetails.setRole( + webApp.describedIdentityProviderDetails().identityCenterConfig().role()); + model.setIdentityProviderDetails(identityProviderDetails); + + model.setAccessEndpoint(webApp.accessEndpoint()); + + WebAppUnits webAppUnits = new WebAppUnits(); + webAppUnits.setProvisioned(webApp.webAppUnits().provisioned()); + model.setWebAppUnits(webAppUnits); + + model.setTags(translateFromSdkTags(webApp.tags())); + + // Only add customization if response exists + if (customizationResponse != null && customizationResponse.webAppCustomization() != null) { + DescribedWebAppCustomization webAppCustomization = customizationResponse.webAppCustomization(); + WebAppCustomization customization = new WebAppCustomization(); + customization.setTitle(webAppCustomization.title()); + customization.setLogoFile(convertToBase64String(webAppCustomization.logoFile())); + customization.setFaviconFile(convertToBase64String(webAppCustomization.faviconFile())); + model.setWebAppCustomization(customization); + } + + return model; + } + + private DescribeWebAppRequest translateToReadRequest(final ResourceModel model) { + return DescribeWebAppRequest.builder().webAppId(model.getWebAppId()).build(); + } + + private DescribeWebAppCustomizationRequest translateToReadCustomizationRequest(final ResourceModel model) { + return DescribeWebAppCustomizationRequest.builder() + .webAppId(model.getWebAppId()) + .build(); + } + + protected DescribeWebAppResponse readWebApp(DescribeWebAppRequest awsRequest, ProxyClient client) { + try (TransferClient transferClient = client.client()) { + DescribeWebAppResponse awsResponse = + client.injectCredentialsAndInvokeV2(awsRequest, transferClient::describeWebApp); + log("has been read successfully.", awsRequest.webAppId()); + return awsResponse; + } + } + + protected DescribeWebAppCustomizationResponse readWebAppCustomization( + DescribeWebAppCustomizationRequest awsRequest, ProxyClient client) { + try (TransferClient transferClient = client.client()) { + DescribeWebAppCustomizationResponse awsResponse = + client.injectCredentialsAndInvokeV2(awsRequest, transferClient::describeWebAppCustomization); + log("has been read successfully.", awsRequest.webAppId()); + return awsResponse; + } catch (ResourceNotFoundException e) { + log("No web app customization to add", awsRequest.webAppId()); + return null; + } + } + + private String convertToBase64String(SdkBytes bytes) { + if (bytes == null) { + return null; + } + return Base64.getEncoder().encodeToString(bytes.asByteArray()); + } +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/UpdateHandler.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/UpdateHandler.java new file mode 100644 index 0000000..9727305 --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/UpdateHandler.java @@ -0,0 +1,162 @@ +package software.amazon.transfer.webapp; + +import static software.amazon.transfer.webapp.translators.TagHelper.setDesiredTags; + +import java.util.Base64; + +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.transfer.TransferClient; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationRequest; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationResponse; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppIdentityCenterConfig; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppIdentityProviderDetails; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppRequest; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppResponse; +import software.amazon.awssdk.services.transfer.model.WebAppUnits; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.transfer.webapp.translators.Translator; + +public class UpdateHandler extends BaseHandlerStd { + + @Override + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + this.logger = logger; + + final String clientRequestToken = request.getClientRequestToken(); + final ResourceModel previousModel = request.getPreviousResourceState(); + final ResourceModel newModel = request.getDesiredResourceState(); + + if (newModel.getIdentityProviderDetails() == null) { + newModel.setIdentityProviderDetails(previousModel.getIdentityProviderDetails()); + } + if (newModel.getAccessEndpoint() == null) { + newModel.setAccessEndpoint(previousModel.getAccessEndpoint()); + } + // Preserve WebAppUnits + if (newModel.getWebAppUnits() == null) { + newModel.setWebAppUnits(previousModel.getWebAppUnits()); + } + if (newModel.getWebAppCustomization() == null && previousModel.getWebAppCustomization() != null) { + newModel.setWebAppCustomization(previousModel.getWebAppCustomization()); + } + + Translator.ensureWebAppIdInModel(newModel); + + setDesiredTags(request, newModel); + + return ProgressEvent.progress(newModel, callbackContext) + .then(progress -> updateWebApp(proxy, proxyClient, clientRequestToken, progress)) + .then(progress -> { + // Only update customization if it has exists in the model + if (progress.getResourceModel().getWebAppCustomization() != null) { + return updateWebAppCustomization(proxy, proxyClient, clientRequestToken, progress); + } + return progress; + }) + .then(progress -> addTags(progress, request, newModel, proxy, proxyClient, callbackContext)) + .then(progress -> removeTags(progress, request, newModel, proxy, proxyClient, callbackContext)) + .then(progress -> + new ReadHandler().handleRequest(proxy, request, callbackContext, proxyClient, logger)); + } + + private ProgressEvent updateWebApp( + AmazonWebServicesClientProxy proxy, + ProxyClient proxyClient, + String clientRequestToken, + ProgressEvent progress) { + return proxy.initiate( + "AWS-Transfer-Web-App::Update", + proxyClient, + progress.getResourceModel(), + progress.getCallbackContext()) + .translateToServiceRequest(this::translateToUpdateRequest) + .makeServiceCall(this::updateWebApp) + .handleError((ignored, exception, client, model, context) -> + handleError(UPDATE, exception, model, context, clientRequestToken)) + .progress(); + } + + private UpdateWebAppResponse updateWebApp(UpdateWebAppRequest request, ProxyClient client) { + try (TransferClient transferClient = client.client()) { + UpdateWebAppResponse response = client.injectCredentialsAndInvokeV2(request, transferClient::updateWebApp); + log("has successfully been updated.", request.webAppId()); + return response; + } + } + + private UpdateWebAppRequest translateToUpdateRequest(final ResourceModel model) { + return UpdateWebAppRequest.builder() + .webAppId(model.getWebAppId()) + .identityProviderDetails(UpdateWebAppIdentityProviderDetails.builder() + .identityCenterConfig(UpdateWebAppIdentityCenterConfig.builder() + .role(model.getIdentityProviderDetails().getRole()) + .build()) + .build()) + .accessEndpoint(model.getAccessEndpoint()) + .webAppUnits(WebAppUnits.builder() + .provisioned(model.getWebAppUnits().getProvisioned()) + .build()) + .build(); + } + + private ProgressEvent updateWebAppCustomization( + AmazonWebServicesClientProxy proxy, + ProxyClient proxyClient, + String clientRequestToken, + ProgressEvent progress) { + return proxy.initiate( + "AWS-Transfer-Web-App::UpdateCustomization", + proxyClient, + progress.getResourceModel(), + progress.getCallbackContext()) + .translateToServiceRequest(this::translateToUpdateCustomizationRequest) + .makeServiceCall(this::updateWebAppCustomization) + .handleError((ignored, exception, client, model, context) -> + handleError(UPDATE, exception, model, context, clientRequestToken)) + .progress(); + } + + private UpdateWebAppCustomizationRequest translateToUpdateCustomizationRequest(final ResourceModel model) { + return UpdateWebAppCustomizationRequest.builder() + .webAppId(model.getWebAppId()) + .title(model.getWebAppCustomization().getTitle()) + .logoFile( + model.getWebAppCustomization().getLogoFile() != null + ? convertFileToSdkBytes( + model.getWebAppCustomization().getLogoFile()) + : null) + .faviconFile( + model.getWebAppCustomization().getFaviconFile() != null + ? convertFileToSdkBytes( + model.getWebAppCustomization().getFaviconFile()) + : null) + .build(); + } + + private UpdateWebAppCustomizationResponse updateWebAppCustomization( + UpdateWebAppCustomizationRequest request, ProxyClient client) { + try (TransferClient transferClient = client.client()) { + UpdateWebAppCustomizationResponse response = + client.injectCredentialsAndInvokeV2(request, transferClient::updateWebAppCustomization); + log("customization has successfully been updated.", request.webAppId()); + return response; + } + } + + private SdkBytes convertFileToSdkBytes(String fileData) { + if (fileData == null) { + return null; + } + // Decode Base64 string to byte array, then convert to SdkBytes + return SdkBytes.fromByteArray(Base64.getDecoder().decode(fileData)); + } +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/BaseArn.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/BaseArn.java new file mode 100644 index 0000000..8104ecd --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/BaseArn.java @@ -0,0 +1,59 @@ +package software.amazon.transfer.webapp.translators; + +import com.amazonaws.arn.Arn; +import com.amazonaws.arn.ArnResource; +import com.amazonaws.regions.Region; + +/** Transfer service base ARN class. */ +public abstract class BaseArn { + static final String RESOURCE_DELIMITER = "/"; + + /** Public SDK name of the service. */ + static final String SERVICE_NAME = "transfer"; + + private final Region region; + private final String accountId; + + BaseArn(Region region, String accountId) { + this.region = region; + this.accountId = accountId; + } + + public abstract String getResourceType(); + + public abstract String getResourceId(); + + public String getVendor() { + return SERVICE_NAME; + } + + public String getAccountId() { + return accountId; + } + + public Region getRegion() { + return region; + } + + public String getArn() { + String resource = ArnResource.builder() + .withResourceType(getResourceType()) + .withResource(getResourceId()) + .build() + .toString() + .replace(":", RESOURCE_DELIMITER); + + return Arn.builder() + .withPartition(getRegion().getPartition()) + .withService(SERVICE_NAME) + .withRegion(getRegion().getName()) + .withAccountId(getAccountId()) + .withResource(resource) + .build() + .toString(); + } + + protected static String[] getRelativeIdParts(Arn arn) { + return arn.getResourceAsString().split(RESOURCE_DELIMITER, -1); + } +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/TagHelper.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/TagHelper.java new file mode 100644 index 0000000..8be7749 --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/TagHelper.java @@ -0,0 +1,129 @@ +package software.amazon.transfer.webapp.translators; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.ObjectUtils; + +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.transfer.webapp.ResourceModel; + +public final class TagHelper { + private TagHelper() {} + + /** + * shouldUpdateTags + * + *

Determines whether web app defined tags have been changed during update. + */ + public static boolean shouldUpdateTags(final ResourceHandlerRequest handlerRequest) { + final Map previousTags = getPreviouslyAttachedTags(handlerRequest); + final Map desiredTags = getNewDesiredTags(handlerRequest); + return ObjectUtils.notEqual(previousTags, desiredTags); + } + + /** + * getPreviouslyAttachedTags + * + *

If stack tags and resource tags are not merged together in Configuration class, we will + * get previously attached system (with `aws:cloudformation` prefix) and web app defined tags from + * handlerRequest.getPreviousSystemTags() (system tags), + * handlerRequest.getPreviousResourceTags() (stack tags), + * handlerRequest.getPreviousResourceState().getTags() (resource tags). + * + *

System tags are an optional feature. Merge them to your tags if you have enabled them for + * your resource. System tags can change on resource update if the resource is imported to the + * stack. + */ + public static Map getPreviouslyAttachedTags( + final ResourceHandlerRequest handlerRequest) { + final Map previousTags = new HashMap<>(); + + if (handlerRequest.getPreviousSystemTags() != null) { + previousTags.putAll(handlerRequest.getPreviousSystemTags()); + } + + if (handlerRequest.getPreviousResourceTags() != null) { + previousTags.putAll(handlerRequest.getPreviousResourceTags()); + } + + ResourceModel model = handlerRequest.getPreviousResourceState(); + if (model != null && model.getTags() != null) { + model.getTags().forEach(tag -> { + previousTags.put(tag.getKey(), tag.getValue()); + }); + } + return previousTags; + } + + /** + * getNewDesiredTags + * + *

If stack tags and resource tags are not merged together in Configuration class, we will + * get new desired system (with `aws:cloudformation` prefix) and web app defined tags from + * handlerRequest.getSystemTags() (system tags), handlerRequest.getDesiredResourceTags() (stack + * tags), handlerRequest.getDesiredResourceState().getTags() (resource tags). + * + *

System tags are an optional feature. Merge them to your tags if you have enabled them for + * your resource. System tags can change on resource update if the resource is imported to the + * stack. + */ + public static Map getNewDesiredTags(final ResourceHandlerRequest handlerRequest) { + final Map desiredTags = new HashMap<>(); + + if (handlerRequest.getSystemTags() != null) { + desiredTags.putAll(handlerRequest.getSystemTags()); + } + + // get desired stack level tags from handlerRequest + if (handlerRequest.getDesiredResourceTags() != null) { + desiredTags.putAll(handlerRequest.getDesiredResourceTags()); + } + + if (handlerRequest.getDesiredResourceState().getTags() != null) { + handlerRequest.getDesiredResourceState().getTags().forEach(tag -> { + desiredTags.put(tag.getKey(), tag.getValue()); + }); + } + return desiredTags; + } + + /** + * generateTagsToAdd + * + *

Determines the tags the customer desired to define or redefine. + */ + public static Map generateTagsToAdd( + final Map previousTags, final Map desiredTags) { + return desiredTags.entrySet().stream() + .filter(e -> !previousTags.containsKey(e.getKey()) + || !Objects.equals(previousTags.get(e.getKey()), e.getValue())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + /** + * getTagsToRemove + * + *

Determines the tags the customer desired to remove from the function. + */ + public static Set generateTagsToRemove( + final Map previousTags, final Map desiredTags) { + final Set desiredTagNames = desiredTags.keySet(); + + return previousTags.keySet().stream() + .filter(tagName -> !desiredTagNames.contains(tagName)) + .collect(Collectors.toSet()); + } + + public static void setDesiredTags( + ResourceHandlerRequest request, ResourceModel desiredResourceModel) { + + Map tagsMap = getNewDesiredTags(request); + if (!tagsMap.isEmpty()) { + desiredResourceModel.setTags(Translator.translateTagMapToTagList(tagsMap)); + } + } +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/Translator.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/Translator.java new file mode 100644 index 0000000..ee85f5a --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/Translator.java @@ -0,0 +1,144 @@ +package software.amazon.transfer.webapp.translators; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.commons.lang3.StringUtils; + +import software.amazon.awssdk.services.transfer.model.DescribeWebAppRequest; +import software.amazon.awssdk.services.transfer.model.TagResourceRequest; +import software.amazon.awssdk.services.transfer.model.UntagResourceRequest; +import software.amazon.transfer.webapp.ResourceModel; +import software.amazon.transfer.webapp.Tag; + +/** + * This class is a centralized placeholder for - api request construction - object translation + * to/from aws sdk - resource model construction for read/list handlers + */ +public final class Translator { + private Translator() {} + + public static List translateToSdkTags(List tags) { + return streamOfOrEmpty(tags) + .map(tag -> software.amazon.awssdk.services.transfer.model.Tag.builder() + .key(tag.getKey()) + .value(tag.getValue()) + .build()) + .toList(); + } + + public static List translateToSdkTags( + Map tags) { + if (tags == null) { + return null; + } + return tags.entrySet().stream() + .map(tag -> software.amazon.awssdk.services.transfer.model.Tag.builder() + .key(tag.getKey()) + .value(tag.getValue()) + .build()) + .toList(); + } + + /** + * Request to read a resource + * + * @param model resource model + * @return awsRequest the aws service request to describe a resource + */ + public static DescribeWebAppRequest translateToReadRequest(final ResourceModel model) { + return DescribeWebAppRequest.builder().webAppId(model.getWebAppId()).build(); + } + + public static List translateFromSdkTags(List tags) { + if (tags == null) { + return null; + } + return tags.stream() + .map(tag -> Tag.builder().key(tag.key()).value(tag.value()).build()) + .toList(); + } + + public static List translateTagMapToTagList(Map tagMap) { + if (tagMap == null) { + return null; + } + return tagMap.entrySet().stream() + .map(entry -> Tag.builder() + .key(entry.getKey()) + .value(entry.getValue()) + .build()) + .toList(); + } + + public static Map translateTagListToTagMap(List tagList) { + if (tagList == null) { + return null; + } + return tagList.stream().collect(Collectors.toMap(Tag::getKey, Tag::getValue)); + } + + public static Stream streamOfOrEmpty(final Collection collection) { + if (collection == null) { + return Stream.empty(); + } + return collection.stream(); + } + + public static String emptyStringIfNull(String nullableString) { + if (nullableString == null) { + return ""; + } + return nullableString; + } + + public static List nullIfEmptyList(final List list) { + if (list == null || list.isEmpty()) { + return null; + } + return list; + } + + public static List emptyListIfNull(final List list) { + if (list == null) { + return List.of(); + } + return list; + } + + /** + * Request to add tags to a resource + * + * @param model resource model + * @return awsRequest the aws service request to create a resource + */ + public static TagResourceRequest tagResourceRequest( + final ResourceModel model, final Map addedTags) { + List tagsToAdd = translateToSdkTags(addedTags); + return TagResourceRequest.builder().arn(model.getArn()).tags(tagsToAdd).build(); + } + + /** + * Request to add tags to a resource + * + * @param model resource model + * @return awsRequest the aws service request to create a resource + */ + public static UntagResourceRequest untagResourceRequest(final ResourceModel model, final Set removedTags) { + return UntagResourceRequest.builder() + .arn(model.getArn()) + .tagKeys(removedTags) + .build(); + } + + public static void ensureWebAppIdInModel(ResourceModel model) { + if (StringUtils.isBlank(model.getWebAppId())) { + WebAppArn webAppArn = WebAppArn.fromString(model.getArn()); + model.setWebAppId(webAppArn.getWebAppId()); + } + } +} diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/WebAppArn.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/WebAppArn.java new file mode 100644 index 0000000..8dfd7eb --- /dev/null +++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/WebAppArn.java @@ -0,0 +1,46 @@ +package software.amazon.transfer.webapp.translators; + +import com.amazonaws.arn.Arn; +import com.amazonaws.regions.Region; +import com.amazonaws.regions.RegionUtils; + +/** ARN representing a web app. */ +public class WebAppArn extends BaseArn { + /** Number of parts delimited by {@link #RESOURCE_DELIMITER} in relativeId of ARN. */ + private static final int RELATIVE_ID_PARTS = 2; + /** Web app resource type. */ + static final String WEB_APP = "webapp"; + + private final String webAppId; + + public WebAppArn(Region region, String accountId, String webAppId) { + super(region, accountId); + this.webAppId = webAppId; + } + + public String getResourceType() { + return WEB_APP; + } + + public String getResourceId() { + return webAppId; + } + + public String getWebAppId() { + return webAppId; + } + + public static WebAppArn fromString(String arn) { + return fromArn(Arn.fromString(arn)); + } + + public static WebAppArn fromArn(Arn arn) { + String[] relativeIdParts = getRelativeIdParts(arn); + if (relativeIdParts.length != RELATIVE_ID_PARTS || !relativeIdParts[0].equals(WEB_APP)) { + throw new IllegalArgumentException("Invalid Web App ARN: " + arn); + } + + Region r = RegionUtils.getRegion(arn.getRegion()); + return new WebAppArn(r, arn.getAccountId(), relativeIdParts[1]); + } +} diff --git a/aws-transfer-webapp/src/resources/log4j2.xml b/aws-transfer-webapp/src/resources/log4j2.xml new file mode 100644 index 0000000..5657daf --- /dev/null +++ b/aws-transfer-webapp/src/resources/log4j2.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/AbstractTestBase.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/AbstractTestBase.java new file mode 100644 index 0000000..c030291 --- /dev/null +++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/AbstractTestBase.java @@ -0,0 +1,271 @@ +package software.amazon.transfer.webapp; + +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import org.junit.jupiter.api.BeforeEach; +import org.mockito.Mock; + +import software.amazon.awssdk.awscore.AwsRequest; +import software.amazon.awssdk.awscore.AwsResponse; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.core.pagination.sync.SdkIterable; +import software.amazon.awssdk.services.transfer.TransferClient; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppCustomizationResponse; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppResponse; +import software.amazon.awssdk.services.transfer.model.DescribedWebApp; +import software.amazon.awssdk.services.transfer.model.DescribedWebAppCustomization; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Credentials; +import software.amazon.cloudformation.proxy.LoggerProxy; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.transfer.webapp.translators.Translator; + +public abstract class AbstractTestBase { + public static final String TEST_APPLICATION_ARN = + "arn:aws:sso::123456789012:application/ins-abcd1234efgh5678/apl-1234567890abcdef"; + public static final String TEST_INSTANCE_ARN = "arn:aws:sso:::instance/ins-abcd1234efgh5678"; + public static final String TEST_ROLE = "arn:aws:iam::123456789012:role/test-role"; + public static final String TEST_CUSTOMIZATION_TITLE = "Test Web App Customization Title"; + public static final String TEST_CUSTOMIZATION_LOGO_FILE = loadTestFile("logo.png"); + public static final String TEST_CUSTOMIZATION_FAVICON_FILE = loadTestFile("favicon.png"); + public static final Integer TEST_PROVISIONED = 3; + public static final String TEST_ARN = "arn:aws:transfer:us-east-1:123456789012:webapp/webapp-12345678901234567"; + public static final String TEST_WEB_APP_ID = "webapp-12345678901234567"; + public static final String TEST_ACCESS_ENDPOINT = "https://x7k9pq4mnt5wy.cloudfront.net"; + protected static final Map RESOURCE_TAG_MAP = Collections.singletonMap("key", "value"); + protected static final Map TEST_TAG_MAP = ImmutableMap.of("key2", "value2"); + protected static final Map SYSTEM_TAG_MAP = + Collections.singletonMap("aws:cloudformation:stack-name", "StackName"); + protected static final List MODEL_TAGS = + ImmutableList.of(Tag.builder().key("key").value("value").build()); + protected static final Map EXTRA_MODEL_TAGS = ImmutableMap.of("extrakey", "extravalue"); + protected static final software.amazon.awssdk.services.transfer.model.Tag SDK_MODEL_TAG = + software.amazon.awssdk.services.transfer.model.Tag.builder() + .key("key") + .value("value") + .build(); + protected static final software.amazon.awssdk.services.transfer.model.Tag SDK_SYSTEM_TAG = + software.amazon.awssdk.services.transfer.model.Tag.builder() + .key("aws:cloudformation:stack-name") + .value("StackName") + .build(); + + public static IdentityProviderDetails getIdentityProviderDetails() { + return IdentityProviderDetails.builder() + .applicationArn(TEST_APPLICATION_ARN) + .instanceArn(TEST_INSTANCE_ARN) + .role(TEST_ROLE) + .build(); + } + + public static WebAppUnits getWebAppUnits() { + return WebAppUnits.builder().provisioned(TEST_PROVISIONED).build(); + } + + public static WebAppCustomization getWebAppCustomization() { + return WebAppCustomization.builder() + .title(TEST_CUSTOMIZATION_TITLE) + .logoFile(TEST_CUSTOMIZATION_LOGO_FILE) + .faviconFile(TEST_CUSTOMIZATION_FAVICON_FILE) + .build(); + } + + protected static final Credentials MOCK_CREDENTIALS; + protected static final LoggerProxy logger; + + static { + MOCK_CREDENTIALS = new Credentials("accessKey", "secretKey", "token"); + logger = new LoggerProxy(); + } + + protected AmazonWebServicesClientProxy proxy; + + protected ProxyClient proxyClient; + + @Mock + protected TransferClient client; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy( + logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + client = mock(TransferClient.class); + proxyClient = MOCK_PROXY(proxy, client); + } + + static ProxyClient MOCK_PROXY(final AmazonWebServicesClientProxy proxy, final T sdkClient) { + return new ProxyClient() { + @Override + public ResponseT injectCredentialsAndInvokeV2( + RequestT request, Function requestFunction) { + return proxy.injectCredentialsAndInvokeV2(request, requestFunction); + } + + @Override + public + CompletableFuture injectCredentialsAndInvokeV2Async( + RequestT request, Function> requestFunction) { + throw new UnsupportedOperationException(); + } + + @Override + public < + RequestT extends AwsRequest, + ResponseT extends AwsResponse, + IterableT extends SdkIterable> + IterableT injectCredentialsAndInvokeIterableV2( + RequestT request, Function requestFunction) { + return proxy.injectCredentialsAndInvokeIterableV2(request, requestFunction); + } + + @Override + public + ResponseInputStream injectCredentialsAndInvokeV2InputStream( + RequestT requestT, Function> function) { + throw new UnsupportedOperationException(); + } + + @Override + public + ResponseBytes injectCredentialsAndInvokeV2Bytes( + RequestT requestT, Function> function) { + throw new UnsupportedOperationException(); + } + + @Override + public T client() { + return sdkClient; + } + }; + } + + protected static ResourceHandlerRequest.ResourceHandlerRequestBuilder requestBuilder() { + return ResourceHandlerRequest.builder() + .awsAccountId("123456789012") + .awsPartition("aws") + .region("us-east-1"); + } + + protected ResourceModel simpleWebAppModel() { + return ResourceModel.builder() + .webAppId(TEST_WEB_APP_ID) + .identityProviderDetails(getIdentityProviderDetails()) + .webAppUnits(WebAppUnits.builder().provisioned(1).build()) + .tags(Collections.emptyList()) + .build(); + } + + protected ResourceModel fullyLoadedWebAppModel() { + return ResourceModel.builder() + .identityProviderDetails(getIdentityProviderDetails()) + .accessEndpoint(TEST_ACCESS_ENDPOINT) + .webAppUnits(getWebAppUnits()) + .webAppCustomization(getWebAppCustomization()) + .tags(MODEL_TAGS) + .build(); + } + + protected DescribeWebAppResponse describeWebAppResponseFromModel(ResourceModel model) { + software.amazon.awssdk.services.transfer.model.WebAppUnits webAppUnits = + software.amazon.awssdk.services.transfer.model.WebAppUnits.builder() + .provisioned( + model.getWebAppUnits() != null + ? model.getWebAppUnits().getProvisioned() + : 1) // Default to 1 if not provided + .build(); + + software.amazon.transfer.webapp.WebAppUnits modelWebAppUnits = + software.amazon.transfer.webapp.WebAppUnits.builder() + .provisioned(webAppUnits.provisioned()) + .build(); + model.setWebAppUnits(modelWebAppUnits); + + IdentityProviderDetails currentModel = model.getIdentityProviderDetails(); + IdentityProviderDetails identityProviderDetails = IdentityProviderDetails.builder() + .applicationArn(TEST_APPLICATION_ARN) + .instanceArn(currentModel.getInstanceArn()) + .role(currentModel.getRole()) + .build(); + model.setIdentityProviderDetails(identityProviderDetails); + + return DescribeWebAppResponse.builder() + .webApp( + DescribedWebApp.builder() + .webAppId(model.getWebAppId()) + .accessEndpoint(model.getAccessEndpoint()) + .arn(model.getArn()) + .describedIdentityProviderDetails( + software.amazon.awssdk.services.transfer.model + .DescribedWebAppIdentityProviderDetails.builder() + .identityCenterConfig(software.amazon.awssdk.services.transfer.model + .DescribedIdentityCenterConfig.builder() + .applicationArn(model.getIdentityProviderDetails() + .getApplicationArn()) + .instanceArn(model.getIdentityProviderDetails() + .getInstanceArn()) + .role(model.getIdentityProviderDetails() + .getRole()) + .build()) + .build()) + .webAppUnits(webAppUnits) + .tags(Translator.translateToSdkTags(model.getTags())) + .build()) + .build(); + } + + protected DescribeWebAppCustomizationResponse describeWebAppCustomizationResponseFromModel(ResourceModel model) { + return DescribeWebAppCustomizationResponse.builder() + .webAppCustomization(DescribedWebAppCustomization.builder() + .title(model.getWebAppCustomization().getTitle()) + .logoFile(convertFromBase64String( + model.getWebAppCustomization().getLogoFile())) + .faviconFile(convertFromBase64String( + model.getWebAppCustomization().getFaviconFile())) + .build()) + .build(); + } + + protected static String loadTestFile(String resourcePath) { + try { + // Get file from resources folder + ClassLoader classLoader = AbstractTestBase.class.getClassLoader(); + InputStream inputStream = classLoader.getResourceAsStream(resourcePath); + + if (inputStream == null) { + throw new IllegalArgumentException("File not found: " + resourcePath); + } + + // Read the file bytes + byte[] fileBytes = inputStream.readAllBytes(); + inputStream.close(); + + // Convert to Base64 + return Base64.getEncoder().encodeToString(fileBytes); + } catch (IOException e) { + throw new RuntimeException("Failed to load test file: " + resourcePath, e); + } + } + + private SdkBytes convertFromBase64String(String base64String) { + if (base64String == null) { + return null; + } + return SdkBytes.fromByteArray(Base64.getDecoder().decode(base64String)); + } +} diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/BaseHandlerStdTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/BaseHandlerStdTest.java new file mode 100644 index 0000000..ba301f1 --- /dev/null +++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/BaseHandlerStdTest.java @@ -0,0 +1,56 @@ +package software.amazon.transfer.webapp; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +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.transfer.TransferClient; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +public class BaseHandlerStdTest { + @Mock + private AmazonWebServicesClientProxy proxy; + + static class MockTestHandler extends BaseHandlerStd { + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + ProxyClient proxyClient, + Logger logger) { + assertNotNull(callbackContext); + return null; + } + } + + @Test + void justToCover3LinesOfCode() { + var uut = new MockTestHandler(); + + // Check with null CallbackContext + uut.handleRequest(proxy, null, null, null); + verify(proxy, times(1)).newProxy(any()); + verifyNoMoreInteractions(proxy); + reset(proxy); + + // Check with non-null CallbackContext + uut.handleRequest(proxy, null, new CallbackContext(), null); + verify(proxy, times(1)).newProxy(any()); + verifyNoMoreInteractions(proxy); + reset(proxy); + } +} diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/CreateHandlerTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/CreateHandlerTest.java new file mode 100644 index 0000000..7efaa80 --- /dev/null +++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/CreateHandlerTest.java @@ -0,0 +1,141 @@ +package software.amazon.transfer.webapp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import software.amazon.awssdk.services.transfer.model.CreateWebAppRequest; +import software.amazon.awssdk.services.transfer.model.CreateWebAppResponse; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppCustomizationRequest; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppRequest; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppResponse; +import software.amazon.awssdk.services.transfer.model.InvalidRequestException; +import software.amazon.awssdk.services.transfer.model.ThrottlingException; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationRequest; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationResponse; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +@ExtendWith(SoftAssertionsExtension.class) +public class CreateHandlerTest extends AbstractTestBase { + + @InjectSoftAssertions + private SoftAssertions softly; + + private final CreateHandler handler = new CreateHandler(); + + @Test + public void handleRequest_SimpleSuccess() { + final ResourceModel model = simpleWebAppModel(); + model.setWebAppId(TEST_WEB_APP_ID); + model.setArn(TEST_ARN); + model.setAccessEndpoint(TEST_ACCESS_ENDPOINT); + + final ResourceHandlerRequest request = + requestBuilder().desiredResourceState(model).build(); + + setupCreateWebAppResponse(); + + DescribeWebAppResponse describeResponse = describeWebAppResponseFromModel(model); + doReturn(describeResponse).when(client).describeWebApp(any(DescribeWebAppRequest.class)); + + ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + softly.assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + softly.assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + softly.assertThat(response.getResourceModel()).isEqualTo(model); + softly.assertThat(response.getResourceModels()).isNull(); + softly.assertThat(response.getMessage()).isNull(); + softly.assertThat(response.getErrorCode()).isNull(); + + verify(client, atLeastOnce()).createWebApp(any(CreateWebAppRequest.class)); + verify(client, atLeastOnce()).describeWebApp(any(DescribeWebAppRequest.class)); + } + + @Test + public void handleRequest_LoadedSuccess() { + final ResourceModel model = fullyLoadedWebAppModel(); + model.setWebAppId(TEST_WEB_APP_ID); + model.setArn(TEST_ARN); + + final ResourceHandlerRequest request = + requestBuilder().desiredResourceState(model).build(); + + setupCreateWebAppResponse(); + + UpdateWebAppCustomizationResponse updateResponse = UpdateWebAppCustomizationResponse.builder() + .webAppId(TEST_WEB_APP_ID) + .build(); + doReturn(updateResponse).when(client).updateWebAppCustomization(any(UpdateWebAppCustomizationRequest.class)); + + DescribeWebAppResponse describeResponse = describeWebAppResponseFromModel(model); + + doReturn(describeResponse).when(client).describeWebApp(any(DescribeWebAppRequest.class)); + + doReturn(describeWebAppCustomizationResponseFromModel(model)) + .when(client) + .describeWebAppCustomization(any(DescribeWebAppCustomizationRequest.class)); + + ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + softly.assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + softly.assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + softly.assertThat(response.getResourceModel()).isEqualTo(model); + softly.assertThat(response.getResourceModels()).isNull(); + softly.assertThat(response.getMessage()).isNull(); + softly.assertThat(response.getErrorCode()).isNull(); + + verify(client, atLeastOnce()).createWebApp(any(CreateWebAppRequest.class)); + verify(client, atLeastOnce()).updateWebAppCustomization(any(UpdateWebAppCustomizationRequest.class)); + verify(client, atLeastOnce()).describeWebApp(any(DescribeWebAppRequest.class)); + verify(client, atLeastOnce()).describeWebAppCustomization(any(DescribeWebAppCustomizationRequest.class)); + } + + @Test + public void errorPathsTest() { + final ResourceModel model = simpleWebAppModel(); + + final ResourceHandlerRequest request = + requestBuilder().desiredResourceState(model).build(); + + setupCreateWebAppResponse(); + + Exception ex1 = ThrottlingException.builder().build(); + Exception ex2 = InvalidRequestException.builder().build(); + + doThrow(ex1).doThrow(ex2).when(client).describeWebApp(any(DescribeWebAppRequest.class)); + + ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + softly.assertThat(response.getStatus()).isEqualTo(OperationStatus.FAILED); + + response = handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + softly.assertThat(response.getStatus()).isEqualTo(OperationStatus.FAILED); + } + + private void setupCreateWebAppResponse() { + CreateWebAppResponse createWebAppResponse = + CreateWebAppResponse.builder().webAppId(TEST_WEB_APP_ID).build(); + doReturn(createWebAppResponse).when(client).createWebApp(any(CreateWebAppRequest.class)); + } +} diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/DeleteHandlerTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/DeleteHandlerTest.java new file mode 100644 index 0000000..6aa92b3 --- /dev/null +++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/DeleteHandlerTest.java @@ -0,0 +1,49 @@ +package software.amazon.transfer.webapp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import software.amazon.awssdk.services.transfer.model.DeleteWebAppRequest; +import software.amazon.awssdk.services.transfer.model.DeleteWebAppResponse; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +public class DeleteHandlerTest extends AbstractTestBase { + + @Test + public void handleRequest_SimpleSuccess() { + final DeleteHandler handler = new DeleteHandler(); + + final ResourceModel model = ResourceModel.builder().arn(TEST_ARN).build(); + + final ResourceHandlerRequest request = + requestBuilder().desiredResourceState(model).build(); + + DeleteWebAppResponse deleteWebAppResponse = + DeleteWebAppResponse.builder().build(); + + doReturn(deleteWebAppResponse).when(client).deleteWebApp(any(DeleteWebAppRequest.class)); + + final ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + verify(client, atLeastOnce()).deleteWebApp(any(DeleteWebAppRequest.class)); + } +} diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ListHandlerTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ListHandlerTest.java new file mode 100644 index 0000000..1ac249f --- /dev/null +++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ListHandlerTest.java @@ -0,0 +1,66 @@ +package software.amazon.transfer.webapp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; + +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import software.amazon.awssdk.services.transfer.model.ListWebAppsRequest; +import software.amazon.awssdk.services.transfer.model.ListWebAppsResponse; +import software.amazon.awssdk.services.transfer.model.ListedWebApp; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +@ExtendWith(SoftAssertionsExtension.class) +public class ListHandlerTest extends AbstractTestBase { + + @InjectSoftAssertions + private SoftAssertions softly; + + @Test + public void handleRequest_SimpleSuccess() { + final ListHandler handler = new ListHandler(); + + final ResourceModel model = simpleWebAppModel(); + + final ResourceHandlerRequest request = + requestBuilder().desiredResourceState(model).build(); + + ListWebAppsResponse listWebAppsResponse = ListWebAppsResponse.builder() + .webApps(ListedWebApp.builder() + .arn(TEST_ARN) + .webAppId(TEST_WEB_APP_ID) + .build()) + .build(); + doReturn(listWebAppsResponse).when(client).listWebApps(any(ListWebAppsRequest.class)); + + final ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + assertThat(response.getResourceModels()).isNotNull(); + assertThat(response.getResourceModels()).size().isEqualTo(1); + softly.assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + softly.assertThat(response.getCallbackContext()).isNull(); + softly.assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + softly.assertThat(response.getResourceModel()).isNull(); + softly.assertThat(response.getMessage()).isNull(); + softly.assertThat(response.getErrorCode()).isNull(); + + ResourceModel result = response.getResourceModels().get(0); + softly.assertThat(result.getArn()).isEqualTo(TEST_ARN); + softly.assertThat(result.getWebAppId()).isEqualTo(TEST_WEB_APP_ID); + + verify(client, atLeastOnce()).listWebApps(any(ListWebAppsRequest.class)); + } +} diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ReadHandlerTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ReadHandlerTest.java new file mode 100644 index 0000000..241e8e8 --- /dev/null +++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ReadHandlerTest.java @@ -0,0 +1,85 @@ +package software.amazon.transfer.webapp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; + +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import software.amazon.awssdk.services.transfer.model.DescribeWebAppCustomizationRequest; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppRequest; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +@ExtendWith(SoftAssertionsExtension.class) +public class ReadHandlerTest extends AbstractTestBase { + + @InjectSoftAssertions + private SoftAssertions softly; + + private final ReadHandler handler = new ReadHandler(); + + private void assertSuccessfulResponse( + ProgressEvent response, ResourceHandlerRequest request) { + assertThat(response).isNotNull(); + softly.assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + softly.assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + softly.assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); + softly.assertThat(response.getResourceModels()).isNull(); + softly.assertThat(response.getMessage()).isNull(); + softly.assertThat(response.getErrorCode()).isNull(); + + verify(client, atLeastOnce()).describeWebApp(any(DescribeWebAppRequest.class)); + } + + @Test + public void handleRequest_SimpleSuccess() { + ResourceModel model = simpleWebAppModel(); + model.setWebAppId(TEST_WEB_APP_ID); + model.setArn(TEST_ARN); + model.setAccessEndpoint(TEST_ACCESS_ENDPOINT); + + ResourceHandlerRequest request = + requestBuilder().desiredResourceState(model).build(); + + doReturn(describeWebAppResponseFromModel(model)).when(client).describeWebApp(any(DescribeWebAppRequest.class)); + + ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertSuccessfulResponse(response, request); + softly.assertThat(response.getResourceModel().getArn().equals(TEST_ARN)); + } + + @Test + public void handleRequest_LoadedSuccess() { + ResourceModel model = fullyLoadedWebAppModel(); + model.setWebAppId(TEST_WEB_APP_ID); + model.setArn(TEST_ARN); + + ResourceHandlerRequest request = + requestBuilder().desiredResourceState(model).build(); + + doReturn(describeWebAppResponseFromModel(model)).when(client).describeWebApp(any(DescribeWebAppRequest.class)); + + doReturn(describeWebAppCustomizationResponseFromModel(model)) + .when(client) + .describeWebAppCustomization(any(DescribeWebAppCustomizationRequest.class)); + + ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertSuccessfulResponse(response, request); + verify(client, atLeastOnce()).describeWebAppCustomization(any(DescribeWebAppCustomizationRequest.class)); + softly.assertThat(response.getResourceModel().getArn().equals(TEST_ARN)); + } +} diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/UpdateHandlerTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/UpdateHandlerTest.java new file mode 100644 index 0000000..6ab6440 --- /dev/null +++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/UpdateHandlerTest.java @@ -0,0 +1,324 @@ +package software.amazon.transfer.webapp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Stubber; + +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppCustomizationRequest; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppRequest; +import software.amazon.awssdk.services.transfer.model.DescribeWebAppResponse; +import software.amazon.awssdk.services.transfer.model.TagResourceRequest; +import software.amazon.awssdk.services.transfer.model.UntagResourceRequest; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationRequest; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationResponse; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppRequest; +import software.amazon.awssdk.services.transfer.model.UpdateWebAppResponse; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.transfer.webapp.translators.Translator; + +@ExtendWith(MockitoExtension.class) +@ExtendWith(SoftAssertionsExtension.class) +public class UpdateHandlerTest extends AbstractTestBase { + + final UpdateHandler handler = new UpdateHandler(); + + @Test + public void handleRequest_SimpleSuccess() { + ResourceModel model = simpleWebAppModel(); + + final ResourceHandlerRequest request = requestBuilder() + .previousResourceState(model) + .desiredResourceState(model) + .build(); + + ProgressEvent response = updateWebAppAndAssertSuccess(request); + assertThat(response.getResourceModel()).isEqualTo(model); + + verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class)); + } + + @Test + public void handleRequest_NullIdentityProviderDetails() { + ResourceModel model = simpleWebAppModel(); + ResourceModel newModel = simpleWebAppModel(); + newModel.setIdentityProviderDetails(null); + + final ResourceHandlerRequest request = requestBuilder() + .previousResourceState(model) + .desiredResourceState(newModel) + .build(); + + ProgressEvent response = updateWebAppAndAssertSuccess(request); + assertThat(response.getResourceModel().getIdentityProviderDetails()) + .isEqualTo(model.getIdentityProviderDetails()); + + verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class)); + } + + @Test + public void handleRequest_NullWebAppCustomization() { + ResourceModel model = fullyLoadedWebAppModel(); + model.setWebAppId(TEST_WEB_APP_ID); + ResourceModel newModel = fullyLoadedWebAppModel(); + newModel.setWebAppId(TEST_WEB_APP_ID); + newModel.setWebAppCustomization(null); + + final ResourceHandlerRequest request = requestBuilder() + .previousResourceState(model) + .desiredResourceState(newModel) + .build(); + + ProgressEvent response = updateWebAppAndAssertSuccess(request); + assertThat(response.getResourceModel().getWebAppCustomization()).isEqualTo(model.getWebAppCustomization()); + + verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class)); + } + + @Test + public void handleRequest_SimpleUpdate_Tagging_Failed() { + ResourceModel model = simpleWebAppModel(); + ResourceModel newModel = simpleWebAppModel(); + newModel.setTags(Translator.translateTagMapToTagList(EXTRA_MODEL_TAGS)); + + ResourceHandlerRequest request = requestBuilder() + .previousResourceState(model) + .desiredResourceState(newModel) + .desiredResourceTags(EXTRA_MODEL_TAGS) + .build(); + + AwsServiceException ex = AwsServiceException.builder() + .awsErrorDetails( + AwsErrorDetails.builder().errorCode("AccessDenied").build()) + .build(); + doThrow(ex).when(client).tagResource(any(TagResourceRequest.class)); + + updateWebAppAndAssertStatus(request, OperationStatus.FAILED); + + verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class)); + verify(client, atLeastOnce()).tagResource(any(TagResourceRequest.class)); + + request = requestBuilder() + .previousResourceState(newModel) + .desiredResourceState(model) + .previousResourceTags(EXTRA_MODEL_TAGS) + .build(); + doThrow(ex).when(client).untagResource(any(UntagResourceRequest.class)); + + updateWebAppAndAssertStatus(request, OperationStatus.FAILED); + + verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class)); + verify(client, atLeastOnce()).untagResource(any(UntagResourceRequest.class)); + } + + @Test + public void handleRequest_VerifyAddingAndRemovingTags() { + Tag tag1 = Tag.builder().key("key1").value("value1").build(); + Tag tag2 = Tag.builder().key("key2").value("value2").build(); + Tag tag3 = Tag.builder().key("key3").value("value3").build(); + + ResourceModel currentModel = simpleWebAppModel(); + ResourceModel desiredModel = simpleWebAppModel(); + + desiredModel.setTags(List.of(tag1, tag2, tag3)); + + ResourceHandlerRequest request = requestBuilder() + .previousResourceState(currentModel) + .desiredResourceState(desiredModel) + .build(); + + ProgressEvent response = updateWebAppAndAssertSuccess(request); + + assertThat(response.getResourceModel()).isEqualTo(desiredModel); + + currentModel.setTags(List.of(tag1, tag2, tag3)); + desiredModel.setTags(List.of()); + + request = requestBuilder() + .previousResourceState(currentModel) + .desiredResourceState(desiredModel) + .build(); + + response = updateWebAppAndAssertSuccess(request); + + currentModel.setTags(List.of(tag1, tag3)); + desiredModel.setTags(List.of(tag1, tag2, tag3)); + + request = requestBuilder() + .previousResourceState(currentModel) + .desiredResourceState(desiredModel) + .build(); + + response = updateWebAppAndAssertSuccess(request); + + currentModel.setTags(List.of(tag1, tag2, tag3)); + desiredModel.setTags(List.of(tag1, tag3)); + + request = requestBuilder() + .previousResourceState(currentModel) + .desiredResourceState(desiredModel) + .build(); + + response = updateWebAppAndAssertSuccess(request); + + assertThat(response.getResourceModel()).isEqualTo(desiredModel); + verify(client, times(4)).updateWebApp(any(UpdateWebAppRequest.class)); + } + + @Test + public void handleRequest_VerifyChangingWebAppUnits() { + ResourceModel currentModel = simpleWebAppModel(); + ResourceModel desiredModel = simpleWebAppModel(); + + desiredModel.setWebAppUnits(WebAppUnits.builder().provisioned(3).build()); + + ResourceHandlerRequest request = requestBuilder() + .previousResourceState(currentModel) + .desiredResourceState(desiredModel) + .build(); + + ProgressEvent response = updateWebAppAndAssertSuccess(request); + + assertThat(response.getResourceModel()).isEqualTo(desiredModel); + + verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class)); + } + + @Test + public void handleRequest_VerifyChangingWebAppCustomization() { + ResourceModel currentModel = simpleWebAppModel(); + ResourceModel desiredModel = simpleWebAppModel(); + + desiredModel.setWebAppCustomization(WebAppCustomization.builder() + .title("new title") + .logoFile(loadTestFile("logo.jpeg")) + .faviconFile(null) + .build()); + + ResourceHandlerRequest request = requestBuilder() + .previousResourceState(currentModel) + .desiredResourceState(desiredModel) + .build(); + + ProgressEvent response = updateWebAppAndAssertSuccess(request); + + assertThat(response.getResourceModel()).isEqualTo(desiredModel); + verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class)); + verify(client, atLeastOnce()).updateWebAppCustomization(any(UpdateWebAppCustomizationRequest.class)); + + desiredModel.setWebAppCustomization(WebAppCustomization.builder() + .title("new title") + .logoFile(null) + .faviconFile(loadTestFile("favicon.jpeg")) + .build()); + + request = requestBuilder() + .previousResourceState(currentModel) + .desiredResourceState(desiredModel) + .build(); + + response = updateWebAppAndAssertSuccess(request); + + assertThat(response.getResourceModel()).isEqualTo(desiredModel); + verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class)); + verify(client, atLeastOnce()).updateWebAppCustomization(any(UpdateWebAppCustomizationRequest.class)); + } + + private ProgressEvent updateWebAppAndAssertSuccess( + ResourceHandlerRequest request) { + return updateWebAppAndAssertStatus(request, OperationStatus.SUCCESS); + } + + private ProgressEvent updateWebAppAndAssertStatus( + ResourceHandlerRequest request, OperationStatus status) { + // Create a copy to avoid the update handler mutating the original. + request = request.toBuilder() + .previousResourceState( + request.getPreviousResourceState().toBuilder().build()) + .desiredResourceState( + request.getDesiredResourceState().toBuilder().build()) + .build(); + + ResourceModel currentModel = request.getPreviousResourceState(); + ResourceModel desiredModel = request.getDesiredResourceState(); + + setupUpdateWebAppResponse(null); + + if (status == OperationStatus.SUCCESS) { + DescribeWebAppResponse describeResponse; + if (desiredModel.getIdentityProviderDetails() == null) { + describeResponse = describeWebAppResponseFromModel(currentModel); + } else { + describeResponse = describeWebAppResponseFromModel(desiredModel); + } + doReturn(describeResponse).when(client).describeWebApp(any(DescribeWebAppRequest.class)); + } + + // Only setup customization response if the model has customization + if (desiredModel.getWebAppCustomization() != null) { + setupUpdateWebAppCustomizationResponse(null); + + doReturn(describeWebAppCustomizationResponseFromModel(desiredModel)) + .when(client) + .describeWebAppCustomization(any(DescribeWebAppCustomizationRequest.class)); + } else if (currentModel.getWebAppCustomization() != null) { + setupUpdateWebAppCustomizationResponse(null); + + doReturn(describeWebAppCustomizationResponseFromModel(currentModel)) + .when(client) + .describeWebAppCustomization(any(DescribeWebAppCustomizationRequest.class)); + } + + final ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(status); + + return response; + } + + private void setupUpdateWebAppResponse(Exception ex) { + UpdateWebAppResponse response = + UpdateWebAppResponse.builder().webAppId(TEST_WEB_APP_ID).build(); + + Stubber stubber; + if (ex != null) { + stubber = doThrow(ex).doReturn(response); + } else { + stubber = doReturn(response); + } + + stubber.when(client).updateWebApp(any(UpdateWebAppRequest.class)); + } + + private void setupUpdateWebAppCustomizationResponse(Exception ex) { + UpdateWebAppCustomizationResponse response = UpdateWebAppCustomizationResponse.builder() + .webAppId(TEST_WEB_APP_ID) + .build(); + + Stubber stubber; + if (ex != null) { + stubber = doThrow(ex).doReturn(response); + } else { + stubber = doReturn(response); + } + + stubber.when(client).updateWebAppCustomization(any(UpdateWebAppCustomizationRequest.class)); + } +} diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/ExtraCoverageTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/ExtraCoverageTest.java new file mode 100644 index 0000000..dc7647a --- /dev/null +++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/ExtraCoverageTest.java @@ -0,0 +1,42 @@ +package software.amazon.transfer.webapp.translators; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import com.google.common.collect.ImmutableMap; + +import org.junit.jupiter.api.Test; + +import software.amazon.awssdk.services.transfer.model.Tag; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.transfer.webapp.ResourceModel; + +public class ExtraCoverageTest { + @Test + public void nullCheckingCodeCoverage() { + assertThat(Translator.translateToSdkTags((Map) null)).isNull(); + assertThat(Translator.translateFromSdkTags((List) null)).isNull(); + assertThat(Translator.translateTagMapToTagList((Map) null)) + .isNull(); + assertThat(Translator.translateTagListToTagMap((List) null)) + .isNull(); + assertThat(Translator.nullIfEmptyList((List) null)).isNull(); + assertThat(Translator.emptyListIfNull((List) null)).isEmpty(); + assertThat(Translator.emptyStringIfNull((String) null)).isEmpty(); + + ResourceModel state = ResourceModel.builder().build(); + ImmutableMap tags = ImmutableMap.of("a", "b"); + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(state) + .desiredResourceState(state) + .previousResourceTags(tags) + .desiredResourceTags(tags) + .previousSystemTags(tags) + .systemTags(tags) + .build(); + assertThat(TagHelper.getNewDesiredTags(request)).isEqualTo(tags); + assertThat(TagHelper.getPreviouslyAttachedTags(request)).isEqualTo(tags); + } +} diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/WebAppArnTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/WebAppArnTest.java new file mode 100644 index 0000000..e7a3c40 --- /dev/null +++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/WebAppArnTest.java @@ -0,0 +1,21 @@ +package software.amazon.transfer.webapp.translators; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class WebAppArnTest { + + @Test + public void testFromString() { + String arn = "arn:aws:transfer:us-east-1:123456789012:webapp/webapp-123456789012"; + WebAppArn webAppArn = WebAppArn.fromString(arn); + assertEquals(arn, webAppArn.getArn()); + assertEquals("transfer", webAppArn.getVendor()); + assertEquals("us-east-1", webAppArn.getRegion().getName()); + assertEquals("123456789012", webAppArn.getAccountId()); + assertEquals("webapp", webAppArn.getResourceType()); + assertEquals("webapp-123456789012", webAppArn.getWebAppId()); + assertEquals("webapp-123456789012", webAppArn.getResourceId()); + } +} diff --git a/aws-transfer-webapp/src/test/resources/favicon.jpeg b/aws-transfer-webapp/src/test/resources/favicon.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..50f8cdb12f5fe0952fa5996b83c23fe8b79bfc2d GIT binary patch literal 745 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!>q*YRht(X$9CR+%khz|%`9e4XZbYl?U{)a zdk;)1{>;_!StYCeH=of0-br!C&g?rf{mJjExpI-}A4Rtqow(%kP1t(zoYm&@*gW19 zc${BodNuZCR8icy)rnam9tRk?9`O9tQdC)T=eqg&ulWZ;)!$s6_Hyq&@3{Mo|8D{S DfbCDYcFCtT37-M6&)5!Ty%Bm5ZGZOq{3)k;40F*!6wgQ=i z9!;5HSI*9&1IYS+{RBMhvo5kc$`Hr|O{xO0;0sc?AXF#eR{>PZ7N-njMrGLH_TLE|hgx_BkPDd3Ooz8D2x!e}) z6kD0t8Evfy?ke1(1PeEvx7fdsAYT3H(J+P&Q`LfW1-eFuob)V^D|h1o59!h$s5M## zibJ&pos9OU*r<>14`FH%tN|NjVDkjcEc@RK|Id-jTN1bIDF5DcywV0AiOD1|7`~Fd Yspu$>SUyGu3jhEB07*qoM6N<$g18T!-2eap literal 0 HcmV?d00001 diff --git a/aws-transfer-webapp/src/test/resources/logo.jpeg b/aws-transfer-webapp/src/test/resources/logo.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..50f8cdb12f5fe0952fa5996b83c23fe8b79bfc2d GIT binary patch literal 745 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!>q*YRht(X$9CR+%khz|%`9e4XZbYl?U{)a zdk;)1{>;_!StYCeH=of0-br!C&g?rf{mJjExpI-}A4Rtqow(%kP1t(zoYm&@*gW19 zc${BodNuZCR8icy)rnam9tRk?9`O9tQdC)T=eqg&ulWZ;)!$s6_Hyq&@3{Mo|8D{S DfbCDYcFCtT37-M6&)5!Ty%Bm5ZGZOq{3)k;40F*!6wgQ=i z9!;5HSI*9&1IYS+{RBMhvo5kc$`Hr|O{xO0;0sc?AXF#eR{>PZ7N-njMrGLH_TLE|hgx_BkPDd3Ooz8D2x!e}) z6kD0t8Evfy?ke1(1PeEvx7fdsAYT3H(J+P&Q`LfW1-eFuob)V^D|h1o59!h$s5M## zibJ&pos9OU*r<>14`FH%tN|NjVDkjcEc@RK|Id-jTN1bIDF5DcywV0AiOD1|7`~Fd Yspu$>SUyGu3jhEB07*qoM6N<$g18T!-2eap literal 0 HcmV?d00001 diff --git a/aws-transfer-webapp/template.yml b/aws-transfer-webapp/template.yml new file mode 100644 index 0000000..b523cd0 --- /dev/null +++ b/aws-transfer-webapp/template.yml @@ -0,0 +1,24 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the AWS::Transfer::WebApp 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: software.amazon.transfer.webapp.HandlerWrapper::handleRequest + Runtime: java17 + CodeUri: ./target/aws-transfer-webapp-handler-1.0-SNAPSHOT.jar + + TestEntrypoint: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.transfer.webapp.HandlerWrapper::testEntrypoint + Runtime: java17 + CodeUri: ./target/aws-transfer-webapp-handler-1.0-SNAPSHOT.jar + diff --git a/pom.xml b/pom.xml index 13ad499..1baa6ee 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ aws-transfer-profile aws-transfer-server aws-transfer-user + aws-transfer-webapp aws-transfer-workflow