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 + 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 outputs = res.getStacks().get(0).getOutputs(); + final Map map = new HashMap<>(outputs.size()); + for (final Output output : outputs) { + map.put(output.getOutputKey(), output.getOutputValue()); + } + return map; + } + + protected final String getStackOutputValue(final String stackName, final String outputKey) { + return this.getStackOutputs(stackName).get(outputKey); + } + + protected final void deleteStack(final String stackName) { + if (Config.get(Config.Key.DELETION_POLICY).equals("delete")) { + this.cf.deleteStack(new DeleteStackRequest().withStackName(stackName)); + this.waitForStack(stackName, FinalStatus.DELETE_COMPLETE); + } + } + +} diff --git a/test/src/test/java/de/widdix/awss3virusscan/ATest.java b/test/src/test/java/de/widdix/awss3virusscan/ATest.java new file mode 100644 index 0000000..68127b7 --- /dev/null +++ b/test/src/test/java/de/widdix/awss3virusscan/ATest.java @@ -0,0 +1,33 @@ +package de.widdix.awss3virusscan; + +import com.evanlennick.retry4j.CallExecutor; +import com.evanlennick.retry4j.CallResults; +import com.evanlennick.retry4j.RetryConfig; +import com.evanlennick.retry4j.RetryConfigBuilder; + +import java.time.temporal.ChronoUnit; +import java.util.concurrent.Callable; + +public abstract class ATest { + + protected final T retry(Callable callable) { + final Callable wrapper = () -> { + try { + return callable.call(); + } catch (final Exception e) { + System.out.println("retry[] exception: " + e.getMessage()); + e.printStackTrace(); + throw e; + } + }; + final RetryConfig config = new RetryConfigBuilder() + .retryOnAnyException() + .withMaxNumberOfTries(30) + .withDelayBetweenTries(10, ChronoUnit.SECONDS) + .withFixedBackoff() + .build(); + final CallResults results = new CallExecutor(config).execute(wrapper); + return (T) results.getResult(); + } + +} diff --git a/test/src/test/java/de/widdix/awss3virusscan/Config.java b/test/src/test/java/de/widdix/awss3virusscan/Config.java new file mode 100644 index 0000000..e6ebb8f --- /dev/null +++ b/test/src/test/java/de/widdix/awss3virusscan/Config.java @@ -0,0 +1,50 @@ +package de.widdix.awss3virusscan; + +public final class Config { + + public enum Key { + TEMPLATE_DIR("TEMPLATE_DIR"), + IAM_ROLE_ARN("IAM_ROLE_ARN"), + DELETION_POLICY("DELETION_POLICY", "delete"); + + private final String name; + private final String defaultValue; + + Key(String name, String defaultValue) { + this.name = name; + this.defaultValue = defaultValue; + } + + Key(String name) { + this.name = name; + this.defaultValue = null; + } + } + + public static String get(final Key key) { + final String env = System.getenv(key.name); + if (env == null) { + if (key.defaultValue == null) { + throw new RuntimeException("config not found: " + key.name); + } else { + return key.defaultValue; + } + } else { + return env; + } + } + + public static boolean has(final Key key) { + final String env = System.getenv(key.name); + if (env == null) { + if (key.defaultValue == null) { + return false; + } else { + return true; + } + } else { + return true; + } + } + +} diff --git a/test/src/test/java/de/widdix/awss3virusscan/TestS3VirusScan.java b/test/src/test/java/de/widdix/awss3virusscan/TestS3VirusScan.java new file mode 100644 index 0000000..68e49af --- /dev/null +++ b/test/src/test/java/de/widdix/awss3virusscan/TestS3VirusScan.java @@ -0,0 +1,115 @@ +package de.widdix.awss3virusscan; + +import com.amazonaws.services.cloudformation.model.Parameter; +import com.amazonaws.services.s3.model.Tag; +import org.junit.Test; + +import java.util.List; + +public class TestS3VirusScan extends ACloudFormationTest { + + @Test + public void testWithoutFileDeletion() { + final String vpcStackName = "vpc-2azs-" + this.random8String(); + final String stackName = "s3-virusscan-" + this.random8String(); + final String bucketName = "s3-virusscan-" + this.random8String(); + try { + this.createWiddixStack(vpcStackName, "vpc/vpc-2azs.yaml"); + try { + this.createStack(stackName, + "template.yaml", + new Parameter().withParameterKey("ParentVPCStack").withParameterValue(vpcStackName), + new Parameter().withParameterKey("TagFiles").withParameterValue("true"), + new Parameter().withParameterKey("DeleteInfectedFiles").withParameterValue("false") + ); + try { + this.createBucket(bucketName, this.getStackOutputValue(stackName, "ScanQueueArn")); + this.createObject(bucketName, "no-virus.txt", "not a virus"); + this.createObject(bucketName, "virus.txt", "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"); + this.retry(() -> { + final List tags = this.getObjectTags(bucketName, "no-virus.txt"); + if (tags.size() == 1) { + final Tag tag = tags.get(0); + if ("clamav-status".equals(tag.getKey())) { + if ("clean".equals(tag.getValue())) { + return tags; + } else { + throw new RuntimeException("clamav-status tag value expected to be clean, but saw " + tag.getValue()); + } + } else { + throw new RuntimeException("one and only tag key expected to be clamav-status, but saw " + tag.getKey()); + } + } else { + throw new RuntimeException("one tag expected, but saw " + tags.size()); + } + }); + this.retry(() -> { + final List tags = this.getObjectTags(bucketName, "virus.txt"); + if (tags.size() == 1) { + final Tag tag = tags.get(0); + if ("clamav-status".equals(tag.getKey())) { + if ("infected".equals(tag.getValue())) { + return tags; + } else { + throw new RuntimeException("clamav-status tag value expected to be infected, but saw " + tag.getValue()); + } + } else { + throw new RuntimeException("one and only tag key expected to be clamav-status, but saw " + tag.getKey()); + } + } else { + throw new RuntimeException("one tag expected, but saw " + tags.size()); + } + }); + this.deleteObject(bucketName, "no-virus.txt"); + this.deleteObject(bucketName, "virus.txt"); + } finally { + this.deleteBucket(bucketName); + } + } finally { + this.deleteStack(stackName); + } + } finally { + this.deleteStack(vpcStackName); + } + } + + @Test + public void testWithFileDeletion() { + final String vpcStackName = "vpc-2azs-" + this.random8String(); + final String stackName = "s3-virusscan-" + this.random8String(); + final String bucketName = "s3-virusscan-" + this.random8String(); + try { + this.createWiddixStack(vpcStackName, "vpc/vpc-2azs.yaml"); + try { + this.createStack(stackName, + "template.yaml", + new Parameter().withParameterKey("ParentVPCStack").withParameterValue(vpcStackName) + ); + try { + this.createBucket(bucketName, this.getStackOutputValue(stackName, "ScanQueueArn")); + this.createObject(bucketName, "no-virus.txt", "not a virus"); + this.createObject(bucketName, "virus.txt", "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"); + this.retry(() -> { + if (this.doesObjectExist(bucketName, "virus.txt") == true) { // expected to be deleted + throw new RuntimeException("virus.txt must be deleted"); + } + return false; + }); + this.retry(() -> { + if (this.doesObjectExist(bucketName, "no-virus.txt") == false) { // expected to exist + throw new RuntimeException("no-virus.txt must be existing"); + } + return true; + }); + this.deleteObject(bucketName, "no-virus.txt"); + } finally { + this.deleteBucket(bucketName); + } + } finally { + this.deleteStack(stackName); + } + } finally { + this.deleteStack(vpcStackName); + } + } +}