diff --git a/test/.gitignore b/test/.gitignore
new file mode 100644
index 0000000..ec10551
--- /dev/null
+++ b/test/.gitignore
@@ -0,0 +1,3 @@
+target/
+.idea/
+*.iml
diff --git a/test/README.md b/test/README.md
new file mode 100644
index 0000000..a2cec08
--- /dev/null
+++ b/test/README.md
@@ -0,0 +1,61 @@
+# S3 VirusScan
+
+Tests for our S3 VirusScan. The goal of this tests is to ensure that our templates are always working. The test are implemented in Java 8 and run in JUnit 4.
+
+If you run this tests, many AWS CloudFormation tests are created and **charges will apply**!
+
+[widdix GmbH](https://widdix.net) sponsors the test runs on every push and once per week to ensure that everything is working as expected.
+
+## Supported env variables
+
+* `IAM_ROLE_ARN` if the tests should assume an IAM role before they run supply the ARN of the IAM role
+* `TEMPLATE_DIR` Load templates from local disk (instead of S3 bucket `widdix-aws-cf-templates`). Must end with an `/`. See `BUCKET_NAME` as well.
+* `DELETION_POLICY` (default `delete`, allowed values [`delete`, `retain`]) should resources be deleted?
+
+## Usage
+
+### AWS credentials
+
+The AWS credentials are passed in as defined by the AWS SDK for Java: http://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html
+
+One addition: you can supply the env variable `IAM_ROLE_ARN` which let's the tests assume a role with the default credentials before running the tests.
+
+### Region selection
+
+The region selection works like defined by the AWS SDK for Java: http://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/java-dg-region-selection.html
+
+### Run all tests
+
+```
+AWS_REGION="us-east-1" mvn test
+```
+
+### Run a single test suite
+
+to run the `TestJenkins` tests:
+
+```
+AWS_REGION="us-east-1" mvn -Dtest=TestS3VirusScan test
+```
+
+### Run a single test
+
+to run the `TestS3VirusScan.test` test:
+
+```
+AWS_REGION="us-east-1" mvn -Dtest=TestS3VirusScan#testWithoutFileDeletion test
+```
+
+### Load templates from local file system
+
+```
+AWS_REGION="us-east-1" TEMPLATE_DIR="/path/to/widdix-aws-s3-virusscan/" mvn test
+```
+
+### Assume role
+
+This is useful if you run on a integration server like Jenkins and want to assume a different IAM role for this tests.
+
+```
+IAM_ROLE_ARN="arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME" mvn test
+```
diff --git a/test/pom.xml b/test/pom.xml
new file mode 100644
index 0000000..4041145
--- /dev/null
+++ b/test/pom.xml
@@ -0,0 +1,82 @@
+
+
+ 4.0.0
+
+ de.widdix
+ awss3virusscan-tests
+ 1.0-SNAPSHOT
+
+
+
+ com.amazonaws
+ aws-java-sdk-cloudformation
+ test
+
+
+ com.amazonaws
+ aws-java-sdk-s3
+ test
+
+
+ com.amazonaws
+ aws-java-sdk-sts
+ test
+
+
+ de.taimos
+ httputils
+ 1.10
+ test
+
+
+ com.evanlennick
+ retry4j
+ 0.6.2
+ test
+
+
+ junit
+ junit
+ 4.12
+ test
+
+
+
+
+
+
+ com.amazonaws
+ aws-java-sdk-bom
+ 1.11.133
+ pom
+ import
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.6.1
+
+
+ 1.8
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 2.20
+
+ methods
+ 2
+
+
+
+
+
+
diff --git a/test/src/test/java/de/widdix/awss3virusscan/AAWSTest.java b/test/src/test/java/de/widdix/awss3virusscan/AAWSTest.java
new file mode 100644
index 0000000..5bf83e6
--- /dev/null
+++ b/test/src/test/java/de/widdix/awss3virusscan/AAWSTest.java
@@ -0,0 +1,98 @@
+package de.widdix.awss3virusscan;
+
+import com.amazonaws.auth.AWSCredentialsProvider;
+import com.amazonaws.auth.DefaultAWSCredentialsProviderChain;
+import com.amazonaws.auth.STSAssumeRoleSessionCredentialsProvider;
+import com.amazonaws.regions.DefaultAwsRegionProviderChain;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.AmazonS3ClientBuilder;
+import com.amazonaws.services.s3.model.*;
+import com.amazonaws.services.securitytoken.AWSSecurityTokenService;
+import com.amazonaws.services.securitytoken.AWSSecurityTokenServiceClientBuilder;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.UUID;
+
+public abstract class AAWSTest extends ATest {
+
+ public final static String IAM_SESSION_NAME = "aws-s3-virusscan";
+
+ protected final AWSCredentialsProvider credentialsProvider;
+
+ private final AmazonS3 s3;
+
+ public AAWSTest() {
+ super();
+ if (Config.has(Config.Key.IAM_ROLE_ARN)) {
+ final AWSSecurityTokenService local = AWSSecurityTokenServiceClientBuilder.standard().withCredentials(new DefaultAWSCredentialsProviderChain()).build();
+ this.credentialsProvider = new STSAssumeRoleSessionCredentialsProvider.Builder(Config.get(Config.Key.IAM_ROLE_ARN), IAM_SESSION_NAME).withStsClient(local).build();
+ } else {
+ this.credentialsProvider = new DefaultAWSCredentialsProviderChain();
+ }
+ this.s3 = AmazonS3ClientBuilder.standard().withCredentials(this.credentialsProvider).build();
+ }
+
+ protected final void createBucket(final String name, final String queueArn) {
+ this.s3.createBucket(new CreateBucketRequest(name, Region.fromValue(this.getRegion())));
+ this.s3.setBucketNotificationConfiguration(name, new BucketNotificationConfiguration("test", new QueueConfiguration(queueArn, EnumSet.of(S3Event.ObjectCreated))));
+ }
+
+ protected final void createObject(final String bucketName, final String key, final String body) {
+ this.s3.putObject(bucketName, key, body);
+ }
+
+ protected final boolean doesObjectExist(final String bucketName, final String key) {
+ return this.s3.doesObjectExist(bucketName, key);
+ }
+
+ protected final List getObjectTags(final String bucketName, final String key) {
+ return this.s3.getObjectTagging(new GetObjectTaggingRequest(bucketName, key)).getTagSet();
+ }
+
+ protected final void deleteObject(final String bucketName, final String key) {
+ if (Config.get(Config.Key.DELETION_POLICY).equals("delete")) {
+ this.s3.deleteObject(bucketName, key);
+ }
+ }
+
+ private void emptyBucket(final String name) {
+ ObjectListing objectListing = s3.listObjects(name);
+ while (true) {
+ objectListing.getObjectSummaries().forEach((summary) -> s3.deleteObject(name, summary.getKey()));
+ if (objectListing.isTruncated()) {
+ objectListing = s3.listNextBatchOfObjects(objectListing);
+ } else {
+ break;
+ }
+ }
+ VersionListing versionListing = s3.listVersions(new ListVersionsRequest().withBucketName(name));
+ while (true) {
+ versionListing.getVersionSummaries().forEach((vs) -> s3.deleteVersion(name, vs.getKey(), vs.getVersionId()));
+ if (versionListing.isTruncated()) {
+ versionListing = s3.listNextBatchOfVersions(versionListing);
+ } else {
+ break;
+ }
+ }
+ }
+
+ protected final void deleteBucket(final String name) {
+ if (Config.get(Config.Key.DELETION_POLICY).equals("delete")) {
+ this.emptyBucket(name);
+ this.s3.deleteBucket(new DeleteBucketRequest(name));
+ }
+ }
+
+ protected final String getRegion() {
+ return new DefaultAwsRegionProviderChain().getRegion();
+ }
+
+ protected final String random8String() {
+ final String uuid = UUID.randomUUID().toString().replace("-", "").toLowerCase();
+ final int beginIndex = (int) (Math.random() * (uuid.length() - 7));
+ final int endIndex = beginIndex + 7;
+ return "r" + uuid.substring(beginIndex, endIndex); // must begin [a-z]
+ }
+
+}
diff --git a/test/src/test/java/de/widdix/awss3virusscan/ACloudFormationTest.java b/test/src/test/java/de/widdix/awss3virusscan/ACloudFormationTest.java
new file mode 100644
index 0000000..d613c55
--- /dev/null
+++ b/test/src/test/java/de/widdix/awss3virusscan/ACloudFormationTest.java
@@ -0,0 +1,169 @@
+package de.widdix.awss3virusscan;
+
+import com.amazonaws.AmazonServiceException;
+import com.amazonaws.services.cloudformation.AmazonCloudFormation;
+import com.amazonaws.services.cloudformation.AmazonCloudFormationClientBuilder;
+import com.amazonaws.services.cloudformation.model.*;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.*;
+
+public abstract class ACloudFormationTest extends AAWSTest {
+
+ public static String readFile(String path, Charset encoding) {
+ try {
+ byte[] encoded = Files.readAllBytes(Paths.get(path));
+ return new String(encoded, encoding);
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private final AmazonCloudFormation cf = AmazonCloudFormationClientBuilder.standard().withCredentials(this.credentialsProvider).build();
+
+ public ACloudFormationTest() {
+ super();
+ }
+
+ protected final void createWiddixStack(final String stackName, final String template, final Parameter... parameters) {
+ final CreateStackRequest req = new CreateStackRequest()
+ .withStackName(stackName)
+ .withParameters(parameters)
+ .withCapabilities(Capability.CAPABILITY_IAM)
+ .withTemplateURL("https://s3-eu-west-1.amazonaws.com/widdix-aws-cf-templates-releases-eu-west-1/stable/" + template);
+ this.cf.createStack(req);
+ this.waitForStack(stackName, FinalStatus.CREATE_COMPLETE);
+ }
+
+ protected final void createStack(final String stackName, final String template, final Parameter... parameters) {
+ CreateStackRequest req = new CreateStackRequest()
+ .withStackName(stackName)
+ .withParameters(parameters)
+ .withCapabilities(Capability.CAPABILITY_IAM);
+ if (Config.has(Config.Key.TEMPLATE_DIR)) {
+ final String dir = Config.get(Config.Key.TEMPLATE_DIR);
+ final String body = readFile(dir + template, Charset.forName("UTF-8"));
+ req = req.withTemplateBody(body);
+ } else {
+ req = req.withTemplateURL("https://s3-eu-west-1.amazonaws.com/widdix-aws-s3-virusscan/" + template);
+ }
+ this.cf.createStack(req);
+ this.waitForStack(stackName, FinalStatus.CREATE_COMPLETE);
+ }
+
+ protected enum FinalStatus {
+ CREATE_COMPLETE(StackStatus.CREATE_COMPLETE, false, true, StackStatus.CREATE_IN_PROGRESS),
+ DELETE_COMPLETE(StackStatus.DELETE_COMPLETE, true, false, StackStatus.DELETE_IN_PROGRESS);
+
+ private final StackStatus finalStatus;
+ private final boolean notFoundIsFinalStatus;
+ private final boolean notFoundIsIntermediateStatus;
+ private final Set intermediateStatus;
+
+ FinalStatus(StackStatus finalStatus, boolean notFoundIsFinalStatus, boolean notFoundIsIntermediateStatus, StackStatus... intermediateStatus) {
+ this.finalStatus = finalStatus;
+ this.notFoundIsFinalStatus = notFoundIsFinalStatus;
+ this.notFoundIsIntermediateStatus = notFoundIsIntermediateStatus;
+ this.intermediateStatus = new HashSet<>(Arrays.asList(intermediateStatus));
+ }
+ }
+
+ private List getStackEvents(final String stackName) {
+ final List events = new ArrayList<>();
+ String nextToken = null;
+ do {
+ try {
+ final DescribeStackEventsResult res = this.cf.describeStackEvents(new DescribeStackEventsRequest().withStackName(stackName).withNextToken(nextToken));
+ events.addAll(res.getStackEvents());
+ nextToken = res.getNextToken();
+ } catch (final AmazonServiceException e) {
+ if (e.getErrorMessage().equals("Stack [" + stackName + "] does not exist")) {
+ nextToken = null;
+ } else {
+ throw e;
+ }
+ }
+ } while (nextToken != null);
+ Collections.reverse(events);
+ return events;
+ }
+
+ private void waitForStack(final String stackName, final FinalStatus finalStackStatus) {
+ System.out.println("waitForStack[" + stackName + "]: to reach status " + finalStackStatus.finalStatus);
+ final List eventsDisplayed = new ArrayList<>();
+ while (true) {
+ try {
+ Thread.sleep(5000);
+ } catch (final InterruptedException e) {
+ // continue
+ }
+ final List events = getStackEvents(stackName);
+ for (final StackEvent event : events) {
+ boolean displayed = false;
+ for (final StackEvent eventDisplayed : eventsDisplayed) {
+ if (event.getEventId().equals(eventDisplayed.getEventId())) {
+ displayed = true;
+ }
+ }
+ if (!displayed) {
+ System.out.println("waitForStack[" + stackName + "]: " + event.getTimestamp().toString() + " " + event.getLogicalResourceId() + " " + event.getResourceStatus() + " " + event.getResourceStatusReason());
+ eventsDisplayed.add(event);
+ }
+ }
+ try {
+ final DescribeStacksResult res = this.cf.describeStacks(new DescribeStacksRequest().withStackName(stackName));
+ final StackStatus currentStatus = StackStatus.fromValue(res.getStacks().get(0).getStackStatus());
+ if (finalStackStatus.finalStatus == currentStatus) {
+ System.out.println("waitForStack[" + stackName + "]: final status reached.");
+ return;
+ } else {
+ if (finalStackStatus.intermediateStatus.contains(currentStatus)) {
+ System.out.println("waitForStack[" + stackName + "]: continue to wait (still in intermediate status " + currentStatus + ") ...");
+ } else {
+ throw new RuntimeException("waitForStack[" + stackName + "]: reached invalid intermediate status " + currentStatus + ".");
+ }
+ }
+ } catch (final AmazonServiceException e) {
+ if (e.getErrorMessage().equals("Stack with id " + stackName + " does not exist")) {
+ if (finalStackStatus.notFoundIsFinalStatus) {
+ System.out.println("waitForStack[" + stackName + "]: final reached (not found).");
+ return;
+ } else {
+ if (finalStackStatus.notFoundIsIntermediateStatus) {
+ System.out.println("waitForStack[" + stackName + "]: continue to wait (stack not found) ...");
+ } else {
+ throw new RuntimeException("waitForStack[" + stackName + "]: stack not found.");
+ }
+ }
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+
+ protected final Map getStackOutputs(final String stackName) {
+ final DescribeStacksResult res = this.cf.describeStacks(new DescribeStacksRequest().withStackName(stackName));
+ final List