second first commit

This commit is contained in:
Zakaria
2026-09-22 08:57:00 -04:00
parent e88d7e2ff3
commit 0aef7fb2d8
4766 changed files with 1236986 additions and 0 deletions
@@ -0,0 +1,13 @@
FROM maven:3.9-eclipse-temurin-17
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src/ src/
RUN mvn test-compile -q
ENV FLOCI_ENDPOINT=http://floci:4566
RUN mkdir -p /results
ENTRYPOINT ["bash", "-c", "status=0; mvn test -q -Dorg.slf4j.simpleLogger.defaultLogLevel=warn || status=$?; if [ -d target/surefire-reports ]; then cp target/surefire-reports/TEST-*.xml /results/ 2>/dev/null || true; fi; exit $status"]
@@ -0,0 +1,93 @@
# sdk-test-java
Compatibility tests for [Floci](https://github.com/hectorvent/floci) using the **AWS SDK for Java v2 (2.31.8)**.
Runs 352 tests across 17 test classes against a live Floci instance — no mocks.
## Services Covered
| Test class | Description |
| -------------------------------- | -------------------------------------------------------- |
| `SsmTest` | Parameter Store — put, get, path, tags |
| `SqsTest` | Queues, send/receive/delete, DLQ, visibility |
| `SnsTest` | Topics, subscriptions, publish, SQS delivery |
| `S3Test` | Buckets, objects, tagging, copy, multipart, batch delete |
| `DynamoDbTest` | Tables, CRUD, batch, TTL, tags, streams |
| `DynamoDbScanConditionTests` | Scan filter and condition expressions |
| `LambdaTest` | Create/invoke/update/delete functions |
| `IamTest` | Users, roles, policies, access keys |
| `StsTest` | GetCallerIdentity, AssumeRole, GetSessionToken |
| `SecretsManagerTest` | Create/get/put/list/delete secrets, versioning, tags |
| `KmsTest` | Keys, aliases, encrypt/decrypt, data keys, sign/verify |
| `CloudWatchTest` | PutMetricData, ListMetrics, GetMetricStatistics, alarms |
| `CloudFormationVirtualHostTests` | Virtual host style S3 access via CloudFormation |
| `ApigwSfnJsonataCrudlTests` | API Gateway + Step Functions JSONata CRUDL integration |
| `ApiGatewayV2WebSocketAndExtendedOpsTest` | API GW v2 WebSocket APIs, Update ops, Route/Integration Responses, Models, Tagging |
| `Ec2Tests` | EC2 instances, VPCs, security groups, subnets |
| `AppSyncTest` | GraphQL API CRUDL, data sources, resolvers, functions, types, API keys, tags, schema validation |
| `EcsTests` | ECS clusters, task definitions, services |
## Adding a New Test
Create a standard JUnit 5 test class in `src/test/java/com/floci/test/`. Tests run against a live Floci instance using real AWS SDK clients.
## Requirements
- Java 17+
- Maven
## Running
```bash
# All tests
mvn test -q
# Specific test class
mvn test -Dtest=S3Test
# Via just (from compatibility-tests/)
just test-java
```
## Configuration
| Variable | Default | Description |
| ---------------- | ----------------------- | ----------------------- |
| `FLOCI_ENDPOINT` | `http://localhost:4566` | Floci emulator endpoint |
AWS credentials are always `test` / `test` / `us-east-1`.
## Metric filter publishing checks
`CloudWatchLogsMetricFilterTest` and `CloudFormationLogsMetricFilterTest` run
with the rest of the suite. Against a server on another port:
```bash
# From compatibility-tests/sdk-test-java
FLOCI_ENDPOINT=http://127.0.0.1:18084 ../../mvnw test \
-Dtest=CloudWatchLogsMetricFilterTest,CloudFormationLogsMetricFilterTest
```
The publishing tests read their expected values from
`src/test/resources/cloudwatchlogs/metric-filter-publishing-aws.json`, a
byte-identical copy of the root fixture recorded from AWS. `MetricFilterFixturePackagingTest`
checks the copy loads from the classpath, and the root
`CloudWatchLogsMetricFilterFixturePackagingTest` fails when either SDK module
copy drifts from the root file. The SDK selects the CloudWatch JSON protocol;
`MetricFilterQueryAssertions` signs the same reads as legacy form-encoded Query
requests, so `GetMetricStatistics` and `GetMetricData` on both wire formats are
asserted against every statistic the fixture records (Sum, SampleCount, Minimum,
Maximum). Reads poll for the sample, since the server publishes it shortly after
`PutLogEvents` returns. Passing
these tests shows Floci matches the recorded values; it is not live AWS
verification. For the opt-in replay against AWS see `../sdk-test-python/README.md`.
## Docker
```bash
docker build -t floci-sdk-java .
docker run --rm --network host floci-sdk-java
# Custom endpoint (macOS/Windows)
docker run --rm -e FLOCI_ENDPOINT=http://host.docker.internal:4566 floci-sdk-java
```
+696
View File
@@ -0,0 +1,696 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.floci</groupId>
<artifactId>sdk-test-java</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Floci SDK Test</name>
<description>Test AWS SDK v2 against Floci emulator</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<aws.sdk.version>2.52.0</aws.sdk.version>
<awsjavasdk.version>2.52.0</awsjavasdk.version>
<jackson.version>2.18.8</jackson.version>
<kafka.clients.version>3.9.2</kafka.clients.version>
<netty.version>4.1.135.Final</netty.version>
<wire.version>6.3.0</wire.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>${aws.sdk.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Force consistent Netty version across AWS SDK and Lettuce -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-bom</artifactId>
<version>${netty.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>${jackson.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.squareup.wire</groupId>
<artifactId>wire-runtime</artifactId>
<version>${wire.version}</version>
</dependency>
<dependency>
<groupId>com.squareup.wire</groupId>
<artifactId>wire-runtime-jvm</artifactId>
<version>${wire.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.18.0</version>
</dependency>
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>${kafka.clients.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>acm</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>amp</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sqs</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3-transfer-manager</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3control</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>ssm</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>redshift</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>redshiftdata</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sns</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>dynamodb</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>dynamodb-enhanced</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>lambda</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>apigateway</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>apigatewayv2</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>apigatewaymanagementapi</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>kms</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>cognitoidentityprovider</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sfn</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>swf</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>iam</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>signin</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>kafka</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>mq</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>kinesisanalyticsv2</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>athena</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>glue</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>resourcegroupstaggingapi</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.glue</groupId>
<artifactId>schema-registry-serde</artifactId>
<version>1.1.27</version>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>firehose</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>ec2</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sts</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>elasticache</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>elasticbeanstalk</artifactId>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.3.2.RELEASE</version>
</dependency>
<!-- RDS management client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>rds</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>neptune</artifactId>
</dependency>
<!-- DMS management client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>databasemigration</artifactId>
</dependency>
<!-- EventBridge client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>eventbridge</artifactId>
</dependency>
<!-- CloudHSM v2 client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>cloudhsmv2</artifactId>
</dependency>
<!-- Organizations client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>organizations</artifactId>
</dependency>
<!-- IAM Identity Center (SSO Admin) client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>ssoadmin</artifactId>
</dependency>
<!-- Identity Store client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>identitystore</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>budgets</artifactId>
</dependency>
<!-- Amazon Macie client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>macie2</artifactId>
</dependency>
<!-- Amazon Detective client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>detective</artifactId>
</dependency>
<!-- Amazon Inspector client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>inspector2</artifactId>
</dependency>
<!-- AWS Security Hub client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>securityhub</artifactId>
</dependency>
<!-- AWS Account Management client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>account</artifactId>
</dependency>
<!-- AWS Control Catalog client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>controlcatalog</artifactId>
</dependency>
<!-- AWS Marketplace Catalog client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>marketplacecatalog</artifactId>
</dependency>
<!-- AWS Marketplace Agreement client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>marketplaceagreement</artifactId>
</dependency>
<!-- AWS Marketplace Entitlement client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>marketplaceentitlement</artifactId>
</dependency>
<!-- AWS Marketplace Deployment client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>marketplacedeployment</artifactId>
</dependency>
<!-- AWS Marketplace Reporting client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>marketplacereporting</artifactId>
</dependency>
<!-- AWS Marketplace Metering client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>marketplacemetering</artifactId>
</dependency>
<!-- AWS Marketplace Discovery client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>marketplacediscovery</artifactId>
</dependency>
<!-- IAM Access Analyzer client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>accessanalyzer</artifactId>
</dependency>
<!-- Cloud Map (servicediscovery) client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>servicediscovery</artifactId>
</dependency>
<!-- CloudTrail client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>cloudtrail</artifactId>
</dependency>
<!-- EMR client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>emr</artifactId>
</dependency>
<!-- WAF v2 client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>wafv2</artifactId>
</dependency>
<!-- Kinesis client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>kinesis</artifactId>
</dependency>
<!-- Netty async HTTP client — required for KinesisAsyncClient (SubscribeToShard) -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>netty-nio-client</artifactId>
</dependency>
<!-- CloudWatch Logs client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>cloudwatchlogs</artifactId>
</dependency>
<!-- CloudWatch Metrics client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>cloudwatch</artifactId>
</dependency>
<!-- CloudWatch RUM client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>rum</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>fis</artifactId>
</dependency>
<!-- GuardDuty client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>guardduty</artifactId>
</dependency>
<!-- CodeArtifact client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>codeartifact</artifactId>
</dependency>
<!-- AWS Resource Access Manager client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>ram</artifactId>
</dependency>
<!-- Secrets Manager client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>secretsmanager</artifactId>
</dependency>
<!-- SES client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>ses</artifactId>
</dependency>
<!-- SES V2 client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sesv2</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>route53</artifactId>
</dependency>
<!-- OpenSearch Service management client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>opensearch</artifactId>
</dependency>
<!-- ECS client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>ecs</artifactId>
</dependency>
<!-- ECR client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>ecr</artifactId>
</dependency>
<!-- EventBridge Pipes client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>pipes</artifactId>
</dependency>
<!-- Bedrock control-plane client (guardrails) -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bedrock</artifactId>
</dependency>
<!-- Bedrock AgentCore control-plane client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bedrockagentcorecontrol</artifactId>
</dependency>
<!-- Bedrock AgentCore data-plane client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bedrockagentcore</artifactId>
</dependency>
<!-- Bedrock Runtime client (Converse/ConverseStream) -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bedrockruntime</artifactId>
</dependency>
<!-- AWS IoT client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>iot</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>iotdataplane</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>iotjobsdataplane</artifactId>
</dependency>
<!-- EKS client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>eks</artifactId>
</dependency>
<!-- ELBv2 client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>elasticloadbalancingv2</artifactId>
</dependency>
<!-- Global Accelerator client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>globalaccelerator</artifactId>
</dependency>
<!-- EventBridge Scheduler client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>scheduler</artifactId>
</dependency>
<!-- Auto Scaling client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>autoscaling</artifactId>
</dependency>
<!-- CloudFormation client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>cloudformation</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>cloudfront</artifactId>
</dependency>
<!-- CodeBuild client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>codebuild</artifactId>
</dependency>
<!-- SageMaker client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sagemaker</artifactId>
</dependency>
<!-- SageMaker Runtime client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sagemakerruntime</artifactId>
</dependency>
<!-- CodeDeploy client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>codedeploy</artifactId>
</dependency>
<!-- CodePipeline client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>codepipeline</artifactId>
</dependency>
<!-- AWS Backup client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>backup</artifactId>
</dependency>
<!-- Resource Explorer 2 client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>resourceexplorer2</artifactId>
</dependency>
<!-- CloudWatch OAM client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>oam</artifactId>
</dependency>
<!-- BCM Pricing Calculator client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bcmpricingcalculator</artifactId>
</dependency>
<!-- Timestream for InfluxDB client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>timestreaminfluxdb</artifactId>
</dependency>
<!-- SSO credential support (needed for FLOCI_TARGET=aws with SSO profiles) -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sso</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>ssooidc</artifactId>
</dependency>
<!-- Amazon Verified Permissions client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>verifiedpermissions</artifactId>
</dependency>
<!-- AppSync client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>appsync</artifactId>
</dependency>
<!-- Lightsail client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>lightsail</artifactId>
</dependency>
<!-- S3 Vectors client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3vectors</artifactId>
</dependency>
<!-- S3 Tables client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3tables</artifactId>
</dependency>
<!-- AppConfig and AppConfigData clients -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>appconfig</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>appconfigdata</artifactId>
</dependency>
<!-- Amazon AppIntegrations client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>appintegrations</artifactId>
</dependency>
<!-- DataSync client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>datasync</artifactId>
</dependency>
<!-- JDBC drivers for RDS data-plane tests -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.11</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.3.0</version>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- JBoss Logging for test diagnostics, matching the repository logging convention -->
<dependency>
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>
<version>3.6.3.Final</version>
<scope>test</scope>
</dependency>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<!-- AssertJ for fluent assertions -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>1.84</version>
<scope>test</scope>
</dependency>
<!-- Decodes the Snappy variants Firehose delivers to S3 (issue #2328).
Pinned because this module imports no BOM; keep it in step with the
version the Quarkus BOM manages for the emulator itself. -->
<dependency>
<groupId>org.xerial.snappy</groupId>
<artifactId>snappy-java</artifactId>
<version>1.1.10.8</version>
<scope>test</scope>
</dependency>
<!-- EFS client -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>efs</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<!-- Include *Tests.java to also run DynamoDbScanConditionTests, EcsTests,
Ec2Tests, etc. that were silently skipped by the original pattern -->
<includes>
<include>**/*Test.java</include>
<include>**/*Tests.java</include>
</includes>
<environmentVariables>
<FLOCI_ENDPOINT>${env.FLOCI_ENDPOINT}</FLOCI_ENDPOINT>
</environmentVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.12.1</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,227 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.kinesisanalyticsv2.KinesisAnalyticsV2Client;
import software.amazon.awssdk.services.kinesisanalyticsv2.model.ApplicationDetail;
import software.amazon.awssdk.services.kinesisanalyticsv2.model.ApplicationStatus;
import software.amazon.awssdk.services.kinesisanalyticsv2.model.CreateApplicationRequest;
import software.amazon.awssdk.services.kinesisanalyticsv2.model.CreateApplicationResponse;
import software.amazon.awssdk.services.kinesisanalyticsv2.model.DeleteApplicationRequest;
import software.amazon.awssdk.services.kinesisanalyticsv2.model.DescribeApplicationRequest;
import software.amazon.awssdk.services.kinesisanalyticsv2.model.KinesisAnalyticsV2Exception;
import software.amazon.awssdk.services.kinesisanalyticsv2.model.ListApplicationsRequest;
import software.amazon.awssdk.services.kinesisanalyticsv2.model.RuntimeEnvironment;
import software.amazon.awssdk.services.kinesisanalyticsv2.model.StartApplicationRequest;
import software.amazon.awssdk.services.kinesisanalyticsv2.model.StopApplicationRequest;
import java.time.Duration;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Ordered lifecycle for Managed Service for Apache Flink (Kinesis Analytics V2), exercised against a
* specific Flink runtime supplied by {@link #runtime()}. Concrete subclasses pin a runtime so the suite
* guards both the Flink 1.x and 2.x lines (Flink 2.0 changed the config system and boots slower):
* CreateApplication → READY, StartApplication provisions a real {@code apache/flink} cluster and the
* application transitions to RUNNING, ListApplications, StopApplication → READY, unsupported-runtime
* rejection, DeleteApplication.
*
* <p>Uses {@code PER_CLASS} instance lifecycle so each concrete subclass gets its own state (static
* fields would collide across subclasses). Requires a running Floci with
* {@code kinesis-analytics.mock=false} and a working Docker daemon; when the application never reaches
* RUNNING (Docker/Flink unavailable in CI), the dependent assertions skip rather than fail, mirroring
* {@code AmazonMqTest} / {@code MskTest}.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
abstract class AbstractKinesisAnalyticsV2LifecycleTest {
private static final String ROLE = "arn:aws:iam::000000000000:role/flink-exec";
/** The Flink {@code RuntimeEnvironment} under test, e.g. {@code FLINK-1_18} or {@code FLINK-2_3}. */
protected abstract String runtime();
private KinesisAnalyticsV2Client kda;
private String applicationName = TestFixtures.uniqueName("flink-app");
private Instant createTimestamp;
private boolean reachedRunning;
@BeforeAll
void setup() {
kda = TestFixtures.kinesisAnalyticsV2Client();
}
@AfterAll
void cleanup() {
if (kda != null) {
if (applicationName != null && createTimestamp != null) {
try {
ensureStopped();
kda.deleteApplication(DeleteApplicationRequest.builder()
.applicationName(applicationName)
.createTimestamp(createTimestamp)
.build());
} catch (Exception e) {
// Tolerated (best-effort teardown) but logged with context per AGENTS.md.
System.err.println("[WARN] AfterAll cleanup failed for application "
+ applicationName + ": " + e.getMessage());
}
}
kda.close();
}
}
@Test
@Order(1)
void createApplicationLandsReady() {
// runtime() is passed as a String: FLINK-2_3 is newer than this SDK's RuntimeEnvironment enum
// (2.44.14). The generated builder supports the String overload; assert via
// runtimeEnvironmentAsString() so both the enum-known (1.18) and newer (2.3) values work.
CreateApplicationResponse response = kda.createApplication(CreateApplicationRequest.builder()
.applicationName(applicationName)
.runtimeEnvironment(runtime())
.serviceExecutionRole(ROLE)
.build());
ApplicationDetail detail = response.applicationDetail();
assertThat(detail.applicationName()).isEqualTo(applicationName);
assertThat(detail.applicationARN()).contains(":kinesisanalytics:");
// AWS-faithful: a freshly created application is READY, not RUNNING.
assertThat(detail.applicationStatus()).isEqualTo(ApplicationStatus.READY);
assertThat(detail.runtimeEnvironmentAsString()).isEqualTo(runtime());
createTimestamp = detail.createTimestamp();
}
@Test
@Order(2)
void startApplicationReachesRunning() {
requireApp();
boolean started;
try {
kda.startApplication(StartApplicationRequest.builder()
.applicationName(applicationName)
.build());
started = true;
} catch (KinesisAnalyticsV2Exception e) {
// Container provisioning failed (Docker likely unavailable) — skip rather than fail.
started = false;
}
Assumptions.assumeTrue(started,
"StartApplication failed (Docker likely unavailable); skipping RUNNING checks");
// Flink 2.x boots noticeably slower than 1.x (multi-pass JVM config parser), so allow more time.
ApplicationDetail detail = waitForStatus(ApplicationStatus.RUNNING, Duration.ofSeconds(300));
Assumptions.assumeTrue(detail != null,
"Application did not reach RUNNING (Docker/Flink likely unavailable); skipping");
reachedRunning = true;
assertThat(detail.applicationStatus()).isEqualTo(ApplicationStatus.RUNNING);
assertThat(detail.runtimeEnvironmentAsString()).isEqualTo(runtime());
}
@Test
@Order(3)
void listApplicationsIncludesCreated() {
requireApp();
var response = kda.listApplications(ListApplicationsRequest.builder().build());
assertThat(response.applicationSummaries())
.anyMatch(s -> s.applicationName().equals(applicationName));
}
@Test
@Order(4)
void stopApplicationReturnsToReady() {
requireApp();
Assumptions.assumeTrue(reachedRunning,
"Application never reached RUNNING; skipping stop");
kda.stopApplication(StopApplicationRequest.builder()
.applicationName(applicationName)
.build());
ApplicationDetail detail = waitForStatus(ApplicationStatus.READY, Duration.ofSeconds(60));
assertThat(detail).isNotNull();
assertThat(detail.applicationStatus()).isEqualTo(ApplicationStatus.READY);
}
@Test
@Order(5)
void unsupportedRuntimeRejected() {
// Floci only backs FLINK-x_y runtimes with a plain Flink image; the SQL and Studio (Zeppelin)
// runtimes are rejected with InvalidArgumentException.
assertThatThrownBy(() -> kda.createApplication(CreateApplicationRequest.builder()
.applicationName(TestFixtures.uniqueName("flink-bad"))
.runtimeEnvironment(RuntimeEnvironment.ZEPPELIN_FLINK_3_0)
.serviceExecutionRole(ROLE)
.build()))
.isInstanceOf(KinesisAnalyticsV2Exception.class);
}
@Test
@Order(6)
void deleteApplication() {
requireApp();
// AWS rejects DeleteApplication on a non-stopped application (ResourceInUseException), so
// ensure it is READY first (it may still be STARTING if it never reached RUNNING in time).
ensureStopped();
kda.deleteApplication(DeleteApplicationRequest.builder()
.applicationName(applicationName)
.createTimestamp(createTimestamp)
.build());
applicationName = null;
}
/** Best-effort: stop the application if it is RUNNING/STARTING so it can be deleted from READY. */
private void ensureStopped() {
try {
ApplicationStatus status = kda.describeApplication(DescribeApplicationRequest.builder()
.applicationName(applicationName)
.build()).applicationDetail().applicationStatus();
if (status == ApplicationStatus.RUNNING || status == ApplicationStatus.STARTING) {
kda.stopApplication(StopApplicationRequest.builder()
.applicationName(applicationName)
.build());
waitForStatus(ApplicationStatus.READY, Duration.ofSeconds(60));
}
} catch (Exception e) {
System.err.println("[WARN] ensureStopped failed for application "
+ applicationName + ": " + e.getMessage());
}
}
private ApplicationDetail waitForStatus(ApplicationStatus target, Duration timeout) {
Instant deadline = Instant.now().plus(timeout);
while (Instant.now().isBefore(deadline)) {
ApplicationDetail detail = kda.describeApplication(DescribeApplicationRequest.builder()
.applicationName(applicationName)
.build()).applicationDetail();
if (detail.applicationStatus() == target) {
return detail;
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
return null;
}
private void requireApp() {
Assumptions.assumeTrue(applicationName != null,
"Application must exist from earlier ordered test");
}
}
@@ -0,0 +1,54 @@
package com.floci.test;
import org.jboss.logging.Logger;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.accessanalyzer.AccessAnalyzerClient;
import software.amazon.awssdk.services.accessanalyzer.model.ServiceQuotaExceededException;
import software.amazon.awssdk.services.accessanalyzer.model.Type;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
@DisplayName("IAM Access Analyzer lifecycle")
class AccessAnalyzerTest {
private static final Logger LOG = Logger.getLogger(AccessAnalyzerTest.class);
@Test
void analyzerLifecycleAndTypeSpecificQuotaUseAwsSdk() {
assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only analyzer names and quota assertions");
try (AccessAnalyzerClient client = TestFixtures.accessAnalyzerClient()) {
String accountName = "floci-account-analyzer";
String unusedName = "floci-unused-analyzer";
String secondAccountName = "floci-account-analyzer-2";
try {
client.createAnalyzer(request -> request.analyzerName(accountName).type(Type.ACCOUNT));
client.createAnalyzer(request -> request.analyzerName(unusedName).type(Type.ACCOUNT_UNUSED_ACCESS));
var listed = client.listAnalyzers(request -> {});
assertThat(listed.analyzers())
.extracting(analyzer -> analyzer.name())
.contains(accountName, unusedName);
assertThatThrownBy(() -> client.createAnalyzer(request -> request
.analyzerName(secondAccountName)
.type(Type.ACCOUNT)))
.isInstanceOf(ServiceQuotaExceededException.class);
} finally {
deleteBestEffort(client, unusedName);
deleteBestEffort(client, accountName);
}
}
}
private static void deleteBestEffort(AccessAnalyzerClient client, String analyzerName) {
try {
client.deleteAnalyzer(request -> request.analyzerName(analyzerName));
} catch (Exception cleanupError) {
LOG.warnf(cleanupError, "Best-effort cleanup failed for analyzerName=%s", analyzerName);
}
}
}
@@ -0,0 +1,33 @@
package com.floci.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.account.AccountClient;
import software.amazon.awssdk.services.account.model.AlternateContactType;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
@DisplayName("AWS Account Management alternate contacts")
class AccountAlternateContactTest {
@Test
void putAndGetAlternateContactUseAwsSdk() {
assumeFalse(TestFixtures.isRealAws(), "Writes emulator account contact state");
try (AccountClient account = TestFixtures.accountClient()) {
account.putAlternateContact(request -> request
.alternateContactType(AlternateContactType.SECURITY)
.emailAddress("security@example.com")
.name("Security Team")
.phoneNumber("+1 555 0100")
.title("Security"));
var response = account.getAlternateContact(request -> request
.alternateContactType(AlternateContactType.SECURITY));
assertThat(response.alternateContact()).isNotNull();
assertThat(response.alternateContact().emailAddress()).isEqualTo("security@example.com");
assertThat(response.alternateContact().alternateContactType()).isEqualTo(AlternateContactType.SECURITY);
}
}
}
@@ -0,0 +1,388 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.core.retry.backoff.FixedDelayBackoffStrategy;
import software.amazon.awssdk.core.waiters.WaiterResponse;
import software.amazon.awssdk.services.acm.AcmClient;
import software.amazon.awssdk.services.acm.model.*;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
@DisplayName("ACM Certificate Manager")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class AcmTest {
private static AcmClient acm;
private static final List<String> arnsToCleanup = new ArrayList<>();
// Lifecycle test state
private static String requestedCertArn;
// Import/Export test state
private static String importedCertArn;
private static byte[] importedCertPem;
private static byte[] importedKeyPem;
private static String exportTestCertArn;
// Tagging test state
private static String tagTestCertArn;
@BeforeAll
static void setup() {
acm = TestFixtures.acmClient();
}
@AfterAll
static void cleanup() {
if (acm != null) {
for (String arn : arnsToCleanup) {
try {
acm.deleteCertificate(b -> b.certificateArn(arn));
} catch (Exception ignored) {}
}
acm.close();
}
}
// ============================================
// Helper: generate a self-signed certificate using openssl
// ============================================
private static byte[][] generateSelfSignedCert() throws Exception {
Path keyFile = Files.createTempFile("test-key", ".pem");
Path certFile = Files.createTempFile("test-cert", ".pem");
try {
ProcessBuilder pb = new ProcessBuilder("openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", keyFile.toString(), "-out", certFile.toString(),
"-days", "365", "-nodes", "-subj", "/CN=test.example.com");
pb.redirectErrorStream(true);
Process p = pb.start();
p.getInputStream().readAllBytes(); // consume output
int exitCode = p.waitFor();
if (exitCode != 0) {
throw new RuntimeException("openssl failed with exit code " + exitCode);
}
byte[] certPem = Files.readAllBytes(certFile);
byte[] keyPem = Files.readAllBytes(keyFile);
return new byte[][] { certPem, keyPem };
} finally {
Files.deleteIfExists(keyFile);
Files.deleteIfExists(certFile);
}
}
// ============================================
// US1: Lifecycle tests
// ============================================
@Test
@Order(1)
@DisplayName("Request a certificate")
void testRequestCertificate() {
String domain = TestFixtures.uniqueName("java-test") + ".example.com";
RequestCertificateResponse response = acm.requestCertificate(b -> b
.domainName(domain));
requestedCertArn = response.certificateArn();
arnsToCleanup.add(requestedCertArn);
assertThat(requestedCertArn).isNotNull();
assertThat(requestedCertArn).matches("arn:aws:acm:.*:.*:certificate/.*");
}
@Test
@Order(2)
@DisplayName("Describe a certificate")
void testDescribeCertificate() {
Assumptions.assumeTrue(requestedCertArn != null, "RequestCertificate must succeed first");
DescribeCertificateResponse response = acm.describeCertificate(b -> b
.certificateArn(requestedCertArn));
CertificateDetail detail = response.certificate();
assertThat(detail.domainName()).contains("example.com");
assertThat(detail.statusAsString()).isNotNull();
}
@Test
@Order(2)
@DisplayName("CertificateValidated waiter completes for an issued certificate")
void testCertificateValidatedWaiterCompletes() {
Assumptions.assumeTrue(requestedCertArn != null, "RequestCertificate must succeed first");
Assumptions.assumeFalse(TestFixtures.isRealAws(), "real ACM waits for DNS validation");
// The waiter polls DomainValidationOptions[].ValidationStatus, as the CDK
// DnsValidatedCertificate handler does, and never returns while any domain is pending.
WaiterResponse<DescribeCertificateResponse> waited = acm.waiter().waitUntilCertificateValidated(
b -> b.certificateArn(requestedCertArn),
o -> o.maxAttempts(3).backoffStrategy(FixedDelayBackoffStrategy.create(Duration.ofSeconds(1))));
CertificateDetail detail = waited.matched().response().orElseThrow().certificate();
assertThat(detail.status()).isEqualTo(CertificateStatus.ISSUED);
assertThat(detail.domainValidationOptions()).isNotEmpty();
assertThat(detail.domainValidationOptions())
.allSatisfy(v -> assertThat(v.validationStatus()).isEqualTo(DomainStatus.SUCCESS));
}
@Test
@Order(3)
@DisplayName("Get a certificate whose chain validates it")
void testGetCertificate() throws Exception {
Assumptions.assumeTrue(requestedCertArn != null, "RequestCertificate must succeed first");
GetCertificateResponse response = acm.getCertificate(b -> b
.certificateArn(requestedCertArn));
assertThat(response.certificate()).isNotNull();
assertThat(response.certificate()).contains("BEGIN CERTIFICATE");
assertThat(response.certificateChain()).isNotNull();
// Certificate and CertificateChain form a chain of trust: the first chain entry is the
// issuing CA, so a client that pins the chain can validate the leaf.
CertificateFactory factory = CertificateFactory.getInstance("X.509");
X509Certificate leaf = (X509Certificate) factory.generateCertificate(
new ByteArrayInputStream(response.certificate().getBytes(StandardCharsets.US_ASCII)));
List<X509Certificate> chain = factory.generateCertificates(
new ByteArrayInputStream(response.certificateChain().getBytes(StandardCharsets.US_ASCII)))
.stream().map(X509Certificate.class::cast).toList();
assertThat(chain).isNotEmpty();
assertThat(leaf.getIssuerX500Principal()).isEqualTo(chain.get(0).getSubjectX500Principal());
assertThat(leaf.getBasicConstraints()).isEqualTo(-1);
leaf.verify(chain.get(0).getPublicKey());
}
@Test
@Order(4)
@DisplayName("List certificates contains the requested cert")
void testListCertificates() {
Assumptions.assumeTrue(requestedCertArn != null, "RequestCertificate must succeed first");
ListCertificatesResponse response = acm.listCertificates();
assertThat(response.certificateSummaryList())
.anyMatch(s -> s.certificateArn().equals(requestedCertArn));
}
@Test
@Order(5)
@DisplayName("Delete a certificate")
void testDeleteCertificate() {
Assumptions.assumeTrue(requestedCertArn != null, "RequestCertificate must succeed first");
acm.deleteCertificate(b -> b.certificateArn(requestedCertArn));
arnsToCleanup.remove(requestedCertArn);
assertThatThrownBy(() -> acm.describeCertificate(b -> b
.certificateArn(requestedCertArn)))
.isInstanceOf(AcmException.class);
}
// ============================================
// US2: Import/Export tests
// ============================================
@Test
@Order(10)
@DisplayName("Import a self-signed certificate")
void testImportCertificate() throws Exception {
byte[][] certAndKey = generateSelfSignedCert();
importedCertPem = certAndKey[0];
importedKeyPem = certAndKey[1];
ImportCertificateResponse response = acm.importCertificate(b -> b
.certificate(SdkBytes.fromByteArray(importedCertPem))
.privateKey(SdkBytes.fromByteArray(importedKeyPem)));
importedCertArn = response.certificateArn();
arnsToCleanup.add(importedCertArn);
assertThat(importedCertArn).isNotNull();
assertThat(importedCertArn).matches("arn:aws:acm:.*:.*:certificate/.*");
}
@Test
@Order(11)
@DisplayName("Get an imported certificate")
void testGetImportedCertificate() {
Assumptions.assumeTrue(importedCertArn != null, "ImportCertificate must succeed first");
GetCertificateResponse response = acm.getCertificate(b -> b
.certificateArn(importedCertArn));
assertThat(response.certificate()).isNotNull();
assertThat(response.certificate()).contains("BEGIN CERTIFICATE");
}
@Test
@Order(12)
@DisplayName("Export an imported certificate with passphrase")
void testExportCertificate() {
Assumptions.assumeTrue(importedCertArn != null, "ImportCertificate must succeed first");
SdkBytes passphrase = SdkBytes.fromUtf8String("test-passphrase-123");
ExportCertificateResponse response = acm.exportCertificate(b -> b
.certificateArn(importedCertArn)
.passphrase(passphrase));
assertThat(response.certificate()).isNotNull();
assertThat(response.certificate()).contains("BEGIN CERTIFICATE");
assertThat(response.privateKey()).isNotNull();
assertThat(response.privateKey()).contains("-----BEGIN");
}
@Test
@Order(13)
@DisplayName("Export a requested (non-imported) certificate fails")
void testExportRequestedCertificateFails() {
// Request a new cert (not imported, so no private key to export)
String domain = TestFixtures.uniqueName("export-fail") + ".example.com";
RequestCertificateResponse reqResp = acm.requestCertificate(b -> b.domainName(domain));
exportTestCertArn = reqResp.certificateArn();
arnsToCleanup.add(exportTestCertArn);
SdkBytes passphrase = SdkBytes.fromUtf8String("test-passphrase");
assertThatThrownBy(() -> acm.exportCertificate(b -> b
.certificateArn(exportTestCertArn)
.passphrase(passphrase)))
.isInstanceOf(AcmException.class);
}
// ============================================
// US3: Tagging tests
// ============================================
@Test
@Order(20)
@DisplayName("Add and list tags on a certificate")
void testAddTags() {
String domain = TestFixtures.uniqueName("tag-test") + ".example.com";
RequestCertificateResponse reqResp = acm.requestCertificate(b -> b.domainName(domain));
tagTestCertArn = reqResp.certificateArn();
arnsToCleanup.add(tagTestCertArn);
acm.addTagsToCertificate(b -> b
.certificateArn(tagTestCertArn)
.tags(
software.amazon.awssdk.services.acm.model.Tag.builder().key("Environment").value("test").build(),
software.amazon.awssdk.services.acm.model.Tag.builder().key("Project").value("floci").build()
));
ListTagsForCertificateResponse tagsResp = acm.listTagsForCertificate(b -> b
.certificateArn(tagTestCertArn));
assertThat(tagsResp.tags()).hasSize(2);
assertThat(tagsResp.tags())
.anyMatch(t -> t.key().equals("Environment") && t.value().equals("test"));
assertThat(tagsResp.tags())
.anyMatch(t -> t.key().equals("Project") && t.value().equals("floci"));
}
@Test
@Order(21)
@DisplayName("Remove a tag from a certificate")
void testRemoveTags() {
Assumptions.assumeTrue(tagTestCertArn != null, "AddTags must succeed first");
acm.removeTagsFromCertificate(b -> b
.certificateArn(tagTestCertArn)
.tags(software.amazon.awssdk.services.acm.model.Tag.builder().key("Project").value("floci").build()));
ListTagsForCertificateResponse tagsResp = acm.listTagsForCertificate(b -> b
.certificateArn(tagTestCertArn));
assertThat(tagsResp.tags()).hasSize(1);
assertThat(tagsResp.tags())
.anyMatch(t -> t.key().equals("Environment") && t.value().equals("test"));
assertThat(tagsResp.tags())
.noneMatch(t -> t.key().equals("Project"));
}
// ============================================
// US4: Account configuration tests
// ============================================
@Test
@Order(30)
@DisplayName("Put and get account configuration")
void testPutAndGetAccountConfiguration() {
acm.putAccountConfiguration(b -> b
.expiryEvents(e -> e.daysBeforeExpiry(45))
.idempotencyToken(TestFixtures.uniqueName()));
var response = acm.getAccountConfiguration(b -> b.build());
assertThat(response.expiryEvents()).isNotNull();
assertThat(response.expiryEvents().daysBeforeExpiry()).isEqualTo(45);
}
// ============================================
// US5: Error handling tests
// ============================================
@Test
@Order(40)
@DisplayName("Describe a non-existent certificate throws exception")
void testDescribeNonExistentCertificate() {
String fakeArn = "arn:aws:acm:us-east-1:000000000000:certificate/00000000-0000-0000-0000-000000000000";
assertThatThrownBy(() -> acm.describeCertificate(b -> b
.certificateArn(fakeArn)))
.isInstanceOf(AcmException.class);
}
@Test
@Order(41)
@DisplayName("Request certificate with SANs")
void testRequestWithSANs() {
String domain = TestFixtures.uniqueName("san-test") + ".example.com";
String san1 = "www." + domain;
String san2 = "api." + domain;
RequestCertificateResponse response = acm.requestCertificate(b -> b
.domainName(domain)
.subjectAlternativeNames(san1, san2));
String arn = response.certificateArn();
arnsToCleanup.add(arn);
assertThat(arn).isNotNull();
DescribeCertificateResponse descResp = acm.describeCertificate(b -> b
.certificateArn(arn));
assertThat(descResp.certificate().subjectAlternativeNames())
.contains(san1, san2);
}
@Test
@Order(42)
@DisplayName("Import invalid PEM throws exception")
void testImportInvalidPEM() {
byte[] garbage = "this is not a valid certificate".getBytes();
assertThatThrownBy(() -> acm.importCertificate(b -> b
.certificate(SdkBytes.fromByteArray(garbage))
.privateKey(SdkBytes.fromByteArray(garbage))))
.isInstanceOf(AcmException.class);
}
}
@@ -0,0 +1,161 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.mq.MqClient;
import software.amazon.awssdk.services.mq.model.BrokerState;
import software.amazon.awssdk.services.mq.model.CreateBrokerRequest;
import software.amazon.awssdk.services.mq.model.CreateBrokerResponse;
import software.amazon.awssdk.services.mq.model.CreateUserRequest;
import software.amazon.awssdk.services.mq.model.DeleteBrokerRequest;
import software.amazon.awssdk.services.mq.model.DeploymentMode;
import software.amazon.awssdk.services.mq.model.DescribeBrokerRequest;
import software.amazon.awssdk.services.mq.model.DescribeBrokerResponse;
import software.amazon.awssdk.services.mq.model.EngineType;
import software.amazon.awssdk.services.mq.model.ListBrokersRequest;
import software.amazon.awssdk.services.mq.model.MqException;
import software.amazon.awssdk.services.mq.model.User;
import java.time.Duration;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Exercises the real RabbitMQ container path: CreateBroker provisions a
* rabbitmq:3-management container, the broker transitions to RUNNING once its
* management API answers, and the AMQP/console endpoints are published. Also
* confirms the AWS-faithful rejection of the ActiveMQ-only User API on RabbitMQ.
*
* <p>Requires a running Floci with {@code amazonmq.mock=false} and a working
* Docker daemon. When the broker never reaches RUNNING (Docker unavailable in
* CI), the dependent assertions skip rather than fail.
*/
@DisplayName("Amazon MQ (RabbitMQ)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class AmazonMqTest {
private static MqClient mq;
private static String brokerId;
private static boolean reachedRunning;
private static final String BROKER_NAME = TestFixtures.uniqueName("mq-broker");
@BeforeAll
static void setup() {
mq = TestFixtures.mqClient();
}
@AfterAll
static void cleanup() {
if (mq != null) {
if (brokerId != null) {
try {
mq.deleteBroker(DeleteBrokerRequest.builder().brokerId(brokerId).build());
} catch (Exception ignored) {}
}
mq.close();
}
}
@Test
@Order(1)
void createBroker() {
CreateBrokerResponse response = mq.createBroker(CreateBrokerRequest.builder()
.brokerName(BROKER_NAME)
.engineType(EngineType.RABBITMQ)
.engineVersion("3.13")
.deploymentMode(DeploymentMode.SINGLE_INSTANCE)
.hostInstanceType("mq.t3.micro")
.publiclyAccessible(false)
.autoMinorVersionUpgrade(true)
.users(User.builder()
.username("admin")
.password("AdminPass123")
.consoleAccess(true)
.build())
.build());
assertThat(response.brokerId()).isNotNull();
assertThat(response.brokerArn()).contains(":mq:");
brokerId = response.brokerId();
}
@Test
@Order(2)
void brokerReachesRunningWithEndpoints() {
requireBroker();
DescribeBrokerResponse describe = waitForState(BrokerState.RUNNING, Duration.ofSeconds(120));
Assumptions.assumeTrue(describe != null,
"Broker did not reach RUNNING (Docker likely unavailable); skipping endpoint checks");
reachedRunning = true;
assertThat(describe.brokerName()).isEqualTo(BROKER_NAME);
assertThat(describe.engineType()).isEqualTo(EngineType.RABBITMQ);
assertThat(describe.brokerInstances()).isNotEmpty();
assertThat(describe.brokerInstances().get(0).endpoints().get(0)).startsWith("amqp://");
assertThat(describe.brokerInstances().get(0).consoleURL()).startsWith("http://");
}
@Test
@Order(3)
void listBrokersIncludesCreated() {
requireBroker();
var response = mq.listBrokers(ListBrokersRequest.builder().build());
assertThat(response.brokerSummaries()).anyMatch(b -> b.brokerId().equals(brokerId));
}
@Test
@Order(4)
void userApiRejectedForRabbitMq() {
requireBroker();
// The standalone User API is ActiveMQ-only; AWS rejects it for RabbitMQ.
assertThatThrownBy(() -> mq.createUser(CreateUserRequest.builder()
.brokerId(brokerId)
.username("alice")
.password("AnotherPass99")
.build()))
.isInstanceOf(MqException.class);
}
@Test
@Order(5)
void deleteBroker() {
requireBroker();
var response = mq.deleteBroker(DeleteBrokerRequest.builder().brokerId(brokerId).build());
assertThat(response.brokerId()).isEqualTo(brokerId);
brokerId = null;
}
private DescribeBrokerResponse waitForState(BrokerState target, Duration timeout) {
Instant deadline = Instant.now().plus(timeout);
while (Instant.now().isBefore(deadline)) {
DescribeBrokerResponse describe = mq.describeBroker(
DescribeBrokerRequest.builder().brokerId(brokerId).build());
if (describe.brokerState() == target) {
return describe;
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
return null;
}
private static void requireBroker() {
Assumptions.assumeTrue(brokerId != null, "Broker must exist from earlier ordered test");
}
}
@@ -0,0 +1,174 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.amp.AmpClient;
import software.amazon.awssdk.services.amp.model.CreateRuleGroupsNamespaceResponse;
import software.amazon.awssdk.services.amp.model.CreateWorkspaceResponse;
import software.amazon.awssdk.services.amp.model.DescribeRuleGroupsNamespaceResponse;
import software.amazon.awssdk.services.amp.model.DescribeWorkspaceResponse;
import software.amazon.awssdk.services.amp.model.ListRuleGroupsNamespacesResponse;
import software.amazon.awssdk.services.amp.model.ListWorkspacesResponse;
import software.amazon.awssdk.services.amp.model.ResourceNotFoundException;
import software.amazon.awssdk.services.amp.model.RuleGroupsNamespaceStatusCode;
import software.amazon.awssdk.services.amp.model.WorkspaceStatusCode;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("Managed Prometheus (AMP)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class AmpTest {
private static AmpClient amp;
private static String alias;
private static String workspaceId;
private static String workspaceArn;
@BeforeAll
static void setup() {
amp = TestFixtures.ampClient();
alias = "sdk-test-workspace-" + System.currentTimeMillis();
}
@AfterAll
static void cleanup() {
if (amp != null) {
if (workspaceId != null) {
try {
amp.deleteWorkspace(r -> r.workspaceId(workspaceId));
} catch (Exception ignored) {}
}
amp.close();
}
}
@Test
@Order(1)
void createWorkspace() {
CreateWorkspaceResponse response = amp.createWorkspace(r -> r
.alias(alias)
.tags(Map.of("team", "devops")));
workspaceId = response.workspaceId();
workspaceArn = response.arn();
assertThat(workspaceId).startsWith("ws-");
assertThat(workspaceArn).contains(":aps:").contains(":workspace/" + workspaceId);
assertThat(response.status().statusCode()).isEqualTo(WorkspaceStatusCode.ACTIVE);
assertThat(response.tags()).containsEntry("team", "devops");
}
@Test
@Order(2)
void describeWorkspace() {
DescribeWorkspaceResponse response = amp.describeWorkspace(r -> r.workspaceId(workspaceId));
assertThat(response.workspace().workspaceId()).isEqualTo(workspaceId);
assertThat(response.workspace().alias()).isEqualTo(alias);
assertThat(response.workspace().arn()).isEqualTo(workspaceArn);
assertThat(response.workspace().status().statusCode()).isEqualTo(WorkspaceStatusCode.ACTIVE);
assertThat(response.workspace().prometheusEndpoint()).contains("/workspaces/" + workspaceId);
// Proves the wire timestamp is restJson1 epoch-seconds: an ISO string here fails SDK
// deserialization before any assertion runs.
assertThat(response.workspace().createdAt()).isNotNull();
assertThat(response.workspace().tags()).containsEntry("team", "devops");
}
@Test
@Order(3)
void listWorkspacesByAliasPrefix() {
ListWorkspacesResponse response = amp.listWorkspaces(r -> r.alias("sdk-test-workspace-"));
assertThat(response.workspaces())
.anySatisfy(w -> assertThat(w.workspaceId()).isEqualTo(workspaceId));
}
@Test
@Order(4)
void updateWorkspaceAlias() {
amp.updateWorkspaceAlias(r -> r.workspaceId(workspaceId).alias(alias + "-renamed"));
assertThat(amp.describeWorkspace(r -> r.workspaceId(workspaceId)).workspace().alias())
.isEqualTo(alias + "-renamed");
}
@Test
@Order(5)
void tagResourceRoundTrip() {
amp.tagResource(r -> r.resourceArn(workspaceArn).tags(Map.of("env", "test")));
assertThat(amp.listTagsForResource(r -> r.resourceArn(workspaceArn)).tags())
.containsEntry("team", "devops")
.containsEntry("env", "test");
amp.untagResource(r -> r.resourceArn(workspaceArn).tagKeys("env"));
assertThat(amp.listTagsForResource(r -> r.resourceArn(workspaceArn)).tags())
.containsEntry("team", "devops")
.doesNotContainKey("env");
}
@Test
@Order(6)
void ruleGroupsNamespaceLifecycle() {
SdkBytes rules = SdkBytes.fromUtf8String("groups:\n- name: alerts\n rules: []\n");
CreateRuleGroupsNamespaceResponse created = amp.createRuleGroupsNamespace(r -> r
.workspaceId(workspaceId)
.name("alerts")
.data(rules)
.tags(Map.of("team", "devops")));
assertThat(created.name()).isEqualTo("alerts");
assertThat(created.arn()).contains(":rulegroupsnamespace/" + workspaceId + "/alerts");
assertThat(created.status().statusCode()).isEqualTo(RuleGroupsNamespaceStatusCode.ACTIVE);
assertThat(created.tags()).containsEntry("team", "devops");
DescribeRuleGroupsNamespaceResponse described = amp.describeRuleGroupsNamespace(r -> r
.workspaceId(workspaceId).name("alerts"));
assertThat(described.ruleGroupsNamespace().arn()).isEqualTo(created.arn());
assertThat(described.ruleGroupsNamespace().data()).isEqualTo(rules);
assertThat(described.ruleGroupsNamespace().createdAt()).isNotNull();
assertThat(described.ruleGroupsNamespace().modifiedAt()).isNotNull();
ListRuleGroupsNamespacesResponse listed = amp.listRuleGroupsNamespaces(r -> r
.workspaceId(workspaceId).name("ale"));
assertThat(listed.ruleGroupsNamespaces())
.anySatisfy(n -> assertThat(n.name()).isEqualTo("alerts"));
SdkBytes updated = SdkBytes.fromUtf8String("groups:\n- name: updated\n rules: []\n");
amp.putRuleGroupsNamespace(r -> r.workspaceId(workspaceId).name("alerts").data(updated));
assertThat(amp.describeRuleGroupsNamespace(r -> r.workspaceId(workspaceId).name("alerts"))
.ruleGroupsNamespace().data()).isEqualTo(updated);
amp.deleteRuleGroupsNamespace(r -> r.workspaceId(workspaceId).name("alerts"));
assertThatThrownBy(() -> amp.describeRuleGroupsNamespace(r -> r
.workspaceId(workspaceId).name("alerts")))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
@Order(7)
void deleteWorkspaceThenDescribeThrowsResourceNotFound() {
amp.deleteWorkspace(r -> r.workspaceId(workspaceId));
// The terraform/pulumi provider's delete waiter matches this typed exception
// (errs.IsA[*types.ResourceNotFoundException]) to treat the workspace as gone.
assertThatThrownBy(() -> amp.describeWorkspace(r -> r.workspaceId(workspaceId)))
.isInstanceOf(ResourceNotFoundException.class);
workspaceId = null;
}
}
@@ -0,0 +1,38 @@
package com.floci.test;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import software.amazon.awssdk.services.apigateway.ApiGatewayClient;
import software.amazon.awssdk.services.apigateway.model.GetAccountRequest;
import software.amazon.awssdk.services.apigateway.model.GetAccountResponse;
import software.amazon.awssdk.services.apigateway.model.UpdateAccountRequest;
import software.amazon.awssdk.services.apigateway.model.PatchOperation;
class ApiGatewayAccountCompatibilityTest {
@Test
void getAndUpdateAccount_viaSdk() {
ApiGatewayClient apigw = TestFixtures.apiGatewayClient();
GetAccountResponse before = apigw.getAccount(GetAccountRequest.builder().build());
assertThat(before.apiKeyVersion()).isNotBlank();
assertThat(before.features()).contains("UsagePlans");
assertThat(before.throttleSettings()).isNotNull();
String arn = "arn:aws:iam::123456789012:role/apigAwsProxyRole";
PatchOperation op = PatchOperation.builder()
.op("replace")
.path("/cloudwatchRoleArn")
.value(arn)
.build();
apigw.updateAccount(UpdateAccountRequest.builder().patchOperations(op).build());
GetAccountResponse after = apigw.getAccount(GetAccountRequest.builder().build());
assertThat(after.cloudwatchRoleArn()).isEqualTo(arn);
// cleanup
PatchOperation remove = PatchOperation.builder().op("remove").path("/cloudwatchRoleArn").build();
apigw.updateAccount(UpdateAccountRequest.builder().patchOperations(remove).build());
}
}
@@ -0,0 +1,91 @@
package com.floci.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.apigateway.ApiGatewayClient;
import software.amazon.awssdk.services.apigateway.model.ApiKey;
import software.amazon.awssdk.services.apigateway.model.CreateApiKeyResponse;
import software.amazon.awssdk.services.apigateway.model.GetApiKeyResponse;
import software.amazon.awssdk.services.apigateway.model.GetApiKeysResponse;
import software.amazon.awssdk.services.apigateway.model.PatchOperation;
import software.amazon.awssdk.services.apigateway.model.UpdateApiKeyResponse;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("API Gateway API keys")
class ApiGatewayApiKeyCompatibilityTest {
@Test
@DisplayName("API key responses expose the complete AWS SDK shape")
void apiKeyResponsesExposeCompleteShape() {
ApiGatewayClient apiGateway = TestFixtures.apiGatewayClient();
String value = "11112222333344445555666677778888";
CreateApiKeyResponse created = apiGateway.createApiKey(request -> request
.name(TestFixtures.uniqueName("apigw-key"))
.enabled(true)
.customerId("marketplace-customer-1")
.value(value));
try {
assertThat(created.id()).isNotBlank().isNotEqualTo(value);
assertThat(created.value()).isEqualTo(value);
assertThat(created.customerId()).isEqualTo("marketplace-customer-1");
assertCompleteShape(created.createdDate(), created.lastUpdatedDate(),
created.hasStageKeys(), created.stageKeys(), created.hasTags(), created.tags());
GetApiKeyResponse read = apiGateway.getApiKey(request -> request.apiKey(created.id()));
assertThat(read.value()).isNull();
assertThat(read.customerId()).isEqualTo("marketplace-customer-1");
assertCompleteShape(read.createdDate(), read.lastUpdatedDate(),
read.hasStageKeys(), read.stageKeys(), read.hasTags(), read.tags());
GetApiKeyResponse readWithValue = apiGateway.getApiKey(request -> request
.apiKey(created.id())
.includeValue(true));
assertThat(readWithValue.value()).isEqualTo(value);
GetApiKeysResponse listed = apiGateway.getApiKeys();
ApiKey listedKey = listed.items().stream()
.filter(key -> created.id().equals(key.id()))
.findFirst()
.orElseThrow();
assertThat(listedKey.value()).isNull();
assertThat(listedKey.customerId()).isEqualTo("marketplace-customer-1");
assertCompleteShape(listedKey.createdDate(), listedKey.lastUpdatedDate(),
listedKey.hasStageKeys(), listedKey.stageKeys(), listedKey.hasTags(), listedKey.tags());
UpdateApiKeyResponse updated = apiGateway.updateApiKey(request -> request
.apiKey(created.id())
.patchOperations(PatchOperation.builder()
.op("replace")
.path("/customerId")
.value("marketplace-customer-2")
.build()));
assertThat(updated.value()).isNull();
assertThat(updated.customerId()).isEqualTo("marketplace-customer-2");
assertCompleteShape(updated.createdDate(), updated.lastUpdatedDate(),
updated.hasStageKeys(), updated.stageKeys(), updated.hasTags(), updated.tags());
} finally {
apiGateway.deleteApiKey(request -> request.apiKey(created.id()));
apiGateway.close();
}
}
private static void assertCompleteShape(Instant createdDate,
Instant lastUpdatedDate,
boolean hasStageKeys,
List<String> stageKeys,
boolean hasTags,
Map<String, String> tags) {
assertThat(createdDate).isNotNull();
assertThat(lastUpdatedDate).isNotNull();
assertThat(hasStageKeys).isTrue();
assertThat(stageKeys).isEmpty();
assertThat(hasTags).isTrue();
assertThat(tags).isEmpty();
}
}
@@ -0,0 +1,322 @@
package com.floci.test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.apigatewayv2.ApiGatewayV2Client;
import software.amazon.awssdk.services.apigatewayv2.model.AuthorizerType;
import software.amazon.awssdk.services.apigatewayv2.model.CreateApiRequest;
import software.amazon.awssdk.services.apigatewayv2.model.CreateApiResponse;
import software.amazon.awssdk.services.apigatewayv2.model.CreateAuthorizerRequest;
import software.amazon.awssdk.services.apigatewayv2.model.CreateAuthorizerResponse;
import software.amazon.awssdk.services.apigatewayv2.model.CreateDeploymentRequest;
import software.amazon.awssdk.services.apigatewayv2.model.CreateIntegrationRequest;
import software.amazon.awssdk.services.apigatewayv2.model.CreateIntegrationResponse;
import software.amazon.awssdk.services.apigatewayv2.model.CreateRouteRequest;
import software.amazon.awssdk.services.apigatewayv2.model.CreateStageRequest;
import software.amazon.awssdk.services.apigatewayv2.model.DeleteApiRequest;
import software.amazon.awssdk.services.apigatewayv2.model.IntegrationType;
import software.amazon.awssdk.services.lambda.LambdaClient;
import software.amazon.awssdk.services.lambda.model.CreateFunctionRequest;
import software.amazon.awssdk.services.lambda.model.DeleteFunctionRequest;
import software.amazon.awssdk.services.lambda.model.FunctionCode;
import software.amazon.awssdk.services.lambda.model.Runtime;
import java.io.ByteArrayOutputStream;
import java.net.ConnectException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("API Gateway V2 Execute")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ApiGatewayV2ExecuteTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final String ROLE = "arn:aws:iam::000000000000:role/lambda-role";
private static final String STAGE = "prod";
private static ApiGatewayV2Client apiGatewayV2;
private static LambdaClient lambda;
private static HttpClient http;
private static String apiId;
private static String functionName;
private static String baseUrl;
private static boolean lambdaDispatchAvailable;
@BeforeAll
static void setup() throws Exception {
apiGatewayV2 = TestFixtures.apiGatewayV2Client();
lambda = TestFixtures.lambdaClient();
http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
functionName = TestFixtures.uniqueName("http-api-fn");
try {
String functionArn = lambda.createFunction(CreateFunctionRequest.builder()
.functionName(functionName)
.runtime(Runtime.NODEJS20_X)
.role(ROLE)
.handler("index.handler")
.timeout(30)
.memorySize(256)
.code(FunctionCode.builder()
.zipFile(SdkBytes.fromByteArray(v2EchoHandlerZip()))
.build())
.build()).functionArn();
CreateApiResponse api = apiGatewayV2.createApi(CreateApiRequest.builder()
.name(TestFixtures.uniqueName("http-api"))
.protocolType("HTTP")
.build());
apiId = api.apiId();
CreateIntegrationResponse integration = apiGatewayV2.createIntegration(CreateIntegrationRequest.builder()
.apiId(apiId)
.integrationType(IntegrationType.AWS_PROXY)
.integrationUri(functionArn)
.payloadFormatVersion("2.0")
.build());
CreateAuthorizerResponse authorizer = apiGatewayV2.createAuthorizer(CreateAuthorizerRequest.builder()
.apiId(apiId)
.name("jwt-auth")
.authorizerType(AuthorizerType.JWT)
.identitySource("$request.header.Authorization")
.jwtConfiguration(b -> b
.issuer("https://issuer.example.test")
.audience("my-audience"))
.build());
apiGatewayV2.createRoute(CreateRouteRequest.builder()
.apiId(apiId)
.routeKey("POST /echo/{proxy+}")
.target("integrations/" + integration.integrationId())
.build());
apiGatewayV2.createRoute(CreateRouteRequest.builder()
.apiId(apiId)
.routeKey("GET /secure")
.authorizationType("JWT")
.authorizerId(authorizer.authorizerId())
.target("integrations/" + integration.integrationId())
.build());
String deploymentId = apiGatewayV2.createDeployment(CreateDeploymentRequest.builder()
.apiId(apiId)
.build()).deploymentId();
apiGatewayV2.createStage(CreateStageRequest.builder()
.apiId(apiId)
.stageName(STAGE)
.deploymentId(deploymentId)
.autoDeploy(false)
.build());
} catch (Exception e) {
Assumptions.assumeTrue(false, "API Gateway v2 execute setup unavailable in this environment: " + e.getMessage());
return;
}
baseUrl = TestFixtures.endpoint() + "/execute-api/" + apiId + "/" + STAGE;
try {
http.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/secure"))
.timeout(Duration.ofSeconds(5))
.GET()
.build(),
HttpResponse.BodyHandlers.ofString());
} catch (ConnectException e) {
Assumptions.assumeTrue(false, "Floci endpoint is not reachable at " + TestFixtures.endpoint());
}
// Probe result is memoized in TestFixtures; skip warmup if dispatch is unavailable
if (!TestFixtures.isLambdaDispatchAvailable()) {
lambdaDispatchAvailable = false;
return;
}
// Warm the API GW → Lambda dispatch path and verify the response carries
// the expected Lambda proxy envelope (statusCode in body). The direct
// probe above only tests raw invoke; APIGW integration may behave differently.
try {
HttpResponse<String> warmup = http.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/echo/warmup"))
.timeout(Duration.ofSeconds(30))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{}"))
.build(),
HttpResponse.BodyHandlers.ofString());
JsonNode warmupBody = JSON.readTree(warmup.body());
lambdaDispatchAvailable = warmup.statusCode() == 200
&& warmupBody.path("statusCode").asInt() == 200;
} catch (java.net.http.HttpTimeoutException | ConnectException e) {
// Transport-level failure: endpoint unreachable or timed out
lambdaDispatchAvailable = false;
}
}
@AfterAll
static void cleanup() {
if (apiGatewayV2 != null && apiId != null) {
try {
apiGatewayV2.deleteApi(DeleteApiRequest.builder().apiId(apiId).build());
} catch (Exception ignored) {
}
apiGatewayV2.close();
}
if (lambda != null && functionName != null) {
try {
lambda.deleteFunction(DeleteFunctionRequest.builder().functionName(functionName).build());
} catch (Exception ignored) {
}
lambda.close();
}
}
@Test
@Order(1)
void dispatchesHttpApiRouteToLambdaWithV2EventShape() throws Exception {
Assumptions.assumeTrue(lambdaDispatchAvailable,
"Lambda dispatch unavailable in this environment");
HttpResponse<String> response = http.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/echo/child/path?color=blue"))
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/json")
.header("X-Test-Header", "hello")
.POST(HttpRequest.BodyPublishers.ofString("{\"message\":\"hi\"}"))
.build(),
HttpResponse.BodyHandlers.ofString());
JsonNode body = JSON.readTree(response.body());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(body.path("statusCode").asInt()).isEqualTo(200);
JsonNode event = JSON.readTree(body.path("body").asText());
assertThat(event.path("version").asText()).isEqualTo("2.0");
assertThat(event.path("routeKey").asText()).isEqualTo("POST /echo/{proxy+}");
assertThat(event.path("rawPath").asText()).isEqualTo("/echo/child/path");
assertThat(event.path("requestContext").path("stage").asText()).isEqualTo(STAGE);
assertThat(event.path("requestContext").path("http").path("method").asText()).isEqualTo("POST");
assertThat(event.path("headers").path("x-test-header").asText()).isEqualTo("hello");
assertThat(event.path("queryStringParameters").path("color").asText()).isEqualTo("blue");
assertThat(event.path("body").asText()).isEqualTo("{\"message\":\"hi\"}");
}
@Test
@Order(2)
void jwtProtectedRouteRejectsMissingToken() throws Exception {
HttpResponse<String> response = http.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/secure"))
.timeout(Duration.ofSeconds(10))
.GET()
.build(),
HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(401);
assertThat(response.body()).contains("Unauthorized");
}
@Test
@Order(3)
void jwtProtectedRouteRejectsWrongAudience() throws Exception {
HttpResponse<String> response = http.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/secure"))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + jwt("https://issuer.example.test", "wrong-audience",
Instant.now().plusSeconds(300).getEpochSecond()))
.GET()
.build(),
HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(401);
assertThat(response.body()).contains("Unauthorized");
}
@Test
@Order(4)
void jwtProtectedRouteInvokesLambdaForValidToken() throws Exception {
Assumptions.assumeTrue(lambdaDispatchAvailable,
"Lambda dispatch not available in this environment");
HttpResponse<String> response = http.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/secure"))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + jwt("https://issuer.example.test", "my-audience",
Instant.now().plusSeconds(300).getEpochSecond()))
.header("User-Agent", "sdk-test-java")
.GET()
.build(),
HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
JsonNode body = JSON.readTree(response.body());
JsonNode event = JSON.readTree(body.path("body").asText());
assertThat(event.path("routeKey").asText()).isEqualTo("GET /secure");
assertThat(event.path("rawPath").asText()).isEqualTo("/secure");
assertThat(event.path("headers").path("authorization").asText()).contains("Bearer ");
assertThat(event.path("requestContext").path("http").path("method").asText()).isEqualTo("GET");
}
private static String jwt(String issuer, String audience, long exp) throws Exception {
String header = base64Url(JSON.writeValueAsBytes(Map.of("alg", "none", "typ", "JWT")));
String payload = base64Url(JSON.writeValueAsBytes(Map.of(
"iss", issuer,
"aud", audience,
"exp", exp
)));
return header + "." + payload + ".";
}
private static String base64Url(byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private static byte[] v2EchoHandlerZip() {
String code = """
exports.handler = async (event) => {
return {
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify(event)
};
};
""";
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
zos.putNextEntry(new ZipEntry("index.js"));
zos.write(code.getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
}
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to build API Gateway v2 Lambda ZIP", e);
}
}
}
@@ -0,0 +1,514 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.apigatewayv2.ApiGatewayV2Client;
import software.amazon.awssdk.services.apigatewayv2.model.ApiGatewayV2Exception;
import software.amazon.awssdk.services.apigatewayv2.model.AuthorizerType;
import software.amazon.awssdk.services.apigatewayv2.model.CreateApiRequest;
import software.amazon.awssdk.services.apigatewayv2.model.CreateAuthorizerRequest;
import software.amazon.awssdk.services.apigatewayv2.model.CreateDeploymentRequest;
import software.amazon.awssdk.services.apigatewayv2.model.CreateIntegrationRequest;
import software.amazon.awssdk.services.apigatewayv2.model.CreateRouteRequest;
import software.amazon.awssdk.services.apigatewayv2.model.CreateStageRequest;
import software.amazon.awssdk.services.apigatewayv2.model.DeleteApiRequest;
import software.amazon.awssdk.services.apigatewayv2.model.DeleteAuthorizerRequest;
import software.amazon.awssdk.services.apigatewayv2.model.DeleteDeploymentRequest;
import software.amazon.awssdk.services.apigatewayv2.model.DeleteIntegrationRequest;
import software.amazon.awssdk.services.apigatewayv2.model.DeleteRouteRequest;
import software.amazon.awssdk.services.apigatewayv2.model.DeleteStageRequest;
import software.amazon.awssdk.services.apigatewayv2.model.GetApiRequest;
import software.amazon.awssdk.services.apigatewayv2.model.GetApisResponse;
import software.amazon.awssdk.services.apigatewayv2.model.GetAuthorizerRequest;
import software.amazon.awssdk.services.apigatewayv2.model.GetAuthorizersRequest;
import software.amazon.awssdk.services.apigatewayv2.model.GetDeploymentRequest;
import software.amazon.awssdk.services.apigatewayv2.model.GetDeploymentsRequest;
import software.amazon.awssdk.services.apigatewayv2.model.GetIntegrationRequest;
import software.amazon.awssdk.services.apigatewayv2.model.GetIntegrationsRequest;
import software.amazon.awssdk.services.apigatewayv2.model.GetRouteRequest;
import software.amazon.awssdk.services.apigatewayv2.model.GetRoutesRequest;
import software.amazon.awssdk.services.apigatewayv2.model.GetStageRequest;
import software.amazon.awssdk.services.apigatewayv2.model.GetStagesRequest;
import software.amazon.awssdk.services.apigatewayv2.model.IntegrationType;
import software.amazon.awssdk.services.apigatewayv2.model.NotFoundException;
import software.amazon.awssdk.services.apigatewayv2.model.ProtocolType;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("API Gateway v2 management API")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ApiGatewayV2ManagementTest {
private static ApiGatewayV2Client apigwv2;
private static String apiId;
private static String authorizerId;
private static String integrationId;
private static String routeId;
private static String deploymentId;
private static final String stageName = "test";
private static boolean stageCreated;
@BeforeAll
static void setup() {
apigwv2 = TestFixtures.apiGatewayV2Client();
}
@AfterAll
static void cleanup() {
if (apigwv2 != null) {
if (apiId != null) {
deleteIfPresent(() -> apigwv2.deleteRoute(DeleteRouteRequest.builder()
.apiId(apiId)
.routeId(routeId)
.build()));
deleteIfPresent(() -> apigwv2.deleteIntegration(DeleteIntegrationRequest.builder()
.apiId(apiId)
.integrationId(integrationId)
.build()));
deleteIfPresent(() -> apigwv2.deleteAuthorizer(DeleteAuthorizerRequest.builder()
.apiId(apiId)
.authorizerId(authorizerId)
.build()));
deleteIfPresent(() -> apigwv2.deleteStage(DeleteStageRequest.builder()
.apiId(apiId)
.stageName(stageName)
.build()));
deleteIfPresent(() -> apigwv2.deleteDeployment(DeleteDeploymentRequest.builder()
.apiId(apiId)
.deploymentId(deploymentId)
.build()));
deleteIfPresent(() -> apigwv2.deleteApi(DeleteApiRequest.builder()
.apiId(apiId)
.build()));
}
apigwv2.close();
}
}
@Test
@Order(1)
void createApi() {
var response = apigwv2.createApi(CreateApiRequest.builder()
.name(TestFixtures.uniqueName("http-api"))
.protocolType(ProtocolType.HTTP)
.build());
apiId = response.apiId();
assertThat(apiId).isNotBlank();
assertThat(response.name()).startsWith("http-api-");
assertThat(response.protocolType()).isEqualTo(ProtocolType.HTTP);
assertThat(response.apiEndpoint()).contains(apiId + ".execute-api.us-east-1.amazonaws.com");
assertThat(response.createdDate()).isNotNull();
}
@Test
@Order(2)
void getApi() {
requireApi();
var response = apigwv2.getApi(GetApiRequest.builder()
.apiId(apiId)
.build());
assertThat(response.apiId()).isEqualTo(apiId);
assertThat(response.protocolType()).isEqualTo(ProtocolType.HTTP);
}
@Test
@Order(3)
void listApis() {
requireApi();
GetApisResponse response = apigwv2.getApis();
assertThat(response.items())
.extracting(item -> item.apiId())
.contains(apiId);
}
@Test
@Order(4)
void createAuthorizer() {
requireApi();
var response = apigwv2.createAuthorizer(CreateAuthorizerRequest.builder()
.apiId(apiId)
.name(TestFixtures.uniqueName("jwt-auth"))
.authorizerType(AuthorizerType.JWT)
.identitySource("$request.header.Authorization")
.jwtConfiguration(jwt -> jwt
.issuer("https://issuer.example.com")
.audience(List.of("aud-1", "aud-2")))
.build());
authorizerId = response.authorizerId();
assertThat(authorizerId).isNotBlank();
assertThat(response.authorizerType()).isEqualTo(AuthorizerType.JWT);
assertThat(response.identitySource()).containsExactly("$request.header.Authorization");
assertThat(response.jwtConfiguration().issuer()).isEqualTo("https://issuer.example.com");
assertThat(response.jwtConfiguration().audience()).containsExactly("aud-1", "aud-2");
}
@Test
@Order(5)
void getAndListAuthorizers() {
requireApi();
requireAuthorizer();
var getResponse = apigwv2.getAuthorizer(GetAuthorizerRequest.builder()
.apiId(apiId)
.authorizerId(authorizerId)
.build());
assertThat(getResponse.authorizerId()).isEqualTo(authorizerId);
var listResponse = apigwv2.getAuthorizers(GetAuthorizersRequest.builder()
.apiId(apiId)
.build());
assertThat(listResponse.items())
.extracting(item -> item.authorizerId())
.contains(authorizerId);
}
@Test
@Order(6)
void createIntegration() {
requireApi();
var response = apigwv2.createIntegration(CreateIntegrationRequest.builder()
.apiId(apiId)
.integrationType(IntegrationType.AWS_PROXY)
.integrationUri("arn:aws:lambda:us-east-1:000000000000:function:phase2-handler")
.payloadFormatVersion("2.0")
.build());
integrationId = response.integrationId();
assertThat(integrationId).isNotBlank();
assertThat(response.integrationType()).isEqualTo(IntegrationType.AWS_PROXY);
assertThat(response.integrationUri()).isEqualTo("arn:aws:lambda:us-east-1:000000000000:function:phase2-handler");
assertThat(response.payloadFormatVersion()).isEqualTo("2.0");
}
@Test
@Order(7)
void getAndListIntegrations() {
requireApi();
requireIntegration();
var getResponse = apigwv2.getIntegration(GetIntegrationRequest.builder()
.apiId(apiId)
.integrationId(integrationId)
.build());
assertThat(getResponse.integrationId()).isEqualTo(integrationId);
var listResponse = apigwv2.getIntegrations(GetIntegrationsRequest.builder()
.apiId(apiId)
.build());
assertThat(listResponse.items())
.extracting(item -> item.integrationId())
.contains(integrationId);
}
@Test
@Order(8)
void createRoute() {
requireApi();
requireAuthorizer();
requireIntegration();
var response = apigwv2.createRoute(CreateRouteRequest.builder()
.apiId(apiId)
.routeKey("GET /phase2")
.authorizationType("JWT")
.authorizerId(authorizerId)
.target("integrations/" + integrationId)
.build());
routeId = response.routeId();
assertThat(routeId).isNotBlank();
assertThat(response.routeKey()).isEqualTo("GET /phase2");
assertThat(response.authorizationTypeAsString()).isEqualTo("JWT");
assertThat(response.target()).isEqualTo("integrations/" + integrationId);
}
@Test
@Order(9)
void getAndListRoutes() {
requireApi();
requireRoute();
var getResponse = apigwv2.getRoute(GetRouteRequest.builder()
.apiId(apiId)
.routeId(routeId)
.build());
assertThat(getResponse.routeId()).isEqualTo(routeId);
var listResponse = apigwv2.getRoutes(GetRoutesRequest.builder()
.apiId(apiId)
.build());
assertThat(listResponse.items())
.extracting(item -> item.routeId())
.contains(routeId);
}
@Test
@Order(10)
void createDeployment() {
requireApi();
var response = apigwv2.createDeployment(CreateDeploymentRequest.builder()
.apiId(apiId)
.description("phase2 deployment")
.build());
deploymentId = response.deploymentId();
assertThat(deploymentId).isNotBlank();
assertThat(response.deploymentStatusAsString()).isEqualTo("DEPLOYED");
assertThat(response.description()).isEqualTo("phase2 deployment");
assertThat(response.createdDate()).isNotNull();
}
@Test
@Order(11)
void getAndListDeployments() {
requireApi();
requireDeployment();
var getResponse = apigwv2.getDeployment(GetDeploymentRequest.builder()
.apiId(apiId)
.deploymentId(deploymentId)
.build());
assertThat(getResponse.deploymentId()).isEqualTo(deploymentId);
assertThat(getResponse.description()).isEqualTo("phase2 deployment");
var listResponse = apigwv2.getDeployments(GetDeploymentsRequest.builder()
.apiId(apiId)
.build());
assertThat(listResponse.items())
.extracting(item -> item.deploymentId())
.contains(deploymentId);
}
@Test
@Order(12)
void createStage() {
requireApi();
requireDeployment();
var response = apigwv2.createStage(CreateStageRequest.builder()
.apiId(apiId)
.stageName(stageName)
.deploymentId(deploymentId)
.autoDeploy(false)
.tags(Map.of("environment", "test", "owner", "platform"))
.build());
assertThat(response.stageName()).isEqualTo(stageName);
assertThat(response.deploymentId()).isEqualTo(deploymentId);
assertThat(response.autoDeploy()).isFalse();
assertThat(response.createdDate()).isNotNull();
assertThat(response.lastUpdatedDate()).isNotNull();
assertThat(response.tags())
.containsEntry("environment", "test")
.containsEntry("owner", "platform");
stageCreated = true;
}
@Test
@Order(13)
void getAndListStages() {
requireApi();
requireStage();
var getResponse = apigwv2.getStage(GetStageRequest.builder()
.apiId(apiId)
.stageName(stageName)
.build());
assertThat(getResponse.stageName()).isEqualTo(stageName);
assertThat(getResponse.deploymentId()).isEqualTo(deploymentId);
assertThat(getResponse.tags())
.containsEntry("environment", "test")
.containsEntry("owner", "platform");
var listResponse = apigwv2.getStages(GetStagesRequest.builder()
.apiId(apiId)
.build());
assertThat(listResponse.items())
.extracting(item -> item.stageName())
.contains(stageName);
// Verify deploymentId is present in the list response
assertThat(listResponse.items())
.filteredOn(item -> stageName.equals(item.stageName()))
.first()
.extracting(item -> item.deploymentId())
.isEqualTo(deploymentId);
assertThat(listResponse.items())
.filteredOn(item -> stageName.equals(item.stageName()))
.first()
.extracting(item -> item.tags())
.satisfies(tags -> assertThat(tags)
.containsEntry("environment", "test")
.containsEntry("owner", "platform"));
}
@Test
@Order(14)
void deleteStage() {
requireApi();
requireStage();
apigwv2.deleteStage(DeleteStageRequest.builder()
.apiId(apiId)
.stageName(stageName)
.build());
assertThatThrownBy(() -> apigwv2.getStage(GetStageRequest.builder()
.apiId(apiId)
.stageName(stageName)
.build()))
.isInstanceOf(NotFoundException.class);
stageCreated = false;
}
@Test
@Order(15)
void deleteDeployment() {
requireApi();
requireDeployment();
apigwv2.deleteDeployment(DeleteDeploymentRequest.builder()
.apiId(apiId)
.deploymentId(deploymentId)
.build());
assertThatThrownBy(() -> apigwv2.getDeployment(GetDeploymentRequest.builder()
.apiId(apiId)
.deploymentId(deploymentId)
.build()))
.isInstanceOf(NotFoundException.class);
deploymentId = null;
}
@Test
@Order(16)
void deleteRoute() {
requireApi();
requireRoute();
apigwv2.deleteRoute(DeleteRouteRequest.builder()
.apiId(apiId)
.routeId(routeId)
.build());
assertThatThrownBy(() -> apigwv2.getRoute(GetRouteRequest.builder()
.apiId(apiId)
.routeId(routeId)
.build()))
.isInstanceOf(NotFoundException.class);
routeId = null;
}
@Test
@Order(17)
void deleteIntegration() {
requireApi();
requireIntegration();
apigwv2.deleteIntegration(DeleteIntegrationRequest.builder()
.apiId(apiId)
.integrationId(integrationId)
.build());
assertThatThrownBy(() -> apigwv2.getIntegration(GetIntegrationRequest.builder()
.apiId(apiId)
.integrationId(integrationId)
.build()))
.isInstanceOf(NotFoundException.class);
integrationId = null;
}
@Test
@Order(18)
void deleteAuthorizer() {
requireApi();
requireAuthorizer();
apigwv2.deleteAuthorizer(DeleteAuthorizerRequest.builder()
.apiId(apiId)
.authorizerId(authorizerId)
.build());
assertThatThrownBy(() -> apigwv2.getAuthorizer(GetAuthorizerRequest.builder()
.apiId(apiId)
.authorizerId(authorizerId)
.build()))
.isInstanceOf(NotFoundException.class);
authorizerId = null;
}
@Test
@Order(19)
void deleteApi() {
requireApi();
apigwv2.deleteApi(DeleteApiRequest.builder()
.apiId(apiId)
.build());
assertThatThrownBy(() -> apigwv2.getApi(GetApiRequest.builder()
.apiId(apiId)
.build()))
.isInstanceOf(NotFoundException.class);
apiId = null;
}
private static void deleteIfPresent(ThrowingRunnable runnable) {
try {
runnable.run();
} catch (NotFoundException ignored) {
} catch (ApiGatewayV2Exception ignored) {
}
}
@FunctionalInterface
private interface ThrowingRunnable {
void run();
}
private static void requireApi() {
Assumptions.assumeTrue(apiId != null, "API must exist from earlier ordered test");
}
private static void requireAuthorizer() {
Assumptions.assumeTrue(authorizerId != null, "Authorizer must exist from earlier ordered test");
}
private static void requireIntegration() {
Assumptions.assumeTrue(integrationId != null, "Integration must exist from earlier ordered test");
}
private static void requireRoute() {
Assumptions.assumeTrue(routeId != null, "Route must exist from earlier ordered test");
}
private static void requireDeployment() {
Assumptions.assumeTrue(deploymentId != null, "Deployment must exist from earlier ordered test");
}
private static void requireStage() {
Assumptions.assumeTrue(stageCreated, "Stage must exist from earlier ordered test");
}
}
@@ -0,0 +1,530 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.apigatewayv2.ApiGatewayV2Client;
import software.amazon.awssdk.services.apigatewayv2.model.*;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Compatibility tests for the new API Gateway v2 operations added in issue #526:
* WebSocket API support, Update operations, Route Responses, Integration Responses,
* Models, and Tagging — all exercised via the real AWS SDK v2 Java client.
*/
@DisplayName("API Gateway v2 — WebSocket & extended operations")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ApiGatewayV2WebSocketAndExtendedOpsTest {
private static ApiGatewayV2Client gw;
// ── shared resource IDs ──
private static String wsApiId;
private static String httpApiId;
private static String wsRouteId;
private static String integrationId;
private static String authorizerId;
private static String deploymentId;
private static String routeResponseId;
private static String integrationResponseId;
private static String modelId;
@BeforeAll
static void setup() {
gw = TestFixtures.apiGatewayV2Client();
}
@AfterAll
static void cleanup() {
if (gw == null) return;
safeDelete(() -> gw.deleteApi(DeleteApiRequest.builder().apiId(wsApiId).build()));
safeDelete(() -> gw.deleteApi(DeleteApiRequest.builder().apiId(httpApiId).build()));
gw.close();
}
// ──────────────────────────── HTTP API defaults ────────────────────────────
@Test @Order(1)
@DisplayName("CreateApi (HTTP) populates AWS defaults")
void createHttpApiWithDefaults() {
var res = gw.createApi(CreateApiRequest.builder()
.name(TestFixtures.uniqueName("http-ext"))
.protocolType(ProtocolType.HTTP)
.build());
httpApiId = res.apiId();
assertThat(httpApiId).isNotBlank();
assertThat(res.protocolType()).isEqualTo(ProtocolType.HTTP);
assertThat(res.apiEndpoint()).contains("https://");
assertThat(res.routeSelectionExpression()).isEqualTo("${request.method} ${request.path}");
assertThat(res.apiKeySelectionExpression()).isEqualTo("$request.header.x-api-key");
assertThat(res.createdDate()).isNotNull();
}
@Test @Order(2)
@DisplayName("GetApi (HTTP) returns persisted defaults")
void getHttpApiVerifyDefaults() {
requireHttpApi();
var res = gw.getApi(GetApiRequest.builder().apiId(httpApiId).build());
assertThat(res.routeSelectionExpression()).isEqualTo("${request.method} ${request.path}");
assertThat(res.apiKeySelectionExpression()).isEqualTo("$request.header.x-api-key");
}
// ──────────────────────────── WebSocket API lifecycle ────────────────────────────
@Test @Order(10)
@DisplayName("CreateApi (WEBSOCKET) with routeSelectionExpression")
void createWebSocketApi() {
var res = gw.createApi(CreateApiRequest.builder()
.name(TestFixtures.uniqueName("ws-ext"))
.protocolType(ProtocolType.WEBSOCKET)
.routeSelectionExpression("$request.body.action")
.description("Java compat WS API")
.apiKeySelectionExpression("$request.header.x-api-key")
.build());
wsApiId = res.apiId();
assertThat(wsApiId).isNotBlank();
assertThat(res.protocolType()).isEqualTo(ProtocolType.WEBSOCKET);
assertThat(res.apiEndpoint()).contains("wss://");
assertThat(res.routeSelectionExpression()).isEqualTo("$request.body.action");
assertThat(res.description()).isEqualTo("Java compat WS API");
}
@Test @Order(11)
@DisplayName("CreateApi (WEBSOCKET) without routeSelectionExpression is rejected")
void createWebSocketApiMissingRse() {
assertThatThrownBy(() -> gw.createApi(CreateApiRequest.builder()
.name(TestFixtures.uniqueName("ws-no-rse"))
.protocolType(ProtocolType.WEBSOCKET)
.build()))
.isInstanceOf(ApiGatewayV2Exception.class);
}
@Test @Order(12)
@DisplayName("UpdateApi preserves unmodified fields")
void updateWebSocketApi() {
requireWsApi();
var res = gw.updateApi(UpdateApiRequest.builder()
.apiId(wsApiId)
.name("ws-updated-java")
.build());
assertThat(res.name()).isEqualTo("ws-updated-java");
assertThat(res.protocolType()).isEqualTo(ProtocolType.WEBSOCKET);
assertThat(res.routeSelectionExpression()).isEqualTo("$request.body.action");
assertThat(res.apiEndpoint()).contains("wss://");
}
@Test @Order(13)
@DisplayName("UpdateApi on non-existent API returns 404")
void updateApiNotFound() {
assertThatThrownBy(() -> gw.updateApi(UpdateApiRequest.builder()
.apiId("nonexistent999")
.name("ghost")
.build()))
.isInstanceOf(NotFoundException.class);
}
// ──────────────────────────── Routes with routeResponseSelectionExpression ────────────────────────────
@Test @Order(20)
@DisplayName("CreateRoute with routeResponseSelectionExpression")
void createRouteWithRrse() {
requireWsApi();
var res = gw.createRoute(CreateRouteRequest.builder()
.apiId(wsApiId)
.routeKey("$default")
.authorizationType("NONE")
.routeResponseSelectionExpression("$default")
.build());
wsRouteId = res.routeId();
assertThat(wsRouteId).isNotBlank();
assertThat(res.routeKey()).isEqualTo("$default");
assertThat(res.routeResponseSelectionExpression()).isEqualTo("$default");
}
@Test @Order(21)
@DisplayName("UpdateRoute preserves unmodified fields")
void updateRoute() {
requireWsApi(); requireWsRoute();
var res = gw.updateRoute(UpdateRouteRequest.builder()
.apiId(wsApiId)
.routeId(wsRouteId)
.target("integrations/fake-id")
.build());
assertThat(res.target()).isEqualTo("integrations/fake-id");
assertThat(res.routeKey()).isEqualTo("$default");
assertThat(res.routeResponseSelectionExpression()).isEqualTo("$default");
}
// ──────────────────────────── Integrations + Update ────────────────────────────
@Test @Order(30)
@DisplayName("CreateIntegration")
void createIntegration() {
requireWsApi();
var res = gw.createIntegration(CreateIntegrationRequest.builder()
.apiId(wsApiId)
.integrationType(IntegrationType.HTTP_PROXY)
.integrationUri("https://example.com")
.payloadFormatVersion("2.0")
.build());
integrationId = res.integrationId();
assertThat(integrationId).isNotBlank();
}
@Test @Order(31)
@DisplayName("UpdateIntegration preserves unmodified fields")
void updateIntegration() {
requireWsApi(); requireIntegration();
var res = gw.updateIntegration(UpdateIntegrationRequest.builder()
.apiId(wsApiId)
.integrationId(integrationId)
.integrationUri("https://updated.example.com")
.build());
assertThat(res.integrationUri()).isEqualTo("https://updated.example.com");
assertThat(res.integrationType()).isEqualTo(IntegrationType.HTTP_PROXY);
assertThat(res.payloadFormatVersion()).isEqualTo("2.0");
}
// ──────────────────────────── Authorizers + Update ────────────────────────────
@Test @Order(40)
@DisplayName("CreateAuthorizer (JWT) with string identitySource")
void createAuthorizer() {
requireWsApi();
var res = gw.createAuthorizer(CreateAuthorizerRequest.builder()
.apiId(wsApiId)
.name("jwt-ext-auth")
.authorizerType(AuthorizerType.JWT)
.identitySource("$request.header.Authorization")
.jwtConfiguration(j -> j.issuer("https://issuer.example.com").audience("aud-1"))
.build());
authorizerId = res.authorizerId();
assertThat(authorizerId).isNotBlank();
assertThat(res.authorizerType()).isEqualTo(AuthorizerType.JWT);
assertThat(res.identitySource()).containsExactly("$request.header.Authorization");
}
@Test @Order(41)
@DisplayName("UpdateAuthorizer preserves unmodified fields")
void updateAuthorizer() {
requireWsApi(); requireAuthorizer();
var res = gw.updateAuthorizer(UpdateAuthorizerRequest.builder()
.apiId(wsApiId)
.authorizerId(authorizerId)
.name("jwt-updated")
.build());
assertThat(res.name()).isEqualTo("jwt-updated");
assertThat(res.authorizerType()).isEqualTo(AuthorizerType.JWT);
assertThat(res.identitySource()).containsExactly("$request.header.Authorization");
}
// ──────────────────────────── Stages & Deployments + Update ────────────────────────────
@Test @Order(50)
@DisplayName("CreateDeployment")
void createDeployment() {
requireWsApi();
var res = gw.createDeployment(CreateDeploymentRequest.builder()
.apiId(wsApiId)
.description("ext-deploy")
.build());
deploymentId = res.deploymentId();
assertThat(deploymentId).isNotBlank();
}
@Test @Order(51)
@DisplayName("UpdateDeployment preserves unmodified fields")
void updateDeployment() {
requireWsApi(); requireDeployment();
var res = gw.updateDeployment(UpdateDeploymentRequest.builder()
.apiId(wsApiId)
.deploymentId(deploymentId)
.description("updated-deploy")
.build());
assertThat(res.description()).isEqualTo("updated-deploy");
assertThat(res.deploymentStatusAsString()).isEqualTo("DEPLOYED");
}
@Test @Order(52)
@DisplayName("CreateStage + UpdateStage preserves unmodified fields")
void createAndUpdateStage() {
requireWsApi(); requireDeployment();
gw.createStage(CreateStageRequest.builder()
.apiId(wsApiId)
.stageName("dev")
.deploymentId(deploymentId)
.autoDeploy(false)
.build());
var res = gw.updateStage(UpdateStageRequest.builder()
.apiId(wsApiId)
.stageName("dev")
.autoDeploy(true)
.build());
assertThat(res.autoDeploy()).isTrue();
assertThat(res.deploymentId()).isEqualTo(deploymentId);
assertThat(res.lastUpdatedDate()).isNotNull();
}
// ──────────────────────────── Route Responses ────────────────────────────
@Test @Order(60)
@DisplayName("Route Response full CRUD")
void routeResponseCrud() {
requireWsApi(); requireWsRoute();
// Create
var createRes = gw.createRouteResponse(CreateRouteResponseRequest.builder()
.apiId(wsApiId)
.routeId(wsRouteId)
.routeResponseKey("$default")
.modelSelectionExpression("$default")
.build());
routeResponseId = createRes.routeResponseId();
assertThat(routeResponseId).isNotBlank();
assertThat(createRes.routeResponseKey()).isEqualTo("$default");
// Get
var getRes = gw.getRouteResponse(GetRouteResponseRequest.builder()
.apiId(wsApiId).routeId(wsRouteId).routeResponseId(routeResponseId).build());
assertThat(getRes.routeResponseId()).isEqualTo(routeResponseId);
// List
var listRes = gw.getRouteResponses(GetRouteResponsesRequest.builder()
.apiId(wsApiId).routeId(wsRouteId).build());
assertThat(listRes.items()).extracting(RouteResponse::routeResponseId).contains(routeResponseId);
// Update
var updateRes = gw.updateRouteResponse(UpdateRouteResponseRequest.builder()
.apiId(wsApiId).routeId(wsRouteId).routeResponseId(routeResponseId)
.routeResponseKey("$updated")
.build());
assertThat(updateRes.routeResponseKey()).isEqualTo("$updated");
// Delete
gw.deleteRouteResponse(DeleteRouteResponseRequest.builder()
.apiId(wsApiId).routeId(wsRouteId).routeResponseId(routeResponseId).build());
assertThatThrownBy(() -> gw.getRouteResponse(GetRouteResponseRequest.builder()
.apiId(wsApiId).routeId(wsRouteId).routeResponseId(routeResponseId).build()))
.isInstanceOf(NotFoundException.class);
}
// ──────────────────────────── Integration Responses ────────────────────────────
@Test @Order(70)
@DisplayName("Integration Response full CRUD")
void integrationResponseCrud() {
requireWsApi(); requireIntegration();
// Create
var createRes = gw.createIntegrationResponse(CreateIntegrationResponseRequest.builder()
.apiId(wsApiId)
.integrationId(integrationId)
.integrationResponseKey("$default")
.contentHandlingStrategy("CONVERT_TO_TEXT")
.build());
integrationResponseId = createRes.integrationResponseId();
assertThat(integrationResponseId).isNotBlank();
assertThat(createRes.contentHandlingStrategy()).hasToString("CONVERT_TO_TEXT");
// Get
var getRes = gw.getIntegrationResponse(GetIntegrationResponseRequest.builder()
.apiId(wsApiId).integrationId(integrationId)
.integrationResponseId(integrationResponseId).build());
assertThat(getRes.integrationResponseId()).isEqualTo(integrationResponseId);
// List
var listRes = gw.getIntegrationResponses(GetIntegrationResponsesRequest.builder()
.apiId(wsApiId).integrationId(integrationId).build());
assertThat(listRes.items()).extracting(IntegrationResponse::integrationResponseId)
.contains(integrationResponseId);
// Update
var updateRes = gw.updateIntegrationResponse(UpdateIntegrationResponseRequest.builder()
.apiId(wsApiId).integrationId(integrationId)
.integrationResponseId(integrationResponseId)
.contentHandlingStrategy("CONVERT_TO_BINARY")
.build());
assertThat(updateRes.contentHandlingStrategy()).hasToString("CONVERT_TO_BINARY");
// Delete
gw.deleteIntegrationResponse(DeleteIntegrationResponseRequest.builder()
.apiId(wsApiId).integrationId(integrationId)
.integrationResponseId(integrationResponseId).build());
assertThatThrownBy(() -> gw.getIntegrationResponse(GetIntegrationResponseRequest.builder()
.apiId(wsApiId).integrationId(integrationId)
.integrationResponseId(integrationResponseId).build()))
.isInstanceOf(NotFoundException.class);
}
// ──────────────────────────── Models ────────────────────────────
@Test @Order(80)
@DisplayName("Model full CRUD with merge-patch")
void modelCrud() {
requireWsApi();
// Create
var createRes = gw.createModel(CreateModelRequest.builder()
.apiId(wsApiId)
.name("PetModel")
.schema("{\"type\":\"object\"}")
.contentType("application/json")
.description("A pet schema")
.build());
modelId = createRes.modelId();
assertThat(modelId).isNotBlank();
assertThat(createRes.name()).isEqualTo("PetModel");
// Get
var getRes = gw.getModel(GetModelRequest.builder()
.apiId(wsApiId).modelId(modelId).build());
assertThat(getRes.name()).isEqualTo("PetModel");
assertThat(getRes.contentType()).isEqualTo("application/json");
// List
var listRes = gw.getModels(GetModelsRequest.builder().apiId(wsApiId).build());
assertThat(listRes.items()).extracting(Model::modelId).contains(modelId);
// Update (merge-patch)
var updateRes = gw.updateModel(UpdateModelRequest.builder()
.apiId(wsApiId).modelId(modelId)
.description("updated description")
.build());
assertThat(updateRes.description()).isEqualTo("updated description");
assertThat(updateRes.name()).isEqualTo("PetModel");
assertThat(updateRes.contentType()).isEqualTo("application/json");
// Delete
gw.deleteModel(DeleteModelRequest.builder().apiId(wsApiId).modelId(modelId).build());
assertThatThrownBy(() -> gw.getModel(GetModelRequest.builder()
.apiId(wsApiId).modelId(modelId).build()))
.isInstanceOf(NotFoundException.class);
}
// ──────────────────────────── Tagging ────────────────────────────
@Test @Order(90)
@DisplayName("Create API with tags, TagResource, UntagResource, GetTags")
void tagging() {
// Create API with initial tags
var createRes = gw.createApi(CreateApiRequest.builder()
.name(TestFixtures.uniqueName("tag-java"))
.protocolType(ProtocolType.HTTP)
.tags(Map.of("initial", "tag"))
.build());
String tagApiId = createRes.apiId();
String arn = "arn:aws:apigateway:us-east-1::/apis/" + tagApiId;
try {
// Verify tags on create
assertThat(createRes.tags()).containsEntry("initial", "tag");
// TagResource — add more tags
gw.tagResource(TagResourceRequest.builder()
.resourceArn(arn)
.tags(Map.of("env", "production", "team", "platform"))
.build());
var tags = gw.getTags(GetTagsRequest.builder().resourceArn(arn).build()).tags();
assertThat(tags).containsEntry("initial", "tag");
assertThat(tags).containsEntry("env", "production");
assertThat(tags).containsEntry("team", "platform");
// UntagResource — remove specific keys
gw.untagResource(UntagResourceRequest.builder()
.resourceArn(arn)
.tagKeys("initial", "team")
.build());
var tagsAfter = gw.getTags(GetTagsRequest.builder().resourceArn(arn).build()).tags();
assertThat(tagsAfter).containsEntry("env", "production");
assertThat(tagsAfter).doesNotContainKey("initial");
assertThat(tagsAfter).doesNotContainKey("team");
} finally {
safeDelete(() -> gw.deleteApi(DeleteApiRequest.builder().apiId(tagApiId).build()));
}
}
@Test @Order(91)
@DisplayName("GetTags on API with no tags returns empty map")
void getTagsEmpty() {
var createRes = gw.createApi(CreateApiRequest.builder()
.name(TestFixtures.uniqueName("notag-java"))
.protocolType(ProtocolType.HTTP)
.build());
String noTagApiId = createRes.apiId();
String arn = "arn:aws:apigateway:us-east-1::/apis/" + noTagApiId;
try {
var tags = gw.getTags(GetTagsRequest.builder().resourceArn(arn).build()).tags();
assertThat(tags).isEmpty();
} finally {
safeDelete(() -> gw.deleteApi(DeleteApiRequest.builder().apiId(noTagApiId).build()));
}
}
@Test @Order(92)
@DisplayName("TagResource on non-existent API returns 404")
void tagResourceNotFound() {
assertThatThrownBy(() -> gw.tagResource(TagResourceRequest.builder()
.resourceArn("arn:aws:apigateway:us-east-1::/apis/nonexistent999")
.tags(Map.of("k", "v"))
.build()))
.isInstanceOf(NotFoundException.class);
}
// ──────────────────────────── Helpers ────────────────────────────
private static void safeDelete(Runnable r) {
try { r.run(); } catch (Exception ignored) {}
}
private static void requireWsApi() {
Assumptions.assumeTrue(wsApiId != null, "WebSocket API must exist");
}
private static void requireHttpApi() {
Assumptions.assumeTrue(httpApiId != null, "HTTP API must exist");
}
private static void requireWsRoute() {
Assumptions.assumeTrue(wsRouteId != null, "WebSocket route must exist");
}
private static void requireIntegration() {
Assumptions.assumeTrue(integrationId != null, "Integration must exist");
}
private static void requireAuthorizer() {
Assumptions.assumeTrue(authorizerId != null, "Authorizer must exist");
}
private static void requireDeployment() {
Assumptions.assumeTrue(deploymentId != null, "Deployment must exist");
}
}
@@ -0,0 +1,787 @@
package com.floci.test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.apigatewaymanagementapi.ApiGatewayManagementApiClient;
import software.amazon.awssdk.services.apigatewaymanagementapi.model.GetConnectionRequest;
import software.amazon.awssdk.services.apigatewaymanagementapi.model.GetConnectionResponse;
import software.amazon.awssdk.services.apigatewaymanagementapi.model.GoneException;
import software.amazon.awssdk.services.apigatewaymanagementapi.model.PostToConnectionRequest;
import software.amazon.awssdk.services.apigatewaymanagementapi.model.DeleteConnectionRequest;
import software.amazon.awssdk.services.apigatewayv2.ApiGatewayV2Client;
import software.amazon.awssdk.services.apigatewayv2.model.*;
import software.amazon.awssdk.services.lambda.LambdaClient;
import software.amazon.awssdk.services.lambda.model.CreateFunctionRequest;
import software.amazon.awssdk.services.lambda.model.DeleteFunctionRequest;
import software.amazon.awssdk.services.lambda.model.FunctionCode;
import software.amazon.awssdk.services.lambda.model.Runtime;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* WebSocket data-plane end-to-end compatibility tests using the AWS SDK v2 Java client.
*
* Mirrors the Node.js compatibility tests in
* {@code compatibility-tests/sdk-test-node/tests/apigatewayv2-websocket-dataplane.test.ts}.
*
* Covers 8 test suites:
* 1. Basic WebSocket flow — connect, send, receive, disconnect
* 2. Chat-style broadcast — two clients, Lambda uses @connections API to broadcast
* 3. $connect authorization — Lambda authorizer allows/denies based on query string token
* 4. Route selection — multiple routes dispatched correctly
* 5. @connections API — POST sends message, GET returns info, DELETE disconnects
* 6. Stage variables — integration URI with ${stageVariables.functionName} resolves
* 7. Mock integration — $connect with MOCK integration, no Lambda needed
* 8. Disconnect cleanup — after disconnect, @connections POST returns 410
*/
@DisplayName("API Gateway v2 — WebSocket data-plane")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ApiGatewayV2WebSocketDataPlaneTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final String ROLE = "arn:aws:iam::000000000000:role/lambda-role";
private static final String STAGE = "test";
private static ApiGatewayV2Client gw;
private static LambdaClient lambda;
private static HttpClient http;
private static final List<String> createdApis = new ArrayList<>();
private static final List<String> createdFunctions = new ArrayList<>();
private static boolean lambdaAvailable;
// ── Lambda handler source code ───────────────────────────────────────────
private static final String ECHO_HANDLER = """
exports.handler = async (event) => {
const body = event.body || '';
try {
const parsed = JSON.parse(body);
if (parsed.action === 'getConnectionId') {
return { statusCode: 200, body: JSON.stringify({ connectionId: event.requestContext.connectionId }) };
}
} catch (e) {}
return { statusCode: 200, body: body || 'echo' };
};
""";
private static final String BROADCAST_HANDLER = """
const http = require('http');
exports.handler = async (event) => {
const body = JSON.parse(event.body);
const apiId = event.requestContext.apiId;
const stage = event.requestContext.stage;
const endpoint = new URL(process.env.FLOCI_ENDPOINT || 'http://host.docker.internal:4566');
const promises = body.targets.map(connId => {
return new Promise((resolve, reject) => {
const postData = body.message;
const options = {
hostname: endpoint.hostname,
port: endpoint.port || 80,
path: '/execute-api/' + apiId + '/' + stage + '/@connections/' + encodeURIComponent(connId),
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': Buffer.byteLength(postData) },
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
});
req.on('error', reject);
req.write(postData);
req.end();
});
});
await Promise.all(promises);
return { statusCode: 200 };
};
""";
private static final String AUTHORIZER_HANDLER = """
exports.handler = async (event) => {
const token = event.queryStringParameters && event.queryStringParameters.token;
const effect = token === 'allow' ? 'Allow' : 'Deny';
return {
principalId: 'user',
policyDocument: {
Version: '2012-10-17',
Statement: [{ Action: 'execute-api:Invoke', Effect: effect, Resource: event.methodArn || '*' }],
},
};
};
""";
private static final String PING_HANDLER = """
exports.handler = async (event) => {
return { statusCode: 200, body: 'pong' };
};
""";
private static final String SEND_MESSAGE_HANDLER = """
exports.handler = async (event) => {
const body = JSON.parse(event.body);
return { statusCode: 200, body: 'received: ' + body.data };
};
""";
private static final String DEFAULT_HANDLER = """
exports.handler = async (event) => {
return { statusCode: 200, body: 'default-route' };
};
""";
// ── Setup / Teardown ─────────────────────────────────────────────────────
@BeforeAll
static void setup() {
gw = TestFixtures.apiGatewayV2Client();
lambda = TestFixtures.lambdaClient();
http = HttpClient.newHttpClient();
lambdaAvailable = TestFixtures.isLambdaDispatchAvailable();
}
@AfterAll
static void cleanup() {
for (String apiId : createdApis) {
try { gw.deleteApi(DeleteApiRequest.builder().apiId(apiId).build()); } catch (Exception ignored) {}
}
for (String fn : createdFunctions) {
try { lambda.deleteFunction(DeleteFunctionRequest.builder().functionName(fn).build()); } catch (Exception ignored) {}
}
if (gw != null) gw.close();
if (lambda != null) lambda.close();
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static String createLambda(String prefix, String code) {
return createLambda(prefix, code, null);
}
private static String createLambda(String prefix, String code, Map<String, String> environment) {
String fnName = TestFixtures.uniqueName(prefix);
var builder = CreateFunctionRequest.builder()
.functionName(fnName)
.runtime(Runtime.NODEJS20_X)
.role(ROLE)
.handler("index.handler")
.timeout(30)
.code(FunctionCode.builder()
.zipFile(SdkBytes.fromByteArray(createZip(code)))
.build());
if (environment != null && !environment.isEmpty()) {
builder.environment(e -> e.variables(environment));
}
lambda.createFunction(builder.build());
createdFunctions.add(fnName);
return fnName;
}
private static String createWsApi(String prefix) {
var res = gw.createApi(CreateApiRequest.builder()
.name(TestFixtures.uniqueName(prefix))
.protocolType(ProtocolType.WEBSOCKET)
.routeSelectionExpression("$request.body.action")
.build());
createdApis.add(res.apiId());
return res.apiId();
}
private static String createLambdaIntegration(String apiId, String fnName) {
var res = gw.createIntegration(CreateIntegrationRequest.builder()
.apiId(apiId)
.integrationType(IntegrationType.AWS_PROXY)
.integrationUri("arn:aws:lambda:us-east-1:000000000000:function:" + fnName)
.build());
return res.integrationId();
}
private static void setupStage(String apiId, Map<String, String> stageVariables) {
var deploy = gw.createDeployment(CreateDeploymentRequest.builder().apiId(apiId).build());
var req = CreateStageRequest.builder()
.apiId(apiId)
.stageName(STAGE)
.deploymentId(deploy.deploymentId());
if (stageVariables != null && !stageVariables.isEmpty()) {
req.stageVariables(stageVariables);
}
gw.createStage(req.build());
}
private static void setupStage(String apiId) {
setupStage(apiId, null);
}
private static String wsUrl(String apiId) {
URI endpoint = TestFixtures.endpoint();
String host = endpoint.getHost();
int port = endpoint.getPort();
return "ws://" + host + ":" + port + "/ws/" + apiId + "/" + STAGE;
}
private static ApiGatewayManagementApiClient managementClient(String apiId) {
URI mgmtEndpoint = URI.create(TestFixtures.endpoint() + "/execute-api/" + apiId + "/" + STAGE);
return ApiGatewayManagementApiClient.builder()
.endpointOverride(mgmtEndpoint)
.region(software.amazon.awssdk.regions.Region.US_EAST_1)
.credentialsProvider(software.amazon.awssdk.auth.credentials.StaticCredentialsProvider.create(
software.amazon.awssdk.auth.credentials.AwsBasicCredentials.create("test", "test")))
.build();
}
private static WebSocket connectWebSocket(String url, MultiMessageCapture capture) throws Exception {
return http.newWebSocketBuilder()
.buildAsync(URI.create(url), capture)
.get(60, TimeUnit.SECONDS);
}
private static String getConnectionId(WebSocket ws, MultiMessageCapture capture) throws Exception {
ws.sendText("{\"action\":\"getConnectionId\"}", true).join();
String response = capture.getNextMessage(15, TimeUnit.SECONDS);
if (response == null) return null;
JsonNode node = JSON.readTree(response);
return node.has("connectionId") ? node.get("connectionId").asText() : null;
}
private static byte[] createZip(String code) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
zos.putNextEntry(new ZipEntry("index.js"));
zos.write(code.getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
}
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to build ZIP", e);
}
}
// ──────────────────────────── 1. Basic WebSocket flow ────────────────────────────
@Test @Order(1)
@DisplayName("1. Basic WebSocket flow — connect, send, receive, disconnect")
void basicWebSocketFlow() throws Exception {
Assumptions.assumeTrue(lambdaAvailable, "Lambda dispatch required");
String echoFn = createLambda("basic-echo", ECHO_HANDLER);
String apiId = createWsApi("basic-flow");
String integId = createLambdaIntegration(apiId, echoFn);
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$connect")
.target("integrations/" + integId).build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$default")
.target("integrations/" + integId)
.routeResponseSelectionExpression("$default").build());
setupStage(apiId);
MultiMessageCapture capture = new MultiMessageCapture();
WebSocket ws = connectWebSocket(wsUrl(apiId), capture);
assertThat(ws).isNotNull();
ws.sendText("{\"action\":\"test\",\"body\":\"hello\"}", true).join();
String response = capture.getNextMessage(15, TimeUnit.SECONDS);
assertThat(response).isNotNull();
ws.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
Thread.sleep(500);
}
// ──────────────────────────── 2. Chat-style broadcast ────────────────────────────
@Test @Order(2)
@DisplayName("2. Chat-style broadcast — Lambda uses @connections to broadcast to multiple clients")
void chatStyleBroadcast() throws Exception {
Assumptions.assumeTrue(lambdaAvailable, "Lambda dispatch required");
String broadcastFn = createLambda("broadcast", BROADCAST_HANDLER,
Map.of("FLOCI_ENDPOINT", "http://host.docker.internal:4566"));
String echoFn = createLambda("bc-echo", ECHO_HANDLER);
String apiId = createWsApi("broadcast");
String broadcastIntegId = createLambdaIntegration(apiId, broadcastFn);
String echoIntegId = createLambdaIntegration(apiId, echoFn);
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$connect")
.target("integrations/" + echoIntegId).build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$default")
.target("integrations/" + echoIntegId)
.routeResponseSelectionExpression("$default").build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("broadcast")
.target("integrations/" + broadcastIntegId)
.routeResponseSelectionExpression("$default").build());
setupStage(apiId);
MultiMessageCapture capture1 = new MultiMessageCapture();
MultiMessageCapture capture2 = new MultiMessageCapture();
WebSocket ws1 = connectWebSocket(wsUrl(apiId), capture1);
WebSocket ws2 = connectWebSocket(wsUrl(apiId), capture2);
Thread.sleep(300);
String connId1 = getConnectionId(ws1, capture1);
String connId2 = getConnectionId(ws2, capture2);
assertThat(connId1).isNotNull();
assertThat(connId2).isNotNull();
// Send broadcast action
ws1.sendText("{\"action\":\"broadcast\",\"targets\":[\"%s\",\"%s\"],\"message\":\"hello-all\"}"
.formatted(connId1, connId2), true).join();
String msg1 = capture1.getNextMessage(15, TimeUnit.SECONDS);
String msg2 = capture2.getNextMessage(15, TimeUnit.SECONDS);
assertThat(msg1).isEqualTo("hello-all");
assertThat(msg2).isEqualTo("hello-all");
ws1.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
ws2.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
Thread.sleep(500);
}
// ──────────────────────────── 3. $connect authorization ────────────────────────────
@Test @Order(3)
@DisplayName("3. $connect authorization — allows with valid token, denies with invalid")
void connectAuthorization() throws Exception {
Assumptions.assumeTrue(lambdaAvailable, "Lambda dispatch required");
String authFn = createLambda("authorizer", AUTHORIZER_HANDLER);
String echoFn = createLambda("auth-echo", ECHO_HANDLER);
String apiId = createWsApi("auth-test");
String echoIntegId = createLambdaIntegration(apiId, echoFn);
var authRes = gw.createAuthorizer(CreateAuthorizerRequest.builder()
.apiId(apiId)
.authorizerType(AuthorizerType.REQUEST)
.name("ws-auth")
.authorizerUri("arn:aws:lambda:us-east-1:000000000000:function:" + authFn)
.identitySource("route.request.querystring.token")
.build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$connect")
.target("integrations/" + echoIntegId)
.authorizationType("CUSTOM")
.authorizerId(authRes.authorizerId()).build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$default")
.target("integrations/" + echoIntegId).build());
setupStage(apiId);
// Should allow with valid token
MultiMessageCapture allowCapture = new MultiMessageCapture();
WebSocket wsAllow = connectWebSocket(wsUrl(apiId) + "?token=allow", allowCapture);
assertThat(wsAllow).isNotNull();
wsAllow.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
// Should deny with invalid token
MultiMessageCapture denyCapture = new MultiMessageCapture();
CompletableFuture<WebSocket> denyFuture = http.newWebSocketBuilder()
.buildAsync(URI.create(wsUrl(apiId) + "?token=deny"), denyCapture);
assertThatThrownBy(() -> denyFuture.get(15, TimeUnit.SECONDS))
.hasMessageContaining("WebSocket");
Thread.sleep(500);
}
// ──────────────────────────── 4. Route selection ────────────────────────────
@Test @Order(4)
@DisplayName("4. Route selection — ping, sendMessage, $default dispatched correctly")
void routeSelection() throws Exception {
Assumptions.assumeTrue(lambdaAvailable, "Lambda dispatch required");
String pingFn = createLambda("ping", PING_HANDLER);
String sendMsgFn = createLambda("sendmsg", SEND_MESSAGE_HANDLER);
String defaultFn = createLambda("default", DEFAULT_HANDLER);
String echoFn = createLambda("rs-echo", ECHO_HANDLER);
String apiId = createWsApi("route-sel");
String pingIntegId = createLambdaIntegration(apiId, pingFn);
String sendMsgIntegId = createLambdaIntegration(apiId, sendMsgFn);
String defaultIntegId = createLambdaIntegration(apiId, defaultFn);
String echoIntegId = createLambdaIntegration(apiId, echoFn);
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$connect")
.target("integrations/" + echoIntegId).build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("ping")
.target("integrations/" + pingIntegId)
.routeResponseSelectionExpression("$default").build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("sendMessage")
.target("integrations/" + sendMsgIntegId)
.routeResponseSelectionExpression("$default").build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$default")
.target("integrations/" + defaultIntegId)
.routeResponseSelectionExpression("$default").build());
setupStage(apiId);
// Test ping route
MultiMessageCapture pingCapture = new MultiMessageCapture();
WebSocket wsPing = connectWebSocket(wsUrl(apiId), pingCapture);
wsPing.sendText("{\"action\":\"ping\"}", true).join();
assertThat(pingCapture.getNextMessage(15, TimeUnit.SECONDS)).isEqualTo("pong");
// Test sendMessage route
wsPing.sendText("{\"action\":\"sendMessage\",\"data\":\"test-data\"}", true).join();
assertThat(pingCapture.getNextMessage(15, TimeUnit.SECONDS)).isEqualTo("received: test-data");
// Test $default route (unknown action)
wsPing.sendText("{\"action\":\"unknownAction\"}", true).join();
assertThat(pingCapture.getNextMessage(15, TimeUnit.SECONDS)).isEqualTo("default-route");
wsPing.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
Thread.sleep(500);
}
// ──────────────────────────── 5. @connections API ────────────────────────────
@Test @Order(5)
@DisplayName("5. @connections API — POST sends message, GET returns info, DELETE disconnects")
void connectionsApi() throws Exception {
Assumptions.assumeTrue(lambdaAvailable, "Lambda dispatch required");
String echoFn = createLambda("conn-echo", ECHO_HANDLER);
String apiId = createWsApi("connections-api");
String integId = createLambdaIntegration(apiId, echoFn);
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$connect")
.target("integrations/" + integId).build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$default")
.target("integrations/" + integId)
.routeResponseSelectionExpression("$default").build());
setupStage(apiId);
ApiGatewayManagementApiClient mgmt = managementClient(apiId);
// POST sends message to connection
MultiMessageCapture capture = new MultiMessageCapture();
WebSocket ws = connectWebSocket(wsUrl(apiId), capture);
Thread.sleep(300);
String connId = getConnectionId(ws, capture);
assertThat(connId).isNotNull();
MultiMessageCapture pushCapture = new MultiMessageCapture();
WebSocket ws2 = connectWebSocket(wsUrl(apiId), pushCapture);
Thread.sleep(300);
String connId2 = getConnectionId(ws2, pushCapture);
assertThat(connId2).isNotNull();
mgmt.postToConnection(PostToConnectionRequest.builder()
.connectionId(connId2)
.data(SdkBytes.fromUtf8String("server-push"))
.build());
String pushed = pushCapture.getNextMessage(15, TimeUnit.SECONDS);
assertThat(pushed).isEqualTo("server-push");
// GET returns connection info
GetConnectionResponse info = mgmt.getConnection(GetConnectionRequest.builder()
.connectionId(connId2).build());
assertThat(info.connectedAt()).isNotNull();
// DELETE disconnects the client
CompletableFuture<Integer> closeFuture = new CompletableFuture<>();
pushCapture.setCloseFuture(closeFuture);
mgmt.deleteConnection(DeleteConnectionRequest.builder()
.connectionId(connId2).build());
Integer closeCode = closeFuture.get(15, TimeUnit.SECONDS);
assertThat(closeCode).isNotNull();
ws.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
Thread.sleep(500);
mgmt.close();
}
// ──────────────────────────── 6. Stage variables ────────────────────────────
@Test @Order(6)
@DisplayName("6. Stage variables — integration URI with ${stageVariables.functionName} resolves")
void stageVariables() throws Exception {
Assumptions.assumeTrue(lambdaAvailable, "Lambda dispatch required");
String echoFn = createLambda("sv-echo", ECHO_HANDLER);
String apiId = createWsApi("stage-vars");
// Integration with stage variable reference in URI
var integRes = gw.createIntegration(CreateIntegrationRequest.builder()
.apiId(apiId)
.integrationType(IntegrationType.AWS_PROXY)
.integrationUri("arn:aws:lambda:us-east-1:000000000000:function:${stageVariables.functionName}")
.build());
String integId = integRes.integrationId();
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$connect")
.target("integrations/" + integId).build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$default")
.target("integrations/" + integId)
.routeResponseSelectionExpression("$default").build());
setupStage(apiId, Map.of("functionName", echoFn));
MultiMessageCapture capture = new MultiMessageCapture();
WebSocket ws = connectWebSocket(wsUrl(apiId), capture);
assertThat(ws).isNotNull();
ws.sendText("{\"action\":\"test\",\"body\":\"stage-var-test\"}", true).join();
String response = capture.getNextMessage(15, TimeUnit.SECONDS);
assertThat(response).isNotNull();
ws.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
Thread.sleep(500);
}
// ──────────────────────────── 7. Mock integration ────────────────────────────
@Test @Order(7)
@DisplayName("7. Mock integration — $connect with MOCK integration, no Lambda needed")
void mockIntegration() throws Exception {
Assumptions.assumeTrue(lambdaAvailable, "Lambda dispatch required for $default route");
String apiId = createWsApi("mock-integ");
// MOCK integration for $connect — no Lambda needed
var mockIntegRes = gw.createIntegration(CreateIntegrationRequest.builder()
.apiId(apiId)
.integrationType(IntegrationType.MOCK)
.build());
String mockIntegId = mockIntegRes.integrationId();
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$connect")
.target("integrations/" + mockIntegId).build());
// $default still needs a Lambda for message handling
String echoFn = createLambda("mock-echo", ECHO_HANDLER);
String echoIntegId = createLambdaIntegration(apiId, echoFn);
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$default")
.target("integrations/" + echoIntegId)
.routeResponseSelectionExpression("$default").build());
setupStage(apiId);
MultiMessageCapture capture = new MultiMessageCapture();
WebSocket ws = connectWebSocket(wsUrl(apiId), capture);
assertThat(ws).isNotNull();
ws.sendText("{\"action\":\"test\"}", true).join();
String response = capture.getNextMessage(15, TimeUnit.SECONDS);
assertThat(response).isNotNull();
ws.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
Thread.sleep(500);
}
// ──────────────────────────── 8. Disconnect cleanup ────────────────────────────
@Test @Order(8)
@DisplayName("8. Disconnect cleanup — after disconnect, @connections POST returns 410")
void disconnectCleanup() throws Exception {
Assumptions.assumeTrue(lambdaAvailable, "Lambda dispatch required");
String echoFn = createLambda("dc-echo", ECHO_HANDLER);
String apiId = createWsApi("disconnect");
String integId = createLambdaIntegration(apiId, echoFn);
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$connect")
.target("integrations/" + integId).build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$default")
.target("integrations/" + integId)
.routeResponseSelectionExpression("$default").build());
setupStage(apiId);
MultiMessageCapture capture = new MultiMessageCapture();
WebSocket ws = connectWebSocket(wsUrl(apiId), capture);
Thread.sleep(300);
String connId = getConnectionId(ws, capture);
assertThat(connId).isNotNull();
// Disconnect
ws.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
Thread.sleep(500);
// POST to disconnected connection should return 410
ApiGatewayManagementApiClient mgmt = managementClient(apiId);
assertThatThrownBy(() -> mgmt.postToConnection(PostToConnectionRequest.builder()
.connectionId(connId)
.data(SdkBytes.fromUtf8String("should-fail"))
.build()))
.isInstanceOf(GoneException.class);
mgmt.close();
}
// ──────────────────────────── 9. Payload size limit ────────────────────────────
@Test @Order(9)
@DisplayName("9. Payload size limit — messages exceeding 128 KB receive error frame")
void payloadSizeLimit() throws Exception {
Assumptions.assumeTrue(lambdaAvailable, "Lambda dispatch required");
String echoFn = createLambda("pl-echo", ECHO_HANDLER);
String apiId = createWsApi("payload-limit");
String integId = createLambdaIntegration(apiId, echoFn);
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$connect")
.target("integrations/" + integId).build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$default")
.target("integrations/" + integId)
.routeResponseSelectionExpression("$default").build());
setupStage(apiId);
MultiMessageCapture capture = new MultiMessageCapture();
WebSocket ws = connectWebSocket(wsUrl(apiId), capture);
assertThat(ws).isNotNull();
// Send a message larger than 128 KB
String oversizeMessage = "x".repeat(128 * 1024 + 1);
ws.sendText(oversizeMessage, true).join();
String response = capture.getNextMessage(15, TimeUnit.SECONDS);
assertThat(response).isNotNull();
assertThat(response).contains("Message too long");
// Verify connection is still alive after rejection
ws.sendText("{\"action\":\"test\",\"body\":\"after-oversize\"}", true).join();
String normalResponse = capture.getNextMessage(15, TimeUnit.SECONDS);
assertThat(normalResponse).isNotNull();
ws.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
Thread.sleep(500);
}
// ──────────────────────────── 10. Server-initiated close via @connections DELETE ────────────────────────────
@Test @Order(10)
@DisplayName("10. Server-initiated close — DELETE disconnects and subsequent POST returns 410")
void serverInitiatedClose() throws Exception {
Assumptions.assumeTrue(lambdaAvailable, "Lambda dispatch required");
String echoFn = createLambda("sc-echo", ECHO_HANDLER);
String apiId = createWsApi("server-close");
String integId = createLambdaIntegration(apiId, echoFn);
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$connect")
.target("integrations/" + integId).build());
gw.createRoute(CreateRouteRequest.builder()
.apiId(apiId).routeKey("$default")
.target("integrations/" + integId)
.routeResponseSelectionExpression("$default").build());
setupStage(apiId);
MultiMessageCapture capture = new MultiMessageCapture();
CompletableFuture<Integer> closeFuture = new CompletableFuture<>();
capture.setCloseFuture(closeFuture);
WebSocket ws = connectWebSocket(wsUrl(apiId), capture);
Thread.sleep(300);
String connId = getConnectionId(ws, capture);
assertThat(connId).isNotNull();
// DELETE the connection via @connections API
ApiGatewayManagementApiClient mgmt = managementClient(apiId);
mgmt.deleteConnection(DeleteConnectionRequest.builder()
.connectionId(connId).build());
// Wait for the WebSocket to close
Integer closeCode = closeFuture.get(15, TimeUnit.SECONDS);
assertThat(closeCode).isNotNull();
Thread.sleep(500);
// POST to the deleted connection should return 410
assertThatThrownBy(() -> mgmt.postToConnection(PostToConnectionRequest.builder()
.connectionId(connId)
.data(SdkBytes.fromUtf8String("should-fail"))
.build()))
.isInstanceOf(GoneException.class);
mgmt.close();
}
// ── WebSocket message capture listener ───────────────────────────────────
private static class MultiMessageCapture implements WebSocket.Listener {
private final LinkedBlockingQueue<String> messages = new LinkedBlockingQueue<>();
private final StringBuilder buffer = new StringBuilder();
private volatile CompletableFuture<Integer> closeFuture;
@Override
public void onOpen(WebSocket webSocket) {
webSocket.request(10);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
buffer.append(data);
if (last) {
messages.offer(buffer.toString());
buffer.setLength(0);
}
webSocket.request(1);
return null;
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (closeFuture != null) {
closeFuture.complete(statusCode);
}
return null;
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
if (closeFuture != null) {
closeFuture.completeExceptionally(error);
}
}
public void setCloseFuture(CompletableFuture<Integer> future) {
this.closeFuture = future;
}
public String getNextMessage(long timeout, TimeUnit unit) throws Exception {
return messages.poll(timeout, unit);
}
}
}
@@ -0,0 +1,95 @@
package com.floci.test;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.apigateway.ApiGatewayClient;
import software.amazon.awssdk.services.apigateway.model.IntegrationType;
import software.amazon.awssdk.services.apigateway.model.Resource;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("API Gateway REST VTL")
class ApiGatewayVtlCompatibilityTest {
@Test
@DisplayName("Header map get and keySet work in the native image")
void headerMapMethodsWorkInNativeImage() throws Exception {
Assumptions.assumeFalse(TestFixtures.isRealAws(), "Floci native-image regression test");
ApiGatewayClient apiGateway = TestFixtures.apiGatewayClient();
String apiId = apiGateway.createRestApi(request -> request.name(TestFixtures.uniqueName("apigw-vtl"))).id();
try {
String rootId = apiGateway.getResources(request -> request.restApiId(apiId)).items().stream()
.filter(resource -> "/".equals(resource.path()))
.findFirst()
.map(Resource::id)
.orElseThrow();
String resourceId = apiGateway.createResource(request -> request
.restApiId(apiId)
.parentId(rootId)
.pathPart("headers")).id();
apiGateway.putMethod(request -> request
.restApiId(apiId)
.resourceId(resourceId)
.httpMethod("POST")
.authorizationType("NONE"));
String requestTemplate = """
#set($headers = $input.params().header)
#set($direct = $headers.get('x-repro-header'))
#set($status = 500)
#foreach($name in $headers.keySet())
#if($direct == 'repro-header-value' && $headers.get($name) == 'repro-header-value')
#set($status = 201)
#end
#end
{"statusCode": $status}
""";
apiGateway.putIntegration(request -> request
.restApiId(apiId)
.resourceId(resourceId)
.httpMethod("POST")
.type(IntegrationType.MOCK)
.requestTemplates(Map.of("application/json", requestTemplate)));
apiGateway.putMethodResponse(request -> request
.restApiId(apiId)
.resourceId(resourceId)
.httpMethod("POST")
.statusCode("201"));
apiGateway.putIntegrationResponse(request -> request
.restApiId(apiId)
.resourceId(resourceId)
.httpMethod("POST")
.statusCode("201"));
String deploymentId = apiGateway.createDeployment(request -> request.restApiId(apiId)).id();
apiGateway.createStage(request -> request
.restApiId(apiId)
.stageName("test")
.deploymentId(deploymentId));
HttpClient httpClient = TestFixtures.emulatorHttpClient();
URI uri = URI.create(TestFixtures.endpoint() + "/execute-api/" + apiId + "/test/headers");
HttpRequest request = HttpRequest.newBuilder(uri)
.header("Content-Type", "application/json")
.header("x-repro-header", "repro-header-value")
.POST(HttpRequest.BodyPublishers.ofString("{}"))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(201);
} finally {
apiGateway.deleteRestApi(request -> request.restApiId(apiId));
apiGateway.close();
}
}
}
@@ -0,0 +1,462 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
import software.amazon.awssdk.core.client.config.SdkAdvancedClientOption;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.apigateway.ApiGatewayClient;
import software.amazon.awssdk.services.apigateway.model.*;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import software.amazon.awssdk.services.sfn.SfnClient;
import software.amazon.awssdk.services.sfn.model.*;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
/**
* End-to-end compatibility tests: API Gateway → Step Functions (JSONata) → DynamoDB.
*
* <p>Exercises all five CRUDL operations via HTTP through a deployed API Gateway stage,
* backed by Express Step Functions state machines using JSONata and optimised DynamoDB
* integrations.
*
* <p>Run against real AWS:
* <pre>
* FLOCI_TARGET=aws \
* SFN_ROLE_ARN=arn:aws:iam::123456789012:role/sfn-role \
* APIGW_ROLE_ARN=arn:aws:iam::123456789012:role/apigw-sfn-role \
* mvn compile exec:java -Dexec.args="apigw-sfn-jsonata-crudl"
* </pre>
*/
@DisplayName("API Gateway → SFN JSONata → DynamoDB CRUDL")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ApigwSfnJsonataCrudlTests {
private static final String TABLE_BASE = "apigw-sfn-crudl";
private static final String STAGE = "v1";
private static DynamoDbClient ddb;
private static SfnClient sfn;
private static ApiGatewayClient apigw;
private static HttpClient http;
private static String tableName;
private static String createArn;
private static String readArn;
private static String updateArn;
private static String deleteArn;
private static String listArn;
private static String apiId;
private static String baseUrl;
private static boolean isRealAws;
private static String sfnRoleArn;
private static String apigwRoleArn;
private static Region region;
@BeforeAll
static void setup() throws Exception {
isRealAws = TestFixtures.isRealAws();
region = Region.US_EAST_1;
sfnRoleArn = isRealAws
? System.getenv("SFN_ROLE_ARN")
: "arn:aws:iam::000000000000:role/sfn-role";
apigwRoleArn = isRealAws
? System.getenv("APIGW_ROLE_ARN")
: "arn:aws:iam::000000000000:role/apigw-role";
if (isRealAws && (sfnRoleArn == null || apigwRoleArn == null)) {
Assumptions.abort("SFN_ROLE_ARN or APIGW_ROLE_ARN not set");
}
tableName = TABLE_BASE + "-" + System.currentTimeMillis();
if (isRealAws) {
ddb = DynamoDbClient.builder().region(region).build();
sfn = SfnClient.builder().region(region).build();
apigw = ApiGatewayClient.builder().region(region).build();
} else {
URI endpoint = TestFixtures.endpoint();
ddb = DynamoDbClient.builder()
.endpointOverride(endpoint)
.region(region)
.credentialsProvider(software.amazon.awssdk.auth.credentials.StaticCredentialsProvider.create(
software.amazon.awssdk.auth.credentials.AwsBasicCredentials.create("test", "test")))
.build();
sfn = SfnClient.builder()
.endpointOverride(endpoint)
.region(region)
.credentialsProvider(software.amazon.awssdk.auth.credentials.StaticCredentialsProvider.create(
software.amazon.awssdk.auth.credentials.AwsBasicCredentials.create("test", "test")))
.overrideConfiguration(ClientOverrideConfiguration.builder()
.putAdvancedOption(SdkAdvancedClientOption.DISABLE_HOST_PREFIX_INJECTION, true)
.build())
.build();
apigw = ApiGatewayClient.builder()
.endpointOverride(endpoint)
.region(region)
.credentialsProvider(software.amazon.awssdk.auth.credentials.StaticCredentialsProvider.create(
software.amazon.awssdk.auth.credentials.AwsBasicCredentials.create("test", "test")))
.build();
}
http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build();
// Create DynamoDB table
createTable(ddb, tableName);
if (isRealAws) Thread.sleep(2000);
// Create state machines
createArn = createSm(sfn, sfnRoleArn, tableName, "create", smCreate(tableName));
readArn = createSm(sfn, sfnRoleArn, tableName, "read", smRead(tableName));
updateArn = createSm(sfn, sfnRoleArn, tableName, "update", smUpdate(tableName));
deleteArn = createSm(sfn, sfnRoleArn, tableName, "delete", smDelete(tableName));
listArn = createSm(sfn, sfnRoleArn, tableName, "list", smList(tableName));
if (createArn == null || readArn == null || updateArn == null || deleteArn == null || listArn == null) {
Assumptions.abort("Failed to create state machines");
}
if (isRealAws) Thread.sleep(2000);
// Build API Gateway
apiId = buildApi(apigw, region.id(), apigwRoleArn, createArn, readArn, updateArn, deleteArn, listArn);
String deployId = apigw.createDeployment(b -> b.restApiId(apiId)).id();
apigw.createStage(b -> b.restApiId(apiId).stageName(STAGE).deploymentId(deployId));
if (isRealAws) Thread.sleep(3000);
baseUrl = isRealAws
? "https://" + apiId + ".execute-api." + region.id() + ".amazonaws.com/" + STAGE
: TestFixtures.endpoint() + "/execute-api/" + apiId + "/" + STAGE;
}
@AfterAll
static void cleanup() {
if (createArn != null) {
try { sfn.deleteStateMachine(b -> b.stateMachineArn(createArn)); } catch (Exception ignored) {}
}
if (readArn != null) {
try { sfn.deleteStateMachine(b -> b.stateMachineArn(readArn)); } catch (Exception ignored) {}
}
if (updateArn != null) {
try { sfn.deleteStateMachine(b -> b.stateMachineArn(updateArn)); } catch (Exception ignored) {}
}
if (deleteArn != null) {
try { sfn.deleteStateMachine(b -> b.stateMachineArn(deleteArn)); } catch (Exception ignored) {}
}
if (listArn != null) {
try { sfn.deleteStateMachine(b -> b.stateMachineArn(listArn)); } catch (Exception ignored) {}
}
if (apiId != null && apigw != null) {
try { apigw.deleteRestApi(b -> b.restApiId(apiId)); } catch (Exception ignored) {}
}
if (tableName != null && ddb != null) {
try { ddb.deleteTable(b -> b.tableName(tableName)); } catch (Exception ignored) {}
}
if (ddb != null) ddb.close();
if (sfn != null) sfn.close();
if (apigw != null) apigw.close();
}
@Test
@Order(1)
@DisplayName("Create - POST /items")
void create() throws Exception {
HttpResponse<String> resp = post(http, baseUrl + "/items",
"{\"id\":\"item-1\",\"name\":\"Widget\",\"value\":\"blue\"}");
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("item-1");
}
@Test
@Order(2)
@DisplayName("Read - GET /items/item-1")
void read() throws Exception {
HttpResponse<String> resp = get(http, baseUrl + "/items/item-1");
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("Widget");
assertThat(resp.body()).contains("blue");
}
@Test
@Order(3)
@DisplayName("Update - PUT /items/item-1")
void update() throws Exception {
HttpResponse<String> updateResp = put(http, baseUrl + "/items/item-1",
"{\"name\":\"Widget Pro\",\"value\":\"green\"}");
HttpResponse<String> verifyResp = get(http, baseUrl + "/items/item-1");
assertThat(updateResp.statusCode()).isEqualTo(200);
assertThat(verifyResp.body()).contains("Widget Pro");
assertThat(verifyResp.body()).contains("green");
}
@Test
@Order(4)
@DisplayName("List - GET /items")
void list() throws Exception {
HttpResponse<String> resp = get(http, baseUrl + "/items");
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("item-1");
}
@Test
@Order(5)
@DisplayName("Delete - DELETE /items/item-1")
void delete() throws Exception {
HttpResponse<String> resp = delete(http, baseUrl + "/items/item-1");
assertThat(resp.statusCode()).isEqualTo(200);
}
@Test
@Order(6)
@DisplayName("Read after delete - GET /items/item-1")
void readAfterDelete() throws Exception {
HttpResponse<String> resp = get(http, baseUrl + "/items/item-1");
assertThat(resp.statusCode()).isEqualTo(200);
assertThat(resp.body()).contains("false");
assertThat(resp.body()).doesNotContain("Widget");
}
// State machine definitions
private static String smCreate(String table) {
return """
{
"QueryLanguage": "JSONata",
"StartAt": "PutItem",
"States": {
"PutItem": {
"Type": "Task",
"Resource": "arn:aws:states:::dynamodb:putItem",
"Arguments": {
"TableName": "TABLE",
"Item": {
"pk": {"S": "{% $states.input.id %}"},
"sk": {"S": "item"},
"name": {"S": "{% $states.input.name %}"},
"value": {"S": "{% $states.input.value %}"}
}
},
"Output": {"id": "{% $states.input.id %}", "created": true},
"End": true
}
}
}""".replace("TABLE", table);
}
private static String smRead(String table) {
return """
{
"QueryLanguage": "JSONata",
"StartAt": "GetItem",
"States": {
"GetItem": {
"Type": "Task",
"Resource": "arn:aws:states:::dynamodb:getItem",
"Arguments": {
"TableName": "TABLE",
"Key": {
"pk": {"S": "{% $states.input.id %}"},
"sk": {"S": "item"}
}
},
"Output": "{% $exists($states.result.Item) ? {\\n \\"found\\": true,\\n \\"id\\": $states.result.Item.pk.S,\\n \\"name\\": $states.result.Item.name.S,\\n \\"value\\": $states.result.Item.value.S\\n} : {\\"found\\": false} %}",
"End": true
}
}
}""".replace("TABLE", table);
}
private static String smUpdate(String table) {
return """
{
"QueryLanguage": "JSONata",
"StartAt": "UpdateItem",
"States": {
"UpdateItem": {
"Type": "Task",
"Resource": "arn:aws:states:::dynamodb:updateItem",
"Arguments": {
"TableName": "TABLE",
"Key": {
"pk": {"S": "{% $states.input.id %}"},
"sk": {"S": "item"}
},
"UpdateExpression": "SET #n = :name, #v = :value",
"ExpressionAttributeNames": {"#n": "name", "#v": "value"},
"ExpressionAttributeValues": {
":name": {"S": "{% $states.input.name %}"},
":value": {"S": "{% $states.input.value %}"}
}
},
"Output": {"id": "{% $states.input.id %}", "updated": true},
"End": true
}
}
}""".replace("TABLE", table);
}
private static String smDelete(String table) {
return """
{
"QueryLanguage": "JSONata",
"StartAt": "DeleteItem",
"States": {
"DeleteItem": {
"Type": "Task",
"Resource": "arn:aws:states:::dynamodb:deleteItem",
"Arguments": {
"TableName": "TABLE",
"Key": {
"pk": {"S": "{% $states.input.id %}"},
"sk": {"S": "item"}
}
},
"Output": {"id": "{% $states.input.id %}", "deleted": true},
"End": true
}
}
}""".replace("TABLE", table);
}
private static String smList(String table) {
return """
{
"QueryLanguage": "JSONata",
"StartAt": "Scan",
"States": {
"Scan": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:dynamodb:scan",
"Arguments": {
"TableName": "TABLE"
},
"Output": {
"count": "{% $states.result.Count %}",
"items": "{% [$states.result.Items.{\\"id\\": pk.S, \\"name\\": name.S, \\"value\\": value.S}] %}"
},
"End": true
}
}
}""".replace("TABLE", table);
}
// Setup helpers
private static void createTable(DynamoDbClient ddb, String tableName) {
try {
ddb.createTable(b -> b
.tableName(tableName)
.keySchema(
KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build(),
KeySchemaElement.builder().attributeName("sk").keyType(KeyType.RANGE).build())
.attributeDefinitions(
AttributeDefinition.builder().attributeName("pk")
.attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder().attributeName("sk")
.attributeType(ScalarAttributeType.S).build())
.billingMode(BillingMode.PAY_PER_REQUEST));
} catch (ResourceInUseException ignored) {}
}
private static String createSm(SfnClient sfn, String roleArn, String tableName, String op, String definition) {
try {
String name = TABLE_BASE + "-" + op + "-" + tableName.substring(tableName.lastIndexOf('-') + 1);
return sfn.createStateMachine(b -> b
.name(name)
.definition(definition)
.type(StateMachineType.EXPRESS)
.roleArn(roleArn)).stateMachineArn();
} catch (Exception e) {
return null;
}
}
private static String buildApi(ApiGatewayClient apigw, String region, String roleArn,
String createArn, String readArn, String updateArn,
String deleteArn, String listArn) {
String apiId = apigw.createRestApi(b -> b.name(TABLE_BASE + "-" + System.currentTimeMillis())).id();
String rootId = apigw.getResources(b -> b.restApiId(apiId)).items()
.stream().filter(r -> "/".equals(r.path())).findFirst()
.map(Resource::id).orElseThrow();
String itemsId = apigw.createResource(b -> b.restApiId(apiId).parentId(rootId).pathPart("items")).id();
String itemId = apigw.createResource(b -> b.restApiId(apiId).parentId(itemsId).pathPart("{id}")).id();
String sfnUri = "arn:aws:apigateway:" + region + ":states:action/StartSyncExecution";
wireMethod(apigw, apiId, itemsId, "POST", sfnUri, roleArn, createArn,
"{\"input\": \"$util.escapeJavaScript($input.json('$'))\",\"stateMachineArn\": \"" + createArn + "\"}");
wireMethod(apigw, apiId, itemsId, "GET", sfnUri, roleArn, listArn,
"{\"input\": \"{}\",\"stateMachineArn\": \"" + listArn + "\"}");
wireMethod(apigw, apiId, itemId, "GET", sfnUri, roleArn, readArn,
"{\"input\": \"{\\\"id\\\": \\\"$input.params('id')\\\"}\",\"stateMachineArn\": \"" + readArn + "\"}");
wireMethod(apigw, apiId, itemId, "PUT", sfnUri, roleArn, updateArn,
"#set($b = $input.path('$'))\n{\"input\": \"{\\\"id\\\": \\\"$input.params('id')\\\","
+ "\\\"name\\\": \\\"$b.name\\\",\\\"value\\\": \\\"$b.value\\\"}\","
+ "\"stateMachineArn\": \"" + updateArn + "\"}");
wireMethod(apigw, apiId, itemId, "DELETE", sfnUri, roleArn, deleteArn,
"{\"input\": \"{\\\"id\\\": \\\"$input.params('id')\\\"}\",\"stateMachineArn\": \"" + deleteArn + "\"}");
return apiId;
}
private static void wireMethod(ApiGatewayClient apigw, String apiId, String resourceId,
String httpMethod, String uri, String roleArn,
String smArn, String reqTemplate) {
apigw.putMethod(b -> b.restApiId(apiId).resourceId(resourceId)
.httpMethod(httpMethod).authorizationType("NONE"));
apigw.putIntegration(b -> b.restApiId(apiId).resourceId(resourceId)
.httpMethod(httpMethod).type(IntegrationType.AWS)
.integrationHttpMethod("POST").uri(uri).credentials(roleArn)
.requestTemplates(Map.of("application/json", reqTemplate)));
apigw.putMethodResponse(b -> b.restApiId(apiId).resourceId(resourceId)
.httpMethod(httpMethod).statusCode("200"));
apigw.putIntegrationResponse(b -> b.restApiId(apiId).resourceId(resourceId)
.httpMethod(httpMethod).statusCode("200")
.responseTemplates(Map.of("application/json", "$input.path('$.output')")));
}
// HTTP helpers
private static HttpResponse<String> get(HttpClient http, String url) throws Exception {
return http.send(HttpRequest.newBuilder().uri(URI.create(url)).GET()
.timeout(Duration.ofSeconds(20)).build(), HttpResponse.BodyHandlers.ofString());
}
private static HttpResponse<String> post(HttpClient http, String url, String body) throws Exception {
return http.send(HttpRequest.newBuilder().uri(URI.create(url))
.POST(HttpRequest.BodyPublishers.ofString(body))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(20)).build(), HttpResponse.BodyHandlers.ofString());
}
private static HttpResponse<String> put(HttpClient http, String url, String body) throws Exception {
return http.send(HttpRequest.newBuilder().uri(URI.create(url))
.PUT(HttpRequest.BodyPublishers.ofString(body))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(20)).build(), HttpResponse.BodyHandlers.ofString());
}
private static HttpResponse<String> delete(HttpClient http, String url) throws Exception {
return http.send(HttpRequest.newBuilder().uri(URI.create(url))
.DELETE().timeout(Duration.ofSeconds(20)).build(),
HttpResponse.BodyHandlers.ofString());
}
}
@@ -0,0 +1,546 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.appconfig.AppConfigClient;
import software.amazon.awssdk.services.appconfig.model.*;
import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient;
import software.amazon.awssdk.services.appconfigdata.model.BadRequestException;
import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationRequest;
import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationResponse;
import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionRequest;
import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionResponse;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
@DisplayName("AppConfig")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class AppConfigTest {
private static AppConfigClient appConfig;
private static AppConfigDataClient appConfigData;
private static String applicationId;
private static String environmentId;
private static String configurationProfileId;
private static String deploymentStrategyId;
private static String configurationToken;
private static String secondConfigurationToken;
private static String unchangedConfigurationToken;
private static String intervalSessionToken;
private static String emptyAppId;
private static String emptyEnvId;
private static String emptyProfileId;
@BeforeAll
static void setup() {
appConfig = TestFixtures.appConfigClient();
appConfigData = TestFixtures.appConfigDataClient();
}
@AfterAll
static void cleanup() {
if (appConfig != null) {
appConfig.close();
}
if (appConfigData != null) {
appConfigData.close();
}
}
@Test
@Order(1)
void createApplication() {
CreateApplicationResponse response = appConfig.createApplication(CreateApplicationRequest.builder()
.name(TestFixtures.uniqueName("app"))
.description("Test Application")
.build());
applicationId = response.id();
assertThat(applicationId).isNotNull();
}
@Test
@Order(2)
void createEnvironment() {
CreateEnvironmentResponse response = appConfig.createEnvironment(CreateEnvironmentRequest.builder()
.applicationId(applicationId)
.name("dev")
.build());
environmentId = response.id();
assertThat(environmentId).isNotNull();
}
@Test
@Order(3)
void createConfigurationProfile() {
CreateConfigurationProfileResponse response = appConfig.createConfigurationProfile(CreateConfigurationProfileRequest.builder()
.applicationId(applicationId)
.name("main-config")
.locationUri("hosted")
.type("AWS.Freeform")
.build());
configurationProfileId = response.id();
assertThat(configurationProfileId).isNotNull();
}
@Test
@Order(4)
void createHostedConfigurationVersion() {
CreateHostedConfigurationVersionResponse response = appConfig.createHostedConfigurationVersion(
CreateHostedConfigurationVersionRequest.builder()
.applicationId(applicationId)
.configurationProfileId(configurationProfileId)
.content(SdkBytes.fromString("{\"key\": \"value\"}", StandardCharsets.UTF_8))
.contentType("application/json")
.build());
assertThat(response.versionNumber()).isEqualTo(1);
}
@Test
@Order(5)
void createDeploymentStrategy() {
CreateDeploymentStrategyResponse response = appConfig.createDeploymentStrategy(CreateDeploymentStrategyRequest.builder()
.name("immediate")
.deploymentDurationInMinutes(0)
.finalBakeTimeInMinutes(0)
.growthFactor(100.0f)
.build());
deploymentStrategyId = response.id();
assertThat(deploymentStrategyId).isNotNull();
}
@Test
@Order(6)
void startDeployment() {
StartDeploymentResponse response = appConfig.startDeployment(StartDeploymentRequest.builder()
.applicationId(applicationId)
.environmentId(environmentId)
.configurationProfileId(configurationProfileId)
.configurationVersion("1")
.deploymentStrategyId(deploymentStrategyId)
.build());
assertThat(response.deploymentNumber()).isNotNull();
}
@Test
@Order(7)
void startConfigurationSession() {
var response = appConfigData.startConfigurationSession(StartConfigurationSessionRequest.builder()
.applicationIdentifier(applicationId)
.environmentIdentifier(environmentId)
.configurationProfileIdentifier(configurationProfileId)
.build());
configurationToken = response.initialConfigurationToken();
assertThat(configurationToken).isNotNull();
}
@Test
@Order(8)
void getLatestConfiguration() {
GetLatestConfigurationResponse response = appConfigData.getLatestConfiguration(GetLatestConfigurationRequest.builder()
.configurationToken(configurationToken)
.build());
assertThat(response.configuration().asString(StandardCharsets.UTF_8)).isEqualTo("{\"key\": \"value\"}");
assertThat(response.contentType()).startsWith("application/json");
assertThat(response.versionLabel()).isEqualTo("1");
assertThat(response.nextPollConfigurationToken()).isNotNull();
assertThat(response.nextPollConfigurationToken()).isNotEqualTo(configurationToken);
secondConfigurationToken = response.nextPollConfigurationToken();
}
@Test
@Order(9)
void staleConfigurationTokenIsRejected() {
assertThrows(BadRequestException.class, () -> appConfigData.getLatestConfiguration(
GetLatestConfigurationRequest.builder()
.configurationToken(configurationToken)
.build()));
}
@Test
@Order(10)
void invalidConfigurationTokenIsRejected() {
assertThrows(BadRequestException.class, () -> appConfigData.getLatestConfiguration(
GetLatestConfigurationRequest.builder()
.configurationToken("not-a-real-token")
.build()));
}
@Test
@Order(11)
void repeatedPollWithSameVersionReturnsEmptyPayload() {
GetLatestConfigurationResponse response = appConfigData.getLatestConfiguration(GetLatestConfigurationRequest.builder()
.configurationToken(secondConfigurationToken)
.build());
assertThat(response.configuration().asByteArray()).isEmpty();
assertThat(response.contentType()).isEqualTo("application/octet-stream");
assertThat(response.versionLabel()).isNull();
assertThat(response.nextPollConfigurationToken()).isNotNull();
assertThat(response.nextPollConfigurationToken()).isNotEqualTo(secondConfigurationToken);
unchangedConfigurationToken = response.nextPollConfigurationToken();
}
@Test
@Order(12)
void updatedDeploymentIsVisibleOnNextPollToken() {
CreateHostedConfigurationVersionResponse versionResponse = appConfig.createHostedConfigurationVersion(
CreateHostedConfigurationVersionRequest.builder()
.applicationId(applicationId)
.configurationProfileId(configurationProfileId)
.content(SdkBytes.fromString("{\"key\": \"value-2\"}", StandardCharsets.UTF_8))
.contentType("application/json")
.build());
assertThat(versionResponse.versionNumber()).isEqualTo(2);
appConfig.startDeployment(StartDeploymentRequest.builder()
.applicationId(applicationId)
.environmentId(environmentId)
.configurationProfileId(configurationProfileId)
.configurationVersion("2")
.deploymentStrategyId(deploymentStrategyId)
.build());
GetLatestConfigurationResponse response = appConfigData.getLatestConfiguration(GetLatestConfigurationRequest.builder()
.configurationToken(unchangedConfigurationToken)
.build());
assertThat(response.configuration().asString(StandardCharsets.UTF_8)).isEqualTo("{\"key\": \"value-2\"}");
assertThat(response.versionLabel()).isEqualTo("2");
assertThat(response.nextPollConfigurationToken()).isNotNull();
assertThat(response.nextPollConfigurationToken()).isNotEqualTo(unchangedConfigurationToken);
}
@Test
@Order(13)
@DisplayName("Poll interval: requested minimum is returned to the client")
void requiredMinimumPollIntervalIsReturned() {
var sessionResponse = appConfigData.startConfigurationSession(StartConfigurationSessionRequest.builder()
.applicationIdentifier(applicationId)
.environmentIdentifier(environmentId)
.configurationProfileIdentifier(configurationProfileId)
.requiredMinimumPollIntervalInSeconds(60)
.build());
intervalSessionToken = sessionResponse.initialConfigurationToken();
GetLatestConfigurationResponse firstResponse = appConfigData.getLatestConfiguration(GetLatestConfigurationRequest.builder()
.configurationToken(intervalSessionToken)
.build());
assertThat(firstResponse.nextPollIntervalInSeconds()).isEqualTo(60);
assertThat(firstResponse.nextPollConfigurationToken()).isNotNull();
GetLatestConfigurationResponse secondResponse = appConfigData.getLatestConfiguration(GetLatestConfigurationRequest.builder()
.configurationToken(firstResponse.nextPollConfigurationToken())
.build());
assertThat(secondResponse.nextPollIntervalInSeconds()).isEqualTo(60);
assertThat(secondResponse.nextPollConfigurationToken()).isNotNull();
}
@Test
@Order(14)
void emptyConfigurationReturnsEmptyPayload() {
emptyAppId = appConfig.createApplication(CreateApplicationRequest.builder()
.name(TestFixtures.uniqueName("empty-app"))
.build()).id();
emptyEnvId = appConfig.createEnvironment(CreateEnvironmentRequest.builder()
.applicationId(emptyAppId)
.name("empty-env")
.build()).id();
emptyProfileId = appConfig.createConfigurationProfile(CreateConfigurationProfileRequest.builder()
.applicationId(emptyAppId)
.name("empty-config")
.locationUri("hosted")
.type("AWS.Freeform")
.build()).id();
String emptyToken = appConfigData.startConfigurationSession(StartConfigurationSessionRequest.builder()
.applicationIdentifier(emptyAppId)
.environmentIdentifier(emptyEnvId)
.configurationProfileIdentifier(emptyProfileId)
.build()).initialConfigurationToken();
GetLatestConfigurationResponse response = appConfigData.getLatestConfiguration(GetLatestConfigurationRequest.builder()
.configurationToken(emptyToken)
.build());
assertThat(response.configuration().asByteArray()).isEmpty();
assertThat(response.contentType()).isEqualTo("application/octet-stream");
// SDK deserializes the empty Version-Label header as null.
// The RestAssured internal test sees "" (raw HTTP header value).
assertThat(response.versionLabel()).isNull();
}
@Test
@Order(50)
@DisplayName("TagResource / ListTagsForResource - SDK round-trip")
void tagAndListViaSdk() {
// Reproducer for the wire-format mismatch that previously made AWS SDK callers
// silently get an empty tag set: SDK serializes as {"Tags": {...}} (capital),
// floci must accept and echo back that exact shape.
String tagAppId = appConfig.createApplication(CreateApplicationRequest.builder()
.name("tag-roundtrip-app")
.build()).id();
String arn = "arn:aws:appconfig:us-east-1:000000000000:application/" + tagAppId;
try {
appConfig.tagResource(TagResourceRequest.builder()
.resourceArn(arn)
.tags(java.util.Map.of("env", "prod", "owner", "Alice"))
.build());
ListTagsForResourceResponse listed = appConfig.listTagsForResource(
ListTagsForResourceRequest.builder().resourceArn(arn).build());
assertThat(listed.tags())
.containsEntry("env", "prod")
.containsEntry("owner", "Alice");
appConfig.untagResource(UntagResourceRequest.builder()
.resourceArn(arn)
.tagKeys("env")
.build());
assertThat(appConfig.listTagsForResource(
ListTagsForResourceRequest.builder().resourceArn(arn).build()).tags())
.doesNotContainKey("env")
.containsEntry("owner", "Alice");
} finally {
try {
appConfig.deleteApplication(DeleteApplicationRequest.builder()
.applicationId(tagAppId).build());
} catch (Exception ignored) {}
}
}
@Test
@Order(51)
@DisplayName("TagResource / ListTagsForResource - deploymentstrategy ARN (top-level, not nested under application/)")
void tagDeploymentStrategyViaSdk() {
// deploymentstrategy/extension/extensionassociation ARNs don't nest under application/
// like every other AppConfig resource tested above - a naive ARN parser can end up
// hardcoding the application/ prefix and rejecting these as invalid, even though AWS
// documents all three as taggable (see e.g. AWS::AppConfig::DeploymentStrategy's Tags
// property). This asserts the SDK call succeeds end-to-end, not just that some ARN shape
// is accepted internally.
String tagStrategyId = appConfig.createDeploymentStrategy(CreateDeploymentStrategyRequest.builder()
.name("tag-roundtrip-strategy")
.deploymentDurationInMinutes(0)
.growthFactor(100f)
.finalBakeTimeInMinutes(0)
.build()).id();
String arn = "arn:aws:appconfig:us-east-1:000000000000:deploymentstrategy/" + tagStrategyId;
appConfig.tagResource(TagResourceRequest.builder()
.resourceArn(arn)
.tags(java.util.Map.of("env", "prod"))
.build());
ListTagsForResourceResponse listed = appConfig.listTagsForResource(
ListTagsForResourceRequest.builder().resourceArn(arn).build());
assertThat(listed.tags()).isNotNull();
}
@Test
@Order(52)
@DisplayName("DeleteConfigurationProfile / DeleteHostedConfigurationVersion / ListDeploymentStrategies / DeleteDeploymentStrategy")
void deleteAndListOperationsPreviouslyReturnedNoSuchBucket() {
// These four operations had no route at all, so the request fell through to S3's
// generic path-style catch-all and returned a raw S3 NoSuchBucket 404 instead of a real
// AppConfig response. Exercised via the SDK (not raw HTTP) so a regression here surfaces
// as an SDK-level exception, matching how a real caller would hit it.
String delAppId = appConfig.createApplication(CreateApplicationRequest.builder()
.name("delete-ops-app")
.build()).id();
try {
String delProfileId = appConfig.createConfigurationProfile(CreateConfigurationProfileRequest.builder()
.applicationId(delAppId)
.name("delete-ops-profile")
.locationUri("hosted")
.type("AWS.Freeform")
.build()).id();
appConfig.createHostedConfigurationVersion(CreateHostedConfigurationVersionRequest.builder()
.applicationId(delAppId)
.configurationProfileId(delProfileId)
.contentType("application/json")
.content(SdkBytes.fromUtf8String("{\"throwaway\": true}"))
.build());
appConfig.deleteHostedConfigurationVersion(DeleteHostedConfigurationVersionRequest.builder()
.applicationId(delAppId)
.configurationProfileId(delProfileId)
.versionNumber(1)
.build());
assertThrows(ResourceNotFoundException.class, () -> appConfig.getHostedConfigurationVersion(
GetHostedConfigurationVersionRequest.builder()
.applicationId(delAppId).configurationProfileId(delProfileId).versionNumber(1).build()));
appConfig.deleteConfigurationProfile(DeleteConfigurationProfileRequest.builder()
.applicationId(delAppId)
.configurationProfileId(delProfileId)
.build());
assertThrows(ResourceNotFoundException.class, () -> appConfig.getConfigurationProfile(
GetConfigurationProfileRequest.builder()
.applicationId(delAppId).configurationProfileId(delProfileId).build()));
String delStrategyId = appConfig.createDeploymentStrategy(CreateDeploymentStrategyRequest.builder()
.name("delete-ops-strategy")
.deploymentDurationInMinutes(0)
.growthFactor(100f)
.finalBakeTimeInMinutes(0)
.build()).id();
ListDeploymentStrategiesResponse listed = appConfig.listDeploymentStrategies(
ListDeploymentStrategiesRequest.builder().build());
assertThat(listed.items())
.extracting(strategy -> strategy.id())
.contains("AppConfig.AllAtOnce", "AppConfig.Linear50PercentEvery30Seconds",
"AppConfig.Canary10Percent20Minutes", delStrategyId);
appConfig.deleteDeploymentStrategy(DeleteDeploymentStrategyRequest.builder()
.deploymentStrategyId(delStrategyId).build());
assertThrows(ResourceNotFoundException.class, () -> appConfig.getDeploymentStrategy(
GetDeploymentStrategyRequest.builder().deploymentStrategyId(delStrategyId).build()));
} finally {
try {
appConfig.deleteApplication(DeleteApplicationRequest.builder()
.applicationId(delAppId).build());
} catch (Exception e) {
System.err.println("Best-effort cleanup for delete-ops-app failed: " + e.getMessage());
}
}
}
@Test
@Order(53)
@DisplayName("DeleteConfigurationProfile rejects a profile that belongs to a different application")
void deleteConfigurationProfileRejectsWrongApplication() {
String ownerAppId = appConfig.createApplication(CreateApplicationRequest.builder()
.name("profile-owner-app").build()).id();
String otherAppId = appConfig.createApplication(CreateApplicationRequest.builder()
.name("profile-other-app").build()).id();
try {
String profileId = appConfig.createConfigurationProfile(CreateConfigurationProfileRequest.builder()
.applicationId(ownerAppId)
.name("owned-profile")
.locationUri("hosted")
.type("AWS.Freeform")
.build()).id();
// Addressing the owner's profile through a DIFFERENT application's path must not
// delete it - only "not found", never a real cross-application deletion.
assertThrows(ResourceNotFoundException.class, () -> appConfig.deleteConfigurationProfile(
DeleteConfigurationProfileRequest.builder()
.applicationId(otherAppId).configurationProfileId(profileId).build()));
assertThat(appConfig.getConfigurationProfile(GetConfigurationProfileRequest.builder()
.applicationId(ownerAppId).configurationProfileId(profileId).build()).id())
.isEqualTo(profileId);
} finally {
try {
appConfig.deleteApplication(DeleteApplicationRequest.builder().applicationId(ownerAppId).build());
} catch (Exception e) {
System.err.println("Best-effort cleanup for profile-owner-app failed: " + e.getMessage());
}
try {
appConfig.deleteApplication(DeleteApplicationRequest.builder().applicationId(otherAppId).build());
} catch (Exception e) {
System.err.println("Best-effort cleanup for profile-other-app failed: " + e.getMessage());
}
}
}
@Test
@Order(54)
@DisplayName("DeleteDeploymentStrategy rejects a predefined strategy")
void deleteDeploymentStrategyRejectsPredefined() {
assertThrows(software.amazon.awssdk.services.appconfig.model.BadRequestException.class, () -> appConfig.deleteDeploymentStrategy(
DeleteDeploymentStrategyRequest.builder().deploymentStrategyId("AppConfig.AllAtOnce").build()));
assertThat(appConfig.getDeploymentStrategy(GetDeploymentStrategyRequest.builder()
.deploymentStrategyId("AppConfig.AllAtOnce").build()).id())
.isEqualTo("AppConfig.AllAtOnce");
}
@Test
@Order(55)
@DisplayName("GetLatestConfiguration resolves basic Feature Flags to retrieval-time format")
void getLatestConfigurationReturnsFeatureFlagsRetrievalFormat() {
String featureFlagsAppId = appConfig.createApplication(CreateApplicationRequest.builder()
.name(TestFixtures.uniqueName("feature-flags-app"))
.build()).id();
String featureFlagsEnvId = appConfig.createEnvironment(CreateEnvironmentRequest.builder()
.applicationId(featureFlagsAppId)
.name("test")
.build()).id();
String featureFlagsProfileId = appConfig.createConfigurationProfile(
CreateConfigurationProfileRequest.builder()
.applicationId(featureFlagsAppId)
.name("flags")
.locationUri("hosted")
.type("AWS.AppConfig.FeatureFlags")
.build()).id();
String deploymentContent = "{\"flags\":{\"enabled\":{\"name\":\"enabled\"},"
+ "\"disabled\":{\"name\":\"disabled\"}},\"values\":{"
+ "\"enabled\":{\"enabled\":true,\"number\":0,\"beta\":false,"
+ "\"_createdAt\":\"created\",\"_updatedAt\":\"updated\"},"
+ "\"disabled\":{\"enabled\":false,\"secret\":\"must-not-leak\"}},"
+ "\"version\":\"1\"}";
CreateHostedConfigurationVersionResponse version = appConfig.createHostedConfigurationVersion(
CreateHostedConfigurationVersionRequest.builder()
.applicationId(featureFlagsAppId)
.configurationProfileId(featureFlagsProfileId)
.content(SdkBytes.fromString(deploymentContent, StandardCharsets.UTF_8))
.contentType("application/json")
.build());
assertThat(version.versionNumber()).isEqualTo(1);
GetHostedConfigurationVersionResponse stored = appConfig.getHostedConfigurationVersion(
GetHostedConfigurationVersionRequest.builder()
.applicationId(featureFlagsAppId)
.configurationProfileId(featureFlagsProfileId)
.versionNumber(1)
.build());
assertThat(stored.content().asString(StandardCharsets.UTF_8)).isEqualTo(deploymentContent);
appConfig.startDeployment(StartDeploymentRequest.builder()
.applicationId(featureFlagsAppId)
.environmentId(featureFlagsEnvId)
.configurationProfileId(featureFlagsProfileId)
.configurationVersion("1")
.deploymentStrategyId("AppConfig.AllAtOnce")
.build());
StartConfigurationSessionResponse session = appConfigData.startConfigurationSession(
StartConfigurationSessionRequest.builder()
.applicationIdentifier(featureFlagsAppId)
.environmentIdentifier(featureFlagsEnvId)
.configurationProfileIdentifier(featureFlagsProfileId)
.build());
GetLatestConfigurationResponse response = appConfigData.getLatestConfiguration(
GetLatestConfigurationRequest.builder()
.configurationToken(session.initialConfigurationToken())
.build());
assertThat(response.configuration().asString(StandardCharsets.UTF_8))
.isEqualTo("{\"enabled\":{\"enabled\":true,\"number\":0,\"beta\":false},"
+ "\"disabled\":{\"enabled\":false}}");
assertThat(response.contentType()).startsWith("application/json");
assertThat(response.versionLabel()).isEqualTo("1");
assertThat(response.nextPollConfigurationToken()).isNotBlank();
}
}
@@ -0,0 +1,235 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.appintegrations.AppIntegrationsClient;
import software.amazon.awssdk.services.appintegrations.model.CreateDataIntegrationResponse;
import software.amazon.awssdk.services.appintegrations.model.CreateEventIntegrationResponse;
import software.amazon.awssdk.services.appintegrations.model.GetDataIntegrationResponse;
import software.amazon.awssdk.services.appintegrations.model.GetEventIntegrationResponse;
import software.amazon.awssdk.services.appintegrations.model.ListDataIntegrationsResponse;
import software.amazon.awssdk.services.appintegrations.model.ListEventIntegrationAssociationsResponse;
import software.amazon.awssdk.services.appintegrations.model.ListEventIntegrationsResponse;
import software.amazon.awssdk.services.appintegrations.model.ResourceNotFoundException;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("Amazon AppIntegrations")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class AppIntegrationsTest {
private static AppIntegrationsClient appIntegrations;
private static String eventIntegrationName;
private static String eventIntegrationArn;
private static String dataIntegrationName;
private static String dataIntegrationId;
private static String dataIntegrationArn;
@BeforeAll
static void setup() {
appIntegrations = TestFixtures.appIntegrationsClient();
long stamp = System.currentTimeMillis();
eventIntegrationName = "sdk-test-event-integration-" + stamp;
dataIntegrationName = "sdk-test-data-integration-" + stamp;
}
@AfterAll
static void cleanup() {
if (appIntegrations == null) {
return;
}
if (eventIntegrationArn != null) {
try {
appIntegrations.deleteEventIntegration(r -> r.name(eventIntegrationName));
} catch (RuntimeException e) {
System.out.println("AppIntegrations cleanup skipped the event integration: " + e);
}
}
if (dataIntegrationId != null) {
try {
appIntegrations.deleteDataIntegration(r -> r.dataIntegrationIdentifier(dataIntegrationId));
} catch (RuntimeException e) {
System.out.println("AppIntegrations cleanup skipped the data integration: " + e);
}
}
appIntegrations.close();
}
@Test
@Order(1)
void createEventIntegration() {
CreateEventIntegrationResponse response = appIntegrations.createEventIntegration(r -> r
.name(eventIntegrationName)
.description("partner events")
.eventBridgeBus("sdk-test-bus")
.eventFilter(f -> f.source("aws.partner/example.com/1234"))
.tags(Map.of("team", "data")));
eventIntegrationArn = response.eventIntegrationArn();
assertThat(eventIntegrationArn)
.contains(":app-integrations:")
.contains(":event-integration/" + eventIntegrationName);
}
@Test
@Order(2)
void getEventIntegration() {
GetEventIntegrationResponse response = appIntegrations.getEventIntegration(r -> r
.name(eventIntegrationName));
assertThat(response.name()).isEqualTo(eventIntegrationName);
assertThat(response.description()).isEqualTo("partner events");
assertThat(response.eventIntegrationArn()).isEqualTo(eventIntegrationArn);
assertThat(response.eventBridgeBus()).isEqualTo("sdk-test-bus");
assertThat(response.eventFilter().source()).isEqualTo("aws.partner/example.com/1234");
assertThat(response.tags()).containsEntry("team", "data");
}
@Test
@Order(3)
void listEventIntegrations() {
ListEventIntegrationsResponse response = appIntegrations.listEventIntegrations(r -> { });
assertThat(response.eventIntegrations()).anySatisfy(integration -> {
assertThat(integration.name()).isEqualTo(eventIntegrationName);
assertThat(integration.eventIntegrationArn()).isEqualTo(eventIntegrationArn);
assertThat(integration.eventBridgeBus()).isEqualTo("sdk-test-bus");
});
}
@Test
@Order(4)
void updateEventIntegrationDescription() {
appIntegrations.updateEventIntegration(r -> r
.name(eventIntegrationName)
.description("partner events, revised"));
assertThat(appIntegrations.getEventIntegration(r -> r.name(eventIntegrationName)).description())
.isEqualTo("partner events, revised");
}
/**
* Associations are written by the consuming service, so a fresh integration reports none.
* The call still has to deserialize, which is what pins the wire member name.
*/
@Test
@Order(5)
void listEventIntegrationAssociationsIsEmpty() {
ListEventIntegrationAssociationsResponse response = appIntegrations
.listEventIntegrationAssociations(r -> r.eventIntegrationName(eventIntegrationName));
assertThat(response.eventIntegrationAssociations()).isEmpty();
}
@Test
@Order(6)
void createDataIntegration() {
CreateDataIntegrationResponse response = appIntegrations.createDataIntegration(r -> r
.name(dataIntegrationName)
.description("salesforce pull")
.kmsKey("arn:aws:kms:us-east-1:000000000000:key/abc")
.sourceURI("Salesforce://AppFlow/test")
.scheduleConfig(s -> s
.scheduleExpression("rate(1 hour)")
.firstExecutionFrom("1439788800000")
.object("Account"))
.fileConfiguration(f -> f.folders(List.of("/home/data")))
.tags(Map.of("team", "data")));
dataIntegrationId = response.id();
dataIntegrationArn = response.arn();
assertThat(dataIntegrationId).isNotBlank();
assertThat(dataIntegrationArn)
.contains(":app-integrations:")
.contains(":data-integration/" + dataIntegrationId);
assertThat(response.name()).isEqualTo(dataIntegrationName);
assertThat(response.kmsKey()).isEqualTo("arn:aws:kms:us-east-1:000000000000:key/abc");
assertThat(response.sourceURI()).isEqualTo("Salesforce://AppFlow/test");
assertThat(response.scheduleConfiguration().scheduleExpression()).isEqualTo("rate(1 hour)");
assertThat(response.fileConfiguration().folders()).containsExactly("/home/data");
assertThat(response.tags()).containsEntry("team", "data");
}
@Test
@Order(7)
void getDataIntegrationByIdAndByArn() {
GetDataIntegrationResponse byId = appIntegrations.getDataIntegration(r -> r
.identifier(dataIntegrationId));
assertThat(byId.id()).isEqualTo(dataIntegrationId);
assertThat(byId.arn()).isEqualTo(dataIntegrationArn);
assertThat(byId.description()).isEqualTo("salesforce pull");
assertThat(byId.scheduleConfiguration().firstExecutionFrom()).isEqualTo("1439788800000");
assertThat(byId.scheduleConfiguration().object()).isEqualTo("Account");
assertThat(appIntegrations.getDataIntegration(r -> r.identifier(dataIntegrationArn)).id())
.isEqualTo(dataIntegrationId);
}
@Test
@Order(8)
void listDataIntegrations() {
ListDataIntegrationsResponse response = appIntegrations.listDataIntegrations(r -> { });
assertThat(response.dataIntegrations()).anySatisfy(summary -> {
assertThat(summary.name()).isEqualTo(dataIntegrationName);
assertThat(summary.arn()).isEqualTo(dataIntegrationArn);
assertThat(summary.sourceURI()).isEqualTo("Salesforce://AppFlow/test");
});
}
@Test
@Order(9)
void updateDataIntegrationDescription() {
appIntegrations.updateDataIntegration(r -> r
.identifier(dataIntegrationId)
.description("salesforce pull, revised"));
assertThat(appIntegrations.getDataIntegration(r -> r.identifier(dataIntegrationId)).description())
.isEqualTo("salesforce pull, revised");
}
@Test
@Order(10)
void tagRoundTripOnBothIntegrationTypes() {
for (String arn : List.of(eventIntegrationArn, dataIntegrationArn)) {
appIntegrations.tagResource(r -> r.resourceArn(arn).tags(Map.of("env", "test")));
assertThat(appIntegrations.listTagsForResource(r -> r.resourceArn(arn)).tags())
.containsEntry("team", "data")
.containsEntry("env", "test");
appIntegrations.untagResource(r -> r.resourceArn(arn).tagKeys("env"));
assertThat(appIntegrations.listTagsForResource(r -> r.resourceArn(arn)).tags())
.containsEntry("team", "data")
.doesNotContainKey("env");
}
}
@Test
@Order(11)
void deleteBothIntegrationsThenGetThrowsResourceNotFound() {
appIntegrations.deleteEventIntegration(r -> r.name(eventIntegrationName));
appIntegrations.deleteDataIntegration(r -> r.dataIntegrationIdentifier(dataIntegrationId));
assertThatThrownBy(() -> appIntegrations.getEventIntegration(r -> r.name(eventIntegrationName)))
.isInstanceOf(ResourceNotFoundException.class);
assertThatThrownBy(() -> appIntegrations.getDataIntegration(r -> r.identifier(dataIntegrationId)))
.isInstanceOf(ResourceNotFoundException.class);
eventIntegrationArn = null;
dataIntegrationId = null;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,393 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.athena.AthenaClient;
import software.amazon.awssdk.services.athena.model.*;
import software.amazon.awssdk.services.glue.GlueClient;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("Athena Query Execution")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class AthenaTest {
private static AthenaClient athena;
private static String queryExecutionId;
@BeforeAll
static void setup() {
athena = TestFixtures.athenaClient();
}
@AfterAll
static void cleanup() {
if (athena != null) {
athena.close();
}
}
@Test
@Order(1)
@DisplayName("Start query execution returns an execution ID")
void startQueryExecution() {
StartQueryExecutionResponse response = athena.startQueryExecution(
StartQueryExecutionRequest.builder()
.queryString("SELECT 1 AS value")
.workGroup("primary")
.resultConfiguration(ResultConfiguration.builder()
.outputLocation("s3://floci-athena-results/sdk-tests/")
.build())
.build());
assertThat(response.queryExecutionId()).isNotBlank();
queryExecutionId = response.queryExecutionId();
}
@Test
@Order(2)
@DisplayName("Get query execution returns execution details")
void getQueryExecution() {
GetQueryExecutionResponse response = athena.getQueryExecution(
GetQueryExecutionRequest.builder()
.queryExecutionId(queryExecutionId)
.build());
QueryExecution execution = response.queryExecution();
assertThat(execution.queryExecutionId()).isEqualTo(queryExecutionId);
assertThat(execution.query()).isEqualTo("SELECT 1 AS value");
assertThat(execution.status().state()).isIn(
QueryExecutionState.RUNNING, QueryExecutionState.SUCCEEDED);
}
@Test
@Order(3)
@DisplayName("Get query results returns result set")
void getQueryResults() throws InterruptedException {
QueryExecutionStatus status = TestFixtures.awaitAthenaQueryTerminal(
athena, queryExecutionId, Duration.ofSeconds(60));
assertThat(status.state())
.as("Athena query did not succeed: %s", status.stateChangeReason())
.isEqualTo(QueryExecutionState.SUCCEEDED);
GetQueryResultsResponse results = athena.getQueryResults(
GetQueryResultsRequest.builder()
.queryExecutionId(queryExecutionId)
.build());
assertThat(results.resultSet()).isNotNull();
}
@Test
@Order(4)
@DisplayName("List query executions includes started execution")
void listQueryExecutions() {
ListQueryExecutionsResponse response = athena.listQueryExecutions(
ListQueryExecutionsRequest.builder().build());
assertThat(response.queryExecutionIds()).contains(queryExecutionId);
}
@Test
@Order(5)
@DisplayName("Get non-existent query execution throws InvalidRequestException")
void getQueryExecutionNotFound() {
assertThatThrownBy(() -> athena.getQueryExecution(
GetQueryExecutionRequest.builder()
.queryExecutionId("00000000-0000-0000-0000-000000000000")
.build()))
.isInstanceOf(InvalidRequestException.class);
}
/**
* Reproduces issue #1498: the AthenaClient fails to unmarshal the {@code CreationTime} field returned by Floci's
* {@code GetWorkGroup} response.
*/
@Test
@Order(6)
@DisplayName("getWorkGroup must unmarshal creationTime successfully")
void getWorkGroupCreationTimeCanBeUnmarshalledBySdk() {
String groupName = UUID.randomUUID().toString();
athena.createWorkGroup(
CreateWorkGroupRequest.builder()
.name(groupName)
.build()
);
GetWorkGroupResponse response = athena.getWorkGroup(
GetWorkGroupRequest.builder()
.workGroup(groupName)
.build()
);
Instant creationTime = response.workGroup().creationTime();
assertThat(creationTime)
.as("creationTime must be parseable by the AWS SDK")
.isNotNull();
}
@Test
@Order(7)
@DisplayName("getTableMetadata must unmarshal timestamps successfully")
void getTableMetadataTimestampsCanBeUnmarshalledBySdk() {
GlueClient glue = TestFixtures.glueClient();
String dbName = "athena_ts_test_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
String tableName = "orders";
glue.createDatabase(r -> r.databaseInput(i -> i.name(dbName)));
glue.createTable(r -> r
.databaseName(dbName)
.tableInput(t -> t
.name(tableName)
.tableType("EXTERNAL_TABLE")
.storageDescriptor(
sd -> sd
.location("s3://test-bucket/" + dbName + "/")
.columns(c -> c.name("id").type("string"))
)
)
);
try {
GetTableMetadataResponse response = athena.getTableMetadata(
GetTableMetadataRequest.builder()
.catalogName("AwsDataCatalog")
.databaseName(dbName)
.tableName(tableName)
.build()
);
assertThat(response.tableMetadata().createTime())
.as("createTime must be parseable by the AWS SDK")
.isNotNull();
assertThat(response.tableMetadata().lastAccessTime())
.as("lastAccessTime must be parseable by the AWS SDK")
.isNotNull();
} finally {
try {
glue.deleteTable(r -> r.databaseName(dbName).name(tableName));
glue.deleteDatabase(r -> r.name(dbName));
} catch (Exception ignored) {
// Best-effort test cleanup
}
glue.close();
}
}
@Test
@Order(8)
@DisplayName("CREATE DATABASE through Athena updates the Glue catalog")
void createDatabaseDdlUsesGlueCatalog() throws InterruptedException {
String dbName = "athena_ddl_test_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
String location = "s3://test-bucket/" + dbName + "/";
try (GlueClient glue = TestFixtures.glueClient()) {
try {
StartQueryExecutionResponse started = athena.startQueryExecution(
StartQueryExecutionRequest.builder()
.queryString("CREATE DATABASE IF NOT EXISTS " + dbName
+ " LOCATION '" + location + "'")
.workGroup("primary")
.build());
QueryExecutionStatus status = TestFixtures.awaitAthenaQueryTerminal(
athena, started.queryExecutionId(), Duration.ofSeconds(60));
assertThat(status.state())
.as("Athena DDL did not succeed: %s", status.stateChangeReason())
.isEqualTo(QueryExecutionState.SUCCEEDED);
QueryExecution execution = athena.getQueryExecution(r -> r
.queryExecutionId(started.queryExecutionId())).queryExecution();
assertThat(execution.statementType()).isEqualTo(StatementType.DDL);
assertThat(execution.resultConfiguration()).isNull();
GetQueryResultsResponse results = athena.getQueryResults(r -> r
.queryExecutionId(started.queryExecutionId()));
assertThat(results.resultSet().rows()).isEmpty();
assertThat(results.resultSet().resultSetMetadata().columnInfo()).isEmpty();
assertThat(glue.getDatabase(r -> r.name(dbName)).database().locationUri())
.isEqualTo(location);
} finally {
try {
glue.deleteDatabase(r -> r.name(dbName));
} catch (Exception ignored) {
// Best-effort test cleanup
}
}
}
}
/**
* Reproduces issue #2859: Athena queries referencing tables with qualified names (e.g.
* {@code FROM shop.orders}) failed with "schema does not exist" when DuckDB schemas were not
* registered for Glue databases.
*/
@Test
@Order(9)
@DisplayName("Query table with qualified database schema resolves and executes")
void queryTableWithQualifiedDatabaseSchemaResolves() throws InterruptedException {
String uniqueSuffix = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
String dbName = "shop_" + uniqueSuffix;
String tableName = "orders";
String bucketName = "athena-compat-" + uniqueSuffix;
String dataPrefix = "orders/";
String location = "s3://" + bucketName + "/" + dataPrefix;
try (GlueClient glue = TestFixtures.glueClient();
S3Client s3 = TestFixtures.s3Client()) {
s3.createBucket(r -> r.bucket(bucketName));
s3.putObject(r -> r.bucket(bucketName).key(dataPrefix + "orders.json"),
RequestBody.fromString(
"{\"customer\":\"ana\",\"amount\":10}\n{\"customer\":\"leo\",\"amount\":7}\n"));
glue.createDatabase(r -> r.databaseInput(i -> i.name(dbName)));
glue.createTable(r -> r
.databaseName(dbName)
.tableInput(t -> t
.name(tableName)
.tableType("EXTERNAL_TABLE")
.storageDescriptor(sd -> sd
.location(location)
.inputFormat("org.apache.hadoop.mapred.TextInputFormat")
.outputFormat("org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat")
.serdeInfo(s -> s
.serializationLibrary("org.openx.data.jsonserde.JsonSerDe")
.parameters(Map.of("serialization.format", "1")))
.columns(
software.amazon.awssdk.services.glue.model.Column.builder().name("customer").type("string").build(),
software.amazon.awssdk.services.glue.model.Column.builder().name("amount").type("int").build()
)
)
)
);
try {
// Qualified query without context database (issue #2859 exact shape)
StartQueryExecutionResponse started = athena.startQueryExecution(
StartQueryExecutionRequest.builder()
.queryString("SELECT customer, sum(amount) as total FROM " + dbName + "." + tableName
+ " GROUP BY customer ORDER BY customer")
.workGroup("primary")
.resultConfiguration(ResultConfiguration.builder()
.outputLocation("s3://" + bucketName + "/results/")
.build())
.build());
QueryExecutionStatus status = TestFixtures.awaitAthenaQueryTerminal(
athena, started.queryExecutionId(), Duration.ofSeconds(60));
assertThat(status.state())
.as("Athena qualified query did not succeed: %s", status.stateChangeReason())
.isEqualTo(QueryExecutionState.SUCCEEDED);
GetQueryResultsResponse results = athena.getQueryResults(r -> r
.queryExecutionId(started.queryExecutionId()));
assertThat(results.resultSet().rows()).hasSizeGreaterThanOrEqualTo(3);
// Unqualified query with context database (context alias verification)
StartQueryExecutionResponse startedUnqualified = athena.startQueryExecution(
StartQueryExecutionRequest.builder()
.queryString("SELECT customer, sum(amount) as total FROM " + tableName
+ " GROUP BY customer ORDER BY customer")
.queryExecutionContext(QueryExecutionContext.builder().database(dbName).build())
.workGroup("primary")
.resultConfiguration(ResultConfiguration.builder()
.outputLocation("s3://" + bucketName + "/results/")
.build())
.build());
QueryExecutionStatus statusUnqualified = TestFixtures.awaitAthenaQueryTerminal(
athena, startedUnqualified.queryExecutionId(), Duration.ofSeconds(60));
assertThat(statusUnqualified.state())
.as("Athena unqualified query did not succeed: %s", statusUnqualified.stateChangeReason())
.isEqualTo(QueryExecutionState.SUCCEEDED);
} finally {
try {
glue.deleteTable(r -> r.databaseName(dbName).name(tableName));
glue.deleteDatabase(r -> r.name(dbName));
} catch (Exception ignored) {
// Best-effort test cleanup
}
try {
s3.listObjectsV2(r -> r.bucket(bucketName)).contents().forEach(obj ->
s3.deleteObject(r -> r.bucket(bucketName).key(obj.key())));
s3.deleteBucket(r -> r.bucket(bucketName));
} catch (Exception ignored) {
// Best-effort test cleanup
}
}
}
}
/**
* github.com/floci-io/floci/issues/2791: terraform-provider-aws's read/refresh for
* aws_athena_workgroup calls ListTagsForResource unconditionally. Round-trips through the
* real AthenaClient (rather than raw JSON) to validate the request/response shapes.
*/
@Test
@Order(10)
@DisplayName("listTagsForResource returns the tags set on workgroup creation")
void listTagsForResourceReturnsTagsSetOnCreation() {
String groupName = "sdk-tags-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
athena.createWorkGroup(CreateWorkGroupRequest.builder()
.name(groupName)
.tags(software.amazon.awssdk.services.athena.model.Tag.builder()
.key("env").value("prod").build())
.build());
ListTagsForResourceResponse response = athena.listTagsForResource(
ListTagsForResourceRequest.builder()
.resourceARN("arn:aws:athena:us-east-1:000000000000:workgroup/" + groupName)
.build());
assertThat(response.tags()).hasSize(1);
assertThat(response.tags().get(0).key()).isEqualTo("env");
assertThat(response.tags().get(0).value()).isEqualTo("prod");
}
@Test
@Order(11)
@DisplayName("updateWorkGroup round-trips changes through the Athena SDK")
void updateWorkGroupRoundTripsThroughSdk() {
String groupName = "sdk-update-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
athena.createWorkGroup(CreateWorkGroupRequest.builder()
.name(groupName)
.description("before")
.configuration(WorkGroupConfiguration.builder()
.resultConfiguration(ResultConfiguration.builder()
.outputLocation("s3://before/results/")
.build())
.enforceWorkGroupConfiguration(true)
.build())
.build());
athena.updateWorkGroup(UpdateWorkGroupRequest.builder()
.workGroup(groupName)
.description("after")
.state(WorkGroupState.DISABLED)
.configurationUpdates(WorkGroupConfigurationUpdates.builder()
.resultConfigurationUpdates(ResultConfigurationUpdates.builder()
.outputLocation("s3://after/results/")
.build())
.enforceWorkGroupConfiguration(false)
.publishCloudWatchMetricsEnabled(true)
.build())
.build());
WorkGroup workGroup = athena.getWorkGroup(r -> r.workGroup(groupName)).workGroup();
assertThat(workGroup.description()).isEqualTo("after");
assertThat(workGroup.state()).isEqualTo(WorkGroupState.DISABLED);
assertThat(workGroup.configuration().resultConfiguration().outputLocation())
.isEqualTo("s3://after/results/");
assertThat(workGroup.configuration().enforceWorkGroupConfiguration()).isFalse();
assertThat(workGroup.configuration().publishCloudWatchMetricsEnabled()).isTrue();
}
}
@@ -0,0 +1,207 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.autoscaling.AutoScalingClient;
import software.amazon.awssdk.services.autoscaling.model.AttachInstancesRequest;
import software.amazon.awssdk.services.autoscaling.model.AutoScalingException;
import software.amazon.awssdk.services.autoscaling.model.CreateAutoScalingGroupRequest;
import software.amazon.awssdk.services.autoscaling.model.CreateLaunchConfigurationRequest;
import software.amazon.awssdk.services.autoscaling.model.DesiredConfiguration;
import software.amazon.awssdk.services.autoscaling.model.LaunchTemplateSpecification;
import software.amazon.awssdk.services.autoscaling.model.StartInstanceRefreshRequest;
import software.amazon.awssdk.services.autoscaling.model.UpdateAutoScalingGroupRequest;
import software.amazon.awssdk.services.ec2.Ec2Client;
import software.amazon.awssdk.services.ec2.model.CreateLaunchTemplateRequest;
import software.amazon.awssdk.services.ec2.model.CreateLaunchTemplateVersionRequest;
import software.amazon.awssdk.services.ec2.model.RequestLaunchTemplateData;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("Auto Scaling")
class AutoScalingTest {
private static final String MISSING_LAUNCH_TEMPLATE_IMAGE_ID_MESSAGE =
"You must use a valid fully-formed launch template. The request must contain the parameter ImageId";
private static final String INVALID_LAUNCH_CONFIGURATION_PARAMETERS_MESSAGE =
"Valid requests must contain either the InstanceID parameter "
+ "or both the ImageId and InstanceType parameters.";
private static final String ACTIVE_INSTANCE_REFRESH_DESIRED_CONFIGURATION_MESSAGE =
"An active instance refresh with a desired configuration exists. All configuration options derived from the desired configuration are not available for update while the instance refresh is active.";
private static AutoScalingClient autoScaling;
private static Ec2Client ec2;
@BeforeAll
static void setup() {
autoScaling = TestFixtures.autoScalingClient();
ec2 = TestFixtures.ec2Client();
}
@AfterAll
static void cleanup() {
if (autoScaling != null) {
autoScaling.close();
}
if (ec2 != null) {
ec2.close();
}
}
@Test
@DisplayName("CreateAutoScalingGroup rejects launch templates without ImageId")
void missingLaunchTemplateImageIdMapsToSdkAutoScalingException() {
String launchTemplateName = TestFixtures.uniqueName("sdk-missing-image");
String autoScalingGroupName = TestFixtures.uniqueName("sdk-missing-image-asg");
ec2.createLaunchTemplate(CreateLaunchTemplateRequest.builder()
.launchTemplateName(launchTemplateName)
.launchTemplateData(RequestLaunchTemplateData.builder()
.instanceType("t3.micro")
.build())
.build());
assertThatThrownBy(() -> autoScaling.createAutoScalingGroup(CreateAutoScalingGroupRequest.builder()
.autoScalingGroupName(autoScalingGroupName)
.launchTemplate(LaunchTemplateSpecification.builder()
.launchTemplateName(launchTemplateName)
.version("1")
.build())
.minSize(0)
.maxSize(1)
.desiredCapacity(1)
.availabilityZones("us-east-1a")
.build()))
.isInstanceOfSatisfying(AutoScalingException.class, error -> {
assertThat(error.statusCode()).isEqualTo(400);
assertThat(error.awsErrorDetails().serviceName()).isEqualTo("AutoScaling");
assertThat(error.awsErrorDetails().errorCode()).isEqualTo("ValidationError");
assertThat(error.awsErrorDetails().errorMessage())
.isEqualTo(MISSING_LAUNCH_TEMPLATE_IMAGE_ID_MESSAGE);
assertThat(error.requestId()).isNotBlank();
assertThat(error.getMessage()).contains(
MISSING_LAUNCH_TEMPLATE_IMAGE_ID_MESSAGE,
"Service: AutoScaling",
"Status Code: 400",
"Request ID: ",
"SDK Attempt Count: 1");
});
}
@Test
@DisplayName("CreateLaunchConfiguration rejects missing ImageId or InstanceType")
void missingLaunchConfigurationImageIdMapsToSdkAutoScalingExceptionAtCreateTime() {
String suffix = TestFixtures.uniqueName("sdk-lc");
assertThatThrownBy(() -> autoScaling.createLaunchConfiguration(CreateLaunchConfigurationRequest.builder()
.launchConfigurationName(suffix + "-missing-image")
.instanceType("t3.micro")
.build()))
.isInstanceOfSatisfying(AutoScalingException.class, error -> {
assertThat(error.statusCode()).isEqualTo(400);
assertThat(error.awsErrorDetails().serviceName()).isEqualTo("AutoScaling");
assertThat(error.awsErrorDetails().errorCode()).isEqualTo("ValidationError");
assertThat(error.awsErrorDetails().errorMessage())
.isEqualTo(INVALID_LAUNCH_CONFIGURATION_PARAMETERS_MESSAGE);
assertThat(error.requestId()).isNotBlank();
assertThat(error.getMessage()).contains(
INVALID_LAUNCH_CONFIGURATION_PARAMETERS_MESSAGE,
"Service: AutoScaling",
"Status Code: 400",
"Request ID: ",
"SDK Attempt Count: 1");
});
assertThatThrownBy(() -> autoScaling.createLaunchConfiguration(CreateLaunchConfigurationRequest.builder()
.launchConfigurationName(suffix + "-missing-type")
.imageId("ami-12345678")
.build()))
.isInstanceOfSatisfying(AutoScalingException.class, error -> {
assertThat(error.statusCode()).isEqualTo(400);
assertThat(error.awsErrorDetails().serviceName()).isEqualTo("AutoScaling");
assertThat(error.awsErrorDetails().errorCode()).isEqualTo("ValidationError");
assertThat(error.awsErrorDetails().errorMessage())
.isEqualTo(INVALID_LAUNCH_CONFIGURATION_PARAMETERS_MESSAGE);
});
}
@Test
@DisplayName("UpdateAutoScalingGroup rejects desired configuration changes during active instance refresh")
void activeInstanceRefreshDesiredConfigurationConflictMapsToSdkAutoScalingException() {
String launchTemplateName = TestFixtures.uniqueName("sdk-active-refresh");
String autoScalingGroupName = TestFixtures.uniqueName("sdk-active-refresh-asg");
ec2.createLaunchTemplate(CreateLaunchTemplateRequest.builder()
.launchTemplateName(launchTemplateName)
.launchTemplateData(RequestLaunchTemplateData.builder()
.imageId("ami-12345678")
.instanceType("t3.micro")
.build())
.build());
ec2.createLaunchTemplateVersion(CreateLaunchTemplateVersionRequest.builder()
.launchTemplateName(launchTemplateName)
.sourceVersion("1")
.launchTemplateData(RequestLaunchTemplateData.builder()
.imageId("ami-12345678")
.instanceType("t3.small")
.build())
.build());
ec2.createLaunchTemplateVersion(CreateLaunchTemplateVersionRequest.builder()
.launchTemplateName(launchTemplateName)
.sourceVersion("1")
.launchTemplateData(RequestLaunchTemplateData.builder()
.imageId("ami-12345678")
.instanceType("t3.medium")
.build())
.build());
autoScaling.createAutoScalingGroup(CreateAutoScalingGroupRequest.builder()
.autoScalingGroupName(autoScalingGroupName)
.launchTemplate(LaunchTemplateSpecification.builder()
.launchTemplateName(launchTemplateName)
.version("1")
.build())
.minSize(0)
.maxSize(1)
.desiredCapacity(1)
.availabilityZones("us-east-1a")
.build());
autoScaling.attachInstances(AttachInstancesRequest.builder()
.autoScalingGroupName(autoScalingGroupName)
.instanceIds("i-sdk-active-refresh")
.build());
autoScaling.startInstanceRefresh(StartInstanceRefreshRequest.builder()
.autoScalingGroupName(autoScalingGroupName)
.desiredConfiguration(DesiredConfiguration.builder()
.launchTemplate(LaunchTemplateSpecification.builder()
.launchTemplateName(launchTemplateName)
.version("2")
.build())
.build())
.build());
assertThatThrownBy(() -> autoScaling.updateAutoScalingGroup(UpdateAutoScalingGroupRequest.builder()
.autoScalingGroupName(autoScalingGroupName)
.launchTemplate(LaunchTemplateSpecification.builder()
.launchTemplateName(launchTemplateName)
.version("3")
.build())
.build()))
.isInstanceOfSatisfying(AutoScalingException.class, error -> {
assertThat(error.statusCode()).isEqualTo(400);
assertThat(error.awsErrorDetails().serviceName()).isEqualTo("AutoScaling");
assertThat(error.awsErrorDetails().errorCode()).isEqualTo("ValidationError");
assertThat(error.awsErrorDetails().errorMessage())
.isEqualTo(ACTIVE_INSTANCE_REFRESH_DESIRED_CONFIGURATION_MESSAGE);
assertThat(error.requestId()).isNotBlank();
assertThat(error.getMessage()).contains(
ACTIVE_INSTANCE_REFRESH_DESIRED_CONFIGURATION_MESSAGE,
"Service: AutoScaling",
"Status Code: 400",
"Request ID: ",
"SDK Attempt Count: 1");
});
}
}
@@ -0,0 +1,432 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.backup.BackupClient;
import software.amazon.awssdk.services.backup.model.*;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("AWS Backup")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BackupTest {
private static BackupClient backup;
private static final String VAULT_NAME = "compat-test-vault";
private static final String IAM_ROLE = "arn:aws:iam::000000000000:role/backup-role";
private static final String RESOURCE_ARN = "arn:aws:dynamodb:us-east-1:000000000000:table/my-table";
private static String vaultArn;
private static String planId;
private static String planArn;
private static String selectionId;
private static String jobId;
private static String recoveryPointArn;
@BeforeAll
static void setup() {
backup = TestFixtures.backupClient();
}
@AfterAll
static void cleanup() {
if (backup == null) return;
try { backup.deleteBackupSelection(r -> r.backupPlanId(planId).selectionId(selectionId)); } catch (Exception ignored) {}
try { backup.deleteBackupPlan(r -> r.backupPlanId(planId)); } catch (Exception ignored) {}
try {
backup.listRecoveryPointsByBackupVault(r -> r.backupVaultName(VAULT_NAME))
.recoveryPoints()
.forEach(rp -> {
try {
backup.deleteRecoveryPoint(r -> r
.backupVaultName(VAULT_NAME)
.recoveryPointArn(rp.recoveryPointArn()));
} catch (Exception ignored2) {}
});
} catch (Exception ignored) {}
try { backup.deleteBackupVault(r -> r.backupVaultName(VAULT_NAME)); } catch (Exception ignored) {}
backup.close();
}
// ── Vault ──────────────────────────────────────────────────────────────────
@Test
@Order(10)
@DisplayName("CreateBackupVault - creates vault with tags")
void createBackupVault() {
CreateBackupVaultResponse resp = backup.createBackupVault(r -> r
.backupVaultName(VAULT_NAME)
.backupVaultTags(Map.of("env", "compat-test")));
vaultArn = resp.backupVaultArn();
assertThat(resp.backupVaultName()).isEqualTo(VAULT_NAME);
assertThat(vaultArn).contains("backup-vault:" + VAULT_NAME);
assertThat(resp.creationDate()).isNotNull();
}
@Test
@Order(11)
@DisplayName("CreateBackupVault - duplicate returns AlreadyExistsException")
void createVaultDuplicateFails() {
assertThatThrownBy(() -> backup.createBackupVault(r -> r.backupVaultName(VAULT_NAME)))
.isInstanceOf(AlreadyExistsException.class);
}
@Test
@Order(12)
@DisplayName("DescribeBackupVault - returns vault metadata")
void describeBackupVault() {
DescribeBackupVaultResponse resp = backup.describeBackupVault(r -> r.backupVaultName(VAULT_NAME));
assertThat(resp.backupVaultName()).isEqualTo(VAULT_NAME);
assertThat(resp.backupVaultArn()).isEqualTo(vaultArn);
assertThat(resp.numberOfRecoveryPoints()).isEqualTo(0);
assertThat(resp.creationDate()).isNotNull();
}
@Test
@Order(13)
@DisplayName("ListBackupVaults - includes created vault")
void listBackupVaults() {
ListBackupVaultsResponse resp = backup.listBackupVaults(r -> r.build());
assertThat(resp.backupVaultList()).isNotEmpty();
assertThat(resp.backupVaultList())
.anyMatch(v -> VAULT_NAME.equals(v.backupVaultName()));
}
@Test
@Order(14)
@DisplayName("DescribeBackupVault - non-existent returns ResourceNotFoundException")
void describeNonExistentVaultFails() {
assertThatThrownBy(() -> backup.describeBackupVault(r -> r.backupVaultName("no-such-vault")))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── Plan ───────────────────────────────────────────────────────────────────
@Test
@Order(20)
@DisplayName("CreateBackupPlan - creates plan with rules")
void createBackupPlan() {
CreateBackupPlanResponse resp = backup.createBackupPlan(r -> r
.backupPlan(p -> p
.backupPlanName("compat-daily")
.rules(BackupRuleInput.builder()
.ruleName("daily")
.targetBackupVaultName(VAULT_NAME)
.scheduleExpression("cron(0 12 * * ? *)")
.startWindowMinutes(60L)
.completionWindowMinutes(120L)
.build())));
planId = resp.backupPlanId();
planArn = resp.backupPlanArn();
assertThat(planId).isNotNull();
assertThat(planArn).contains("backup-plan:");
assertThat(resp.versionId()).isNotNull();
assertThat(resp.creationDate()).isNotNull();
}
@Test
@Order(21)
@DisplayName("GetBackupPlan - returns plan with rules")
void getBackupPlan() {
GetBackupPlanResponse resp = backup.getBackupPlan(r -> r.backupPlanId(planId));
assertThat(resp.backupPlanId()).isEqualTo(planId);
assertThat(resp.backupPlan().backupPlanName()).isEqualTo("compat-daily");
assertThat(resp.backupPlan().rules()).hasSize(1);
assertThat(resp.backupPlan().rules().get(0).ruleName()).isEqualTo("daily");
assertThat(resp.backupPlan().rules().get(0).ruleId()).isNotNull();
}
@Test
@Order(22)
@DisplayName("UpdateBackupPlan - replaces rules and bumps versionId")
void updateBackupPlan() {
String oldVersionId = backup.getBackupPlan(r -> r.backupPlanId(planId)).versionId();
UpdateBackupPlanResponse resp = backup.updateBackupPlan(r -> r
.backupPlanId(planId)
.backupPlan(p -> p
.backupPlanName("compat-daily-v2")
.rules(BackupRuleInput.builder()
.ruleName("daily-v2")
.targetBackupVaultName(VAULT_NAME)
.scheduleExpression("cron(0 6 * * ? *)")
.build())));
assertThat(resp.backupPlanId()).isEqualTo(planId);
assertThat(resp.versionId()).isNotEqualTo(oldVersionId);
}
@Test
@Order(23)
@DisplayName("ListBackupPlans - includes created plan")
void listBackupPlans() {
ListBackupPlansResponse resp = backup.listBackupPlans(r -> r.build());
assertThat(resp.backupPlansList()).isNotEmpty();
assertThat(resp.backupPlansList())
.anyMatch(p -> planId.equals(p.backupPlanId()));
}
// ── Selection ──────────────────────────────────────────────────────────────
@Test
@Order(30)
@DisplayName("CreateBackupSelection - creates selection")
void createBackupSelection() {
CreateBackupSelectionResponse resp = backup.createBackupSelection(r -> r
.backupPlanId(planId)
.backupSelection(s -> s
.selectionName("compat-selection")
.iamRoleArn(IAM_ROLE)
.resources(RESOURCE_ARN)));
selectionId = resp.selectionId();
assertThat(selectionId).isNotNull();
assertThat(resp.backupPlanId()).isEqualTo(planId);
assertThat(resp.creationDate()).isNotNull();
}
@Test
@Order(31)
@DisplayName("GetBackupSelection - returns selection detail")
void getBackupSelection() {
GetBackupSelectionResponse resp = backup.getBackupSelection(r -> r
.backupPlanId(planId)
.selectionId(selectionId));
assertThat(resp.selectionId()).isEqualTo(selectionId);
assertThat(resp.backupSelection().selectionName()).isEqualTo("compat-selection");
assertThat(resp.backupSelection().iamRoleArn()).isEqualTo(IAM_ROLE);
assertThat(resp.backupSelection().resources()).contains(RESOURCE_ARN);
}
@Test
@Order(32)
@DisplayName("ListBackupSelections - includes created selection")
void listBackupSelections() {
ListBackupSelectionsResponse resp = backup.listBackupSelections(r -> r.backupPlanId(planId));
assertThat(resp.backupSelectionsList()).hasSize(1);
assertThat(resp.backupSelectionsList().get(0).selectionId()).isEqualTo(selectionId);
assertThat(resp.backupSelectionsList().get(0).selectionName()).isEqualTo("compat-selection");
}
@Test
@Order(33)
@DisplayName("DeleteBackupPlan with active selection returns InvalidRequestException")
void deletePlanWithSelectionFails() {
assertThatThrownBy(() -> backup.deleteBackupPlan(r -> r.backupPlanId(planId)))
.isInstanceOf(InvalidRequestException.class);
}
// ── Job ────────────────────────────────────────────────────────────────────
@Test
@Order(40)
@DisplayName("StartBackupJob - returns job ID in CREATED state")
void startBackupJob() {
StartBackupJobResponse resp = backup.startBackupJob(r -> r
.backupVaultName(VAULT_NAME)
.resourceArn(RESOURCE_ARN)
.iamRoleArn(IAM_ROLE));
jobId = resp.backupJobId();
assertThat(jobId).isNotNull();
assertThat(resp.creationDate()).isNotNull();
}
@Test
@Order(41)
@DisplayName("DescribeBackupJob - job is in progress shortly after start")
void describeBackupJobInProgress() {
DescribeBackupJobResponse resp = backup.describeBackupJob(r -> r.backupJobId(jobId));
assertThat(resp.backupJobId()).isEqualTo(jobId);
assertThat(resp.state().toString()).isIn("CREATED", "RUNNING", "COMPLETED");
assertThat(resp.backupVaultName()).isEqualTo(VAULT_NAME);
assertThat(resp.resourceArn()).isEqualTo(RESOURCE_ARN);
}
@Test
@Order(42)
@DisplayName("DescribeBackupJob - job completes within 10 seconds")
void describeBackupJobCompleted() throws InterruptedException {
BackupJobState state = null;
for (int i = 0; i < 10; i++) {
Thread.sleep(1000);
DescribeBackupJobResponse resp = backup.describeBackupJob(r -> r.backupJobId(jobId));
state = resp.state();
if (state == BackupJobState.COMPLETED) {
recoveryPointArn = resp.recoveryPointArn();
assertThat(recoveryPointArn).contains("recovery-point:");
assertThat(resp.completionDate()).isNotNull();
return;
}
}
Assertions.fail("Backup job did not complete within 10 seconds, last state: " + state);
}
@Test
@Order(43)
@DisplayName("ListBackupJobs - filter by vault name")
void listBackupJobsByVault() {
ListBackupJobsResponse resp = backup.listBackupJobs(r -> r.byBackupVaultName(VAULT_NAME));
assertThat(resp.backupJobs()).isNotEmpty();
assertThat(resp.backupJobs()).allMatch(j -> VAULT_NAME.equals(j.backupVaultName()));
}
@Test
@Order(44)
@DisplayName("ListBackupJobs - filter by COMPLETED state")
void listBackupJobsByState() {
ListBackupJobsResponse resp = backup.listBackupJobs(r -> r.byState(BackupJobState.COMPLETED));
assertThat(resp.backupJobs()).isNotEmpty();
assertThat(resp.backupJobs()).allMatch(j -> j.state() == BackupJobState.COMPLETED);
}
// ── Recovery Point ─────────────────────────────────────────────────────────
@Test
@Order(50)
@DisplayName("DescribeRecoveryPoint - returns completed recovery point")
void describeRecoveryPoint() {
DescribeRecoveryPointResponse resp = backup.describeRecoveryPoint(r -> r
.backupVaultName(VAULT_NAME)
.recoveryPointArn(recoveryPointArn));
assertThat(resp.recoveryPointArn()).isEqualTo(recoveryPointArn);
assertThat(resp.backupVaultName()).isEqualTo(VAULT_NAME);
assertThat(resp.status()).isEqualTo(RecoveryPointStatus.COMPLETED);
assertThat(resp.resourceArn()).isEqualTo(RESOURCE_ARN);
assertThat(resp.completionDate()).isNotNull();
}
@Test
@Order(51)
@DisplayName("ListRecoveryPointsByBackupVault - returns created recovery point")
void listRecoveryPointsByBackupVault() {
ListRecoveryPointsByBackupVaultResponse resp = backup.listRecoveryPointsByBackupVault(r -> r
.backupVaultName(VAULT_NAME));
assertThat(resp.recoveryPoints()).hasSize(1);
assertThat(resp.recoveryPoints().get(0).recoveryPointArn()).isEqualTo(recoveryPointArn);
}
@Test
@Order(52)
@DisplayName("DescribeBackupVault - NumberOfRecoveryPoints incremented")
void vaultCountAfterJob() {
DescribeBackupVaultResponse resp = backup.describeBackupVault(r -> r.backupVaultName(VAULT_NAME));
assertThat(resp.numberOfRecoveryPoints()).isEqualTo(1);
}
@Test
@Order(53)
@DisplayName("DeleteBackupVault - non-empty vault returns InvalidRequestException")
void deleteNonEmptyVaultFails() {
assertThatThrownBy(() -> backup.deleteBackupVault(r -> r.backupVaultName(VAULT_NAME)))
.isInstanceOf(InvalidRequestException.class);
}
@Test
@Order(54)
@DisplayName("DeleteRecoveryPoint - removes recovery point and decrements vault count")
void deleteRecoveryPoint() {
backup.deleteRecoveryPoint(r -> r
.backupVaultName(VAULT_NAME)
.recoveryPointArn(recoveryPointArn));
DescribeBackupVaultResponse vault = backup.describeBackupVault(r -> r.backupVaultName(VAULT_NAME));
assertThat(vault.numberOfRecoveryPoints()).isEqualTo(0);
assertThatThrownBy(() -> backup.describeRecoveryPoint(r -> r
.backupVaultName(VAULT_NAME)
.recoveryPointArn(recoveryPointArn)))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── Tagging ────────────────────────────────────────────────────────────────
@Test
@Order(60)
@DisplayName("TagResource / ListTags / UntagResource - SDK round-trip")
void tagRoundTrip() {
backup.tagResource(r -> r
.resourceArn(vaultArn)
.tags(Map.of("team", "platform", "cost-center", "eng")));
ListTagsResponse listed = backup.listTags(r -> r.resourceArn(vaultArn));
assertThat(listed.tags())
.containsEntry("env", "compat-test")
.containsEntry("team", "platform")
.containsEntry("cost-center", "eng");
backup.untagResource(r -> r
.resourceArn(vaultArn)
.tagKeyList(List.of("team")));
ListTagsResponse afterUntag = backup.listTags(r -> r.resourceArn(vaultArn));
assertThat(afterUntag.tags())
.doesNotContainKey("team")
.containsEntry("cost-center", "eng")
.containsEntry("env", "compat-test");
}
// ── Supported Resource Types ────────────────────────────────────────────────
@Test
@Order(70)
@DisplayName("GetSupportedResourceTypes - returns non-empty list including S3 and DynamoDB")
void getSupportedResourceTypes() {
GetSupportedResourceTypesResponse resp = backup.getSupportedResourceTypes(
GetSupportedResourceTypesRequest.builder().build());
assertThat(resp.resourceTypes()).isNotEmpty();
assertThat(resp.resourceTypes()).contains("S3", "DynamoDB");
}
// ── Teardown ───────────────────────────────────────────────────────────────
@Test
@Order(80)
@DisplayName("DeleteBackupSelection - removes selection")
void deleteBackupSelection() {
backup.deleteBackupSelection(r -> r
.backupPlanId(planId)
.selectionId(selectionId));
assertThat(backup.listBackupSelections(r -> r.backupPlanId(planId))
.backupSelectionsList()).isEmpty();
}
@Test
@Order(81)
@DisplayName("DeleteBackupPlan - removes plan")
void deleteBackupPlan() {
backup.deleteBackupPlan(r -> r.backupPlanId(planId));
assertThatThrownBy(() -> backup.getBackupPlan(r -> r.backupPlanId(planId)))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
@Order(82)
@DisplayName("DeleteBackupVault - removes empty vault")
void deleteBackupVault() {
backup.deleteBackupVault(r -> r.backupVaultName(VAULT_NAME));
assertThatThrownBy(() -> backup.describeBackupVault(r -> r.backupVaultName(VAULT_NAME)))
.isInstanceOf(ResourceNotFoundException.class);
}
}
@@ -0,0 +1,53 @@
package com.floci.test;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.bcmpricingcalculator.BcmPricingCalculatorClient;
import software.amazon.awssdk.services.bcmpricingcalculator.model.BatchCreateWorkloadEstimateUsageEntry;
import software.amazon.awssdk.services.bcmpricingcalculator.model.BatchCreateWorkloadEstimateUsageRequest;
import software.amazon.awssdk.services.bcmpricingcalculator.model.CreateWorkloadEstimateRequest;
import software.amazon.awssdk.services.bcmpricingcalculator.model.DeleteWorkloadEstimateRequest;
import software.amazon.awssdk.services.bcmpricingcalculator.model.GetWorkloadEstimateRequest;
import software.amazon.awssdk.services.bcmpricingcalculator.model.WorkloadEstimateRateType;
import software.amazon.awssdk.services.bcmpricingcalculator.model.WorkloadEstimateStatus;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class BcmPricingCalculatorTest {
@Test
void workloadEstimateLifecycle() {
try (BcmPricingCalculatorClient client = TestFixtures.bcmPricingCalculatorClient()) {
var created = client.createWorkloadEstimate(CreateWorkloadEstimateRequest.builder()
.name("sdk-estimate")
.clientToken("sdk-estimate-token")
.rateType(WorkloadEstimateRateType.BEFORE_DISCOUNTS)
.build());
assertNotNull(created.id());
var batch = client.batchCreateWorkloadEstimateUsage(BatchCreateWorkloadEstimateUsageRequest.builder()
.workloadEstimateId(created.id())
.clientToken("sdk-usage-token")
.usage(List.of(BatchCreateWorkloadEstimateUsageEntry.builder()
.serviceCode("AmazonEC2")
.usageType("BoxUsage:t3.micro")
.operation("")
.key("sdk1")
.usageAccountId("000000000000")
.group("compute")
.amount(730d)
.build()))
.build());
assertTrue(batch.errors().isEmpty());
assertEquals(1, batch.items().size());
var estimate = client.getWorkloadEstimate(GetWorkloadEstimateRequest.builder()
.identifier(created.id()).build());
assertEquals(WorkloadEstimateStatus.VALID, estimate.status());
assertEquals(7.592d, estimate.totalCost(), 0.000001d);
client.deleteWorkloadEstimate(DeleteWorkloadEstimateRequest.builder()
.identifier(created.id()).build());
}
}
}
@@ -0,0 +1,138 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.bedrockagentcorecontrol.BedrockAgentCoreControlClient;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.*;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
@DisplayName("Bedrock AgentCore Control")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BedrockAgentCoreControlTest {
private static final String ACCOUNT_ID = "000000000000";
private static final String ROLE_ARN = "arn:aws:iam::" + ACCOUNT_ID + ":role/agent-runtime";
private static BedrockAgentCoreControlClient client;
private static String runtimeName;
private static String runtimeId;
@BeforeAll
static void setup() {
client = TestFixtures.bedrockAgentCoreControlClient();
// AgentCore names must match [a-zA-Z][a-zA-Z0-9_]{0,47} — no hyphens.
runtimeName = "itAgent" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
@AfterAll
static void cleanup() {
if (client != null) {
try {
client.deleteAgentRuntime(DeleteAgentRuntimeRequest.builder()
.agentRuntimeId(runtimeId).build());
} catch (Exception ignored) {
}
client.close();
}
}
private static AgentRuntimeArtifact artifact(String uri) {
return AgentRuntimeArtifact.builder()
.containerConfiguration(ContainerConfiguration.builder().containerUri(uri).build())
.build();
}
private static NetworkConfiguration network() {
return NetworkConfiguration.builder().networkMode(NetworkMode.PUBLIC).build();
}
@Test
@Order(1)
void createAgentRuntime() {
CreateAgentRuntimeResponse response = client.createAgentRuntime(CreateAgentRuntimeRequest.builder()
.agentRuntimeName(runtimeName)
.agentRuntimeArtifact(artifact("public.ecr.aws/x/agent:latest"))
.networkConfiguration(network())
.roleArn(ROLE_ARN)
.description("v1")
.build());
runtimeId = response.agentRuntimeId();
assertThat(runtimeId).startsWith(runtimeName + "-");
assertThat(response.agentRuntimeVersion()).isEqualTo("1");
assertThat(response.statusAsString()).isEqualTo("READY");
assertThat(response.agentRuntimeArn()).contains(":bedrock-agentcore:").contains(":agent/");
assertThat(response.workloadIdentityDetails().workloadIdentityArn()).isNotBlank();
}
@Test
@Order(2)
void getAgentRuntime() {
GetAgentRuntimeResponse response = client.getAgentRuntime(GetAgentRuntimeRequest.builder()
.agentRuntimeId(runtimeId).build());
assertThat(response.agentRuntimeName()).isEqualTo(runtimeName);
assertThat(response.agentRuntimeVersion()).isEqualTo("1");
assertThat(response.description()).isEqualTo("v1");
}
@Test
@Order(3)
void listAgentRuntimes() {
ListAgentRuntimesResponse response = client.listAgentRuntimes(ListAgentRuntimesRequest.builder().build());
assertThat(response.agentRuntimes())
.anyMatch(r -> runtimeId.equals(r.agentRuntimeId()));
}
@Test
@Order(4)
void updateAgentRuntimeBumpsVersion() {
UpdateAgentRuntimeResponse response = client.updateAgentRuntime(UpdateAgentRuntimeRequest.builder()
.agentRuntimeId(runtimeId)
.agentRuntimeArtifact(artifact("public.ecr.aws/x/agent:v2"))
.networkConfiguration(network())
.roleArn(ROLE_ARN)
.description("v2")
.build());
assertThat(response.agentRuntimeVersion()).isEqualTo("2");
}
@Test
@Order(5)
void getSpecificVersionReturnsSnapshot() {
GetAgentRuntimeResponse response = client.getAgentRuntime(GetAgentRuntimeRequest.builder()
.agentRuntimeId(runtimeId)
.agentRuntimeVersion("1")
.build());
assertThat(response.agentRuntimeVersion()).isEqualTo("1");
assertThat(response.description()).isEqualTo("v1");
}
@Test
@Order(6)
void deleteAgentRuntime() {
DeleteAgentRuntimeResponse response = client.deleteAgentRuntime(DeleteAgentRuntimeRequest.builder()
.agentRuntimeId(runtimeId).build());
assertThat(response.statusAsString()).isEqualTo("DELETING");
assertThatThrownBy(() -> client.getAgentRuntime(GetAgentRuntimeRequest.builder()
.agentRuntimeId(runtimeId).build()))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
@Order(7)
void createWithInvalidNameThrowsValidation() {
assertThatThrownBy(() -> client.createAgentRuntime(CreateAgentRuntimeRequest.builder()
.agentRuntimeName("1-invalid-name")
.agentRuntimeArtifact(artifact("x:latest"))
.networkConfiguration(network())
.roleArn(ROLE_ARN)
.build()))
.isInstanceOf(ValidationException.class);
}
}
@@ -0,0 +1,171 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.junit.jupiter.api.MethodOrderer;
import software.amazon.awssdk.services.bedrockagentcorecontrol.BedrockAgentCoreControlClient;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.CreateApiKeyCredentialProviderRequest;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.CreateApiKeyCredentialProviderResponse;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.CreateOauth2CredentialProviderRequest;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.CreateOauth2CredentialProviderResponse;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.CredentialProviderVendorType;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.Oauth2ProviderConfigInput;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.SecretSourceType;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Bedrock AgentCore credential providers")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BedrockAgentCoreCredentialProviderTest {
private static BedrockAgentCoreControlClient client;
private static String apiKeyProviderName;
private static String oauth2ProviderName;
@BeforeAll
static void setup() {
client = TestFixtures.bedrockAgentCoreControlClient();
apiKeyProviderName = "api_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
oauth2ProviderName = "oauth_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
@AfterAll
static void cleanup() {
if (client != null) {
client.close();
}
}
@Test
@Order(1)
void createApiKeyCredentialProvider() {
CreateApiKeyCredentialProviderResponse response = client.createApiKeyCredentialProvider(
CreateApiKeyCredentialProviderRequest.builder()
.name(apiKeyProviderName)
.apiKey("secret-value")
.apiKeySecretSource(SecretSourceType.MANAGED)
.build());
assertThat(response.name()).isEqualTo(apiKeyProviderName);
assertThat(response.apiKeySecretSourceAsString()).isEqualTo("MANAGED");
assertThat(response.credentialProviderArn()).contains(":acps:").contains("/apikeycredentialprovider/");
assertThat(response.apiKeySecretArn().secretArn()).contains(":secretsmanager:");
assertThat(response.apiKeySecretJsonKey()).isEqualTo("apiKey");
}
@Test
@Order(2)
void getApiKeyCredentialProvider() {
var response = client.getApiKeyCredentialProvider(builder -> builder.name(apiKeyProviderName));
assertThat(response.name()).isEqualTo(apiKeyProviderName);
assertThat(response.apiKeySecretSourceAsString()).isEqualTo("MANAGED");
assertThat(response.credentialProviderArn()).contains("/apikeycredentialprovider/");
assertThat(response.createdTime()).isNotNull();
assertThat(response.lastUpdatedTime()).isNotNull();
}
@Test
@Order(3)
void listApiKeyCredentialProviders() {
var response = client.listApiKeyCredentialProviders(builder -> builder.maxResults(100));
assertThat(response.credentialProviders())
.anyMatch(provider -> apiKeyProviderName.equals(provider.name()));
}
@Test
@Order(4)
void updateApiKeyCredentialProvider() {
var response = client.updateApiKeyCredentialProvider(builder -> builder
.name(apiKeyProviderName)
.apiKey("rotated-value")
.apiKeySecretSource(SecretSourceType.MANAGED));
assertThat(response.name()).isEqualTo(apiKeyProviderName);
assertThat(response.apiKeySecretSourceAsString()).isEqualTo("MANAGED");
assertThat(response.lastUpdatedTime()).isNotNull();
}
@Test
@Order(5)
void deleteApiKeyCredentialProvider() {
var response = client.deleteApiKeyCredentialProvider(builder -> builder.name(apiKeyProviderName));
assertThat(response.sdkHttpResponse().statusCode()).isEqualTo(204);
}
@Test
@Order(6)
void createOauth2CredentialProvider() {
CreateOauth2CredentialProviderResponse response = client.createOauth2CredentialProvider(
CreateOauth2CredentialProviderRequest.builder()
.name(oauth2ProviderName)
.credentialProviderVendor(CredentialProviderVendorType.GITHUB_OAUTH2)
.oauth2ProviderConfigInput(Oauth2ProviderConfigInput.fromGithubOauth2ProviderConfig(builder -> builder
.clientId("client-id")
.clientSecret("client-value")
.clientSecretSource(SecretSourceType.MANAGED)))
.build());
assertThat(response.name()).isEqualTo(oauth2ProviderName);
assertThat(response.credentialProviderArn()).contains("/oauth2credentialprovider/");
assertThat(response.clientSecretSourceAsString()).isEqualTo("MANAGED");
assertThat(response.statusAsString()).isEqualTo("READY");
assertThat(response.oauth2ProviderConfigOutput().githubOauth2ProviderConfig().clientId()).isEqualTo("client-id");
}
@Test
@Order(7)
void getOauth2CredentialProvider() {
var response = client.getOauth2CredentialProvider(builder -> builder.name(oauth2ProviderName));
assertThat(response.name()).isEqualTo(oauth2ProviderName);
assertThat(response.credentialProviderVendorAsString()).isEqualTo("GithubOauth2");
assertThat(response.statusAsString()).isEqualTo("READY");
assertThat(response.createdTime()).isNotNull();
assertThat(response.lastUpdatedTime()).isNotNull();
assertThat(response.oauth2ProviderConfigOutput().githubOauth2ProviderConfig().clientId()).isEqualTo("client-id");
}
@Test
@Order(8)
void listOauth2CredentialProviders() {
var response = client.listOauth2CredentialProviders(builder -> builder.maxResults(20));
assertThat(response.credentialProviders())
.anyMatch(provider -> oauth2ProviderName.equals(provider.name())
&& "GithubOauth2".equals(provider.credentialProviderVendorAsString()));
}
@Test
@Order(9)
void updateOauth2CredentialProvider() {
var response = client.updateOauth2CredentialProvider(builder -> builder
.name(oauth2ProviderName)
.credentialProviderVendor(CredentialProviderVendorType.GITHUB_OAUTH2)
.oauth2ProviderConfigInput(Oauth2ProviderConfigInput.fromGithubOauth2ProviderConfig(config -> config
.clientId("updated-client-id")
.clientSecret("updated-client-secret")
.clientSecretSource(SecretSourceType.MANAGED))));
assertThat(response.name()).isEqualTo(oauth2ProviderName);
assertThat(response.statusAsString()).isEqualTo("READY");
assertThat(response.credentialProviderVendorAsString()).isEqualTo("GithubOauth2");
assertThat(response.oauth2ProviderConfigOutput().githubOauth2ProviderConfig().clientId())
.isEqualTo("updated-client-id");
assertThat(response.lastUpdatedTime()).isNotNull();
}
@Test
@Order(10)
void deleteOauth2CredentialProvider() {
var response = client.deleteOauth2CredentialProvider(builder -> builder.name(oauth2ProviderName));
assertThat(response.sdkHttpResponse().statusCode()).isEqualTo(204);
}
}
@@ -0,0 +1,116 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.bedrockagentcorecontrol.BedrockAgentCoreControlClient;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.*;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
@DisplayName("Bedrock AgentCore Runtime Endpoints")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BedrockAgentCoreEndpointTest {
private static final String ROLE_ARN = "arn:aws:iam::000000000000:role/agent-runtime";
private static BedrockAgentCoreControlClient client;
private static String runtimeId;
@BeforeAll
static void setup() {
client = TestFixtures.bedrockAgentCoreControlClient();
String name = "epAgent" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
runtimeId = client.createAgentRuntime(CreateAgentRuntimeRequest.builder()
.agentRuntimeName(name)
.agentRuntimeArtifact(AgentRuntimeArtifact.builder()
.containerConfiguration(ContainerConfiguration.builder()
.containerUri("public.ecr.aws/x/agent:latest").build())
.build())
.networkConfiguration(NetworkConfiguration.builder().networkMode(NetworkMode.PUBLIC).build())
.roleArn(ROLE_ARN)
.build()).agentRuntimeId();
}
@AfterAll
static void cleanup() {
if (client != null) {
try {
client.deleteAgentRuntime(DeleteAgentRuntimeRequest.builder()
.agentRuntimeId(runtimeId).build());
} catch (Exception ignored) {
}
client.close();
}
}
@Test
@Order(1)
void defaultEndpointExists() {
ListAgentRuntimeEndpointsResponse response = client.listAgentRuntimeEndpoints(
ListAgentRuntimeEndpointsRequest.builder().agentRuntimeId(runtimeId).build());
assertThat(response.runtimeEndpoints())
.anyMatch(e -> "DEFAULT".equals(e.name()));
}
@Test
@Order(2)
void createEndpoint() {
CreateAgentRuntimeEndpointResponse response = client.createAgentRuntimeEndpoint(
CreateAgentRuntimeEndpointRequest.builder()
.agentRuntimeId(runtimeId)
.name("prod")
.agentRuntimeVersion("1")
.description("prod endpoint")
.build());
assertThat(response.endpointName()).isEqualTo("prod");
assertThat(response.targetVersion()).isEqualTo("1");
assertThat(response.statusAsString()).isEqualTo("READY");
assertThat(response.agentRuntimeEndpointArn()).contains(":agentEndpoint/");
}
@Test
@Order(3)
void getEndpoint() {
GetAgentRuntimeEndpointResponse response = client.getAgentRuntimeEndpoint(
GetAgentRuntimeEndpointRequest.builder()
.agentRuntimeId(runtimeId).endpointName("prod").build());
assertThat(response.name()).isEqualTo("prod");
assertThat(response.targetVersion()).isEqualTo("1");
assertThat(response.description()).isEqualTo("prod endpoint");
}
@Test
@Order(4)
void listEndpoints() {
ListAgentRuntimeEndpointsResponse response = client.listAgentRuntimeEndpoints(
ListAgentRuntimeEndpointsRequest.builder().agentRuntimeId(runtimeId).build());
assertThat(response.runtimeEndpoints()).extracting(AgentRuntimeEndpoint::name)
.contains("DEFAULT", "prod");
}
@Test
@Order(5)
void updateEndpoint() {
client.updateAgentRuntimeEndpoint(UpdateAgentRuntimeEndpointRequest.builder()
.agentRuntimeId(runtimeId).endpointName("prod").description("prod v2").build());
GetAgentRuntimeEndpointResponse response = client.getAgentRuntimeEndpoint(
GetAgentRuntimeEndpointRequest.builder()
.agentRuntimeId(runtimeId).endpointName("prod").build());
assertThat(response.description()).isEqualTo("prod v2");
}
@Test
@Order(6)
void deleteEndpoint() {
DeleteAgentRuntimeEndpointResponse response = client.deleteAgentRuntimeEndpoint(
DeleteAgentRuntimeEndpointRequest.builder()
.agentRuntimeId(runtimeId).endpointName("prod").build());
assertThat(response.statusAsString()).isEqualTo("DELETING");
assertThatThrownBy(() -> client.getAgentRuntimeEndpoint(GetAgentRuntimeEndpointRequest.builder()
.agentRuntimeId(runtimeId).endpointName("prod").build()))
.isInstanceOf(ResourceNotFoundException.class);
}
}
@@ -0,0 +1,146 @@
package com.floci.test;
import org.jboss.logging.Logger;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.bedrockagentcorecontrol.BedrockAgentCoreControlClient;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.*;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("Bedrock AgentCore gateway rules")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BedrockAgentCoreGatewayRuleTest {
private static final Logger LOG = Logger.getLogger(BedrockAgentCoreGatewayRuleTest.class);
private static final String ROLE_ARN = "arn:aws:iam::000000000000:role/gw";
private static BedrockAgentCoreControlClient client;
private static String gatewayId;
private static String ruleId;
@BeforeAll
static void setup() {
client = TestFixtures.bedrockAgentCoreControlClient();
String name = "gw" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
gatewayId = client.createGateway(CreateGatewayRequest.builder()
.name(name)
.protocolType(GatewayProtocolType.MCP)
.authorizerType(AuthorizerType.AWS_IAM)
.roleArn(ROLE_ARN)
.build()).gatewayId();
}
@AfterAll
static void cleanup() {
if (client != null) {
try {
client.deleteGateway(DeleteGatewayRequest.builder().gatewayIdentifier(gatewayId).build());
} catch (Exception e) {
LOG.warnf(e, "Failed to delete AgentCore gateway %s during test cleanup", gatewayId);
}
client.close();
}
}
@Test
@Order(1)
void createGatewayRule() {
Action action = Action.fromRouteToTarget(RouteToTargetAction.fromStaticRoute(
StaticRoute.builder().targetName("target").build()));
CreateGatewayRuleResponse response = client.createGatewayRule(CreateGatewayRuleRequest.builder()
.gatewayIdentifier(gatewayId)
.priority(1)
.actions(action)
.description("route")
.build());
ruleId = response.ruleId();
assertThat(ruleId).matches("[0-9a-f-]{36}");
assertThat(response.gatewayArn()).contains(":gateway/");
assertThat(response.statusAsString()).isEqualTo("ACTIVE");
assertThat(response.priority()).isEqualTo(1);
assertThat(response.actions()).hasSize(1);
}
@Test
@Order(2)
void getGatewayRule() {
GetGatewayRuleResponse response = client.getGatewayRule(GetGatewayRuleRequest.builder()
.gatewayIdentifier(gatewayId)
.ruleId(ruleId)
.build());
assertThat(response.ruleId()).isEqualTo(ruleId);
assertThat(response.priority()).isEqualTo(1);
assertThat(response.description()).isEqualTo("route");
assertThat(response.statusAsString()).isEqualTo("ACTIVE");
assertThat(response.actions()).hasSize(1);
}
@Test
@Order(3)
void listGatewayRules() {
ListGatewayRulesResponse response = client.listGatewayRules(ListGatewayRulesRequest.builder()
.gatewayIdentifier(gatewayId)
.maxResults(100)
.build());
assertThat(response.gatewayRules())
.anyMatch(rule -> ruleId.equals(rule.ruleId()));
}
@Test
@Order(4)
void updateGatewayRule() {
Action action = Action.fromRouteToTarget(RouteToTargetAction.fromStaticRoute(
StaticRoute.builder().targetName("updated-target").build()));
UpdateGatewayRuleResponse response = client.updateGatewayRule(UpdateGatewayRuleRequest.builder()
.gatewayIdentifier(gatewayId)
.ruleId(ruleId)
.priority(2)
.actions(action)
.description("updated route")
.build());
assertThat(response.ruleId()).isEqualTo(ruleId);
assertThat(response.priority()).isEqualTo(2);
assertThat(response.description()).isEqualTo("updated route");
assertThat(response.actions()).hasSize(1);
assertThat(response.statusAsString()).isEqualTo("ACTIVE");
assertThat(response.updatedAt()).isNotNull();
}
@Test
@Order(5)
void createGatewayRuleRejectsInvalidNestedAction() {
Action invalidAction = Action.fromRouteToTarget(RouteToTargetAction.fromStaticRoute(
StaticRoute.builder().build()));
assertThatThrownBy(() -> client.createGatewayRule(CreateGatewayRuleRequest.builder()
.gatewayIdentifier(gatewayId)
.priority(3)
.actions(invalidAction)
.build()))
.isInstanceOf(ValidationException.class);
}
@Test
@Order(6)
void deleteGatewayRule() {
DeleteGatewayRuleResponse response = client.deleteGatewayRule(DeleteGatewayRuleRequest.builder()
.gatewayIdentifier(gatewayId)
.ruleId(ruleId)
.build());
assertThat(response.ruleId()).isEqualTo(ruleId);
assertThat(response.statusAsString()).isEqualTo("DELETING");
}
}
@@ -0,0 +1,81 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.bedrockagentcorecontrol.BedrockAgentCoreControlClient;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.*;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
@DisplayName("Bedrock AgentCore Gateway")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BedrockAgentCoreGatewayTest {
private static final String ROLE_ARN = "arn:aws:iam::000000000000:role/gw";
private static BedrockAgentCoreControlClient client;
private static String gatewayName;
private static String gatewayId;
@BeforeAll
static void setup() {
client = TestFixtures.bedrockAgentCoreControlClient();
gatewayName = "gw" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
@AfterAll
static void cleanup() {
if (client != null) {
try {
client.deleteGateway(DeleteGatewayRequest.builder().gatewayIdentifier(gatewayId).build());
} catch (Exception ignored) {
}
client.close();
}
}
@Test
@Order(1)
void createGateway() {
CreateGatewayResponse response = client.createGateway(CreateGatewayRequest.builder()
.name(gatewayName)
.protocolType(GatewayProtocolType.MCP)
.authorizerType(AuthorizerType.AWS_IAM)
.roleArn(ROLE_ARN)
.build());
gatewayId = response.gatewayId();
assertThat(gatewayId).isNotBlank();
assertThat(response.gatewayArn()).contains(":gateway/");
assertThat(response.gatewayUrl()).contains(gatewayId);
assertThat(response.statusAsString()).isEqualTo("READY");
}
@Test
@Order(2)
void getGateway() {
GetGatewayResponse response = client.getGateway(GetGatewayRequest.builder()
.gatewayIdentifier(gatewayId).build());
assertThat(response.name()).isEqualTo(gatewayName);
assertThat(response.protocolTypeAsString()).isEqualTo("MCP");
}
@Test
@Order(3)
void listGateways() {
ListGatewaysResponse response = client.listGateways(ListGatewaysRequest.builder().build());
assertThat(response.items()).extracting(GatewaySummary::gatewayId).contains(gatewayId);
}
@Test
@Order(4)
void deleteGateway() {
DeleteGatewayResponse response = client.deleteGateway(DeleteGatewayRequest.builder()
.gatewayIdentifier(gatewayId).build());
assertThat(response.statusAsString()).isEqualTo("DELETING");
assertThatThrownBy(() -> client.getGateway(GetGatewayRequest.builder()
.gatewayIdentifier(gatewayId).build()))
.isInstanceOf(ResourceNotFoundException.class);
}
}
@@ -0,0 +1,51 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.bedrockagentcorecontrol.BedrockAgentCoreControlClient;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.*;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
@DisplayName("Bedrock AgentCore clientToken idempotency")
class BedrockAgentCoreIdempotencyTest {
private static final String ROLE_ARN = "arn:aws:iam::000000000000:role/agent-runtime";
private static CreateAgentRuntimeRequest req(String name, String token) {
return CreateAgentRuntimeRequest.builder()
.agentRuntimeName(name)
.agentRuntimeArtifact(AgentRuntimeArtifact.builder()
.containerConfiguration(ContainerConfiguration.builder()
.containerUri("public.ecr.aws/x/agent:latest").build())
.build())
.networkConfiguration(NetworkConfiguration.builder().networkMode(NetworkMode.PUBLIC).build())
.roleArn(ROLE_ARN)
.clientToken(token)
.build();
}
@Test
void createRuntimeWithSameClientTokenReturnsSameRuntime() {
BedrockAgentCoreControlClient client = TestFixtures.bedrockAgentCoreControlClient();
String name = "idem" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
// clientToken must be >= 33 chars of the allowed alphabet.
String token = "idemtoken" + UUID.randomUUID().toString().replace("-", "");
String id1 = null;
try {
id1 = client.createAgentRuntime(req(name, token)).agentRuntimeId();
String id2 = client.createAgentRuntime(req(name, token)).agentRuntimeId();
assertThat(id2).isEqualTo(id1);
} finally {
if (id1 != null) {
try {
client.deleteAgentRuntime(DeleteAgentRuntimeRequest.builder()
.agentRuntimeId(id1).clientToken(token).build());
} catch (Exception ignored) {
}
}
client.close();
}
}
}
@@ -0,0 +1,34 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.bedrockagentcore.BedrockAgentCoreClient;
import software.amazon.awssdk.services.bedrockagentcore.model.InvokeAgentRuntimeRequest;
import software.amazon.awssdk.services.bedrockagentcore.model.InvokeAgentRuntimeResponse;
import static org.assertj.core.api.Assertions.*;
@DisplayName("Bedrock AgentCore InvokeAgentRuntime (data plane)")
class BedrockAgentCoreInvokeTest {
private static final String ARN =
"arn:aws:bedrock-agentcore:us-east-1:000000000000:agent/abcdef12-3456-7890-abcd-ef1234567890:1";
private static final String SESSION_ID = "session-0000000000000000000000000000abc";
@Test
void invokeReturnsCannedBodyAndEchoesSession() {
try (BedrockAgentCoreClient client = TestFixtures.bedrockAgentCoreClient()) {
ResponseBytes<InvokeAgentRuntimeResponse> response = client.invokeAgentRuntimeAsBytes(
InvokeAgentRuntimeRequest.builder()
.agentRuntimeArn(ARN)
.runtimeSessionId(SESSION_ID)
.contentType("application/json")
.payload(SdkBytes.fromUtf8String("{\"prompt\":\"anything\"}"))
.build());
assertThat(response.asUtf8String()).contains("yes");
assertThat(response.response().runtimeSessionId()).isEqualTo(SESSION_ID);
}
}
}
@@ -0,0 +1,97 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.bedrockagentcorecontrol.BedrockAgentCoreControlClient;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.*;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
@DisplayName("Bedrock AgentCore Memory")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BedrockAgentCoreMemoryTest {
private static BedrockAgentCoreControlClient client;
private static String memoryName;
private static String memoryId;
@BeforeAll
static void setup() {
client = TestFixtures.bedrockAgentCoreControlClient();
memoryName = "mem" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
@AfterAll
static void cleanup() {
if (client != null) {
try {
client.deleteMemory(DeleteMemoryRequest.builder().memoryId(memoryId).build());
} catch (Exception ignored) {
}
client.close();
}
}
@Test
@Order(1)
void createMemory() {
CreateMemoryResponse response = client.createMemory(CreateMemoryRequest.builder()
.name(memoryName)
.eventExpiryDuration(30)
.description("v1")
.encryptionKeyArn("arn:aws:kms:us-east-1:000000000000:key/mem-key")
.memoryExecutionRoleArn("arn:aws:iam::000000000000:role/mem-role")
.tags(Map.of("team", "core"))
.build());
memoryId = response.memory().id();
assertThat(memoryId).isNotBlank();
assertThat(response.memory().name()).isEqualTo(memoryName);
assertThat(response.memory().arn()).contains(":memory/");
assertThat(response.memory().statusAsString()).isEqualTo("ACTIVE");
assertThat(response.memory().encryptionKeyArn())
.isEqualTo("arn:aws:kms:us-east-1:000000000000:key/mem-key");
assertThat(response.memory().memoryExecutionRoleArn())
.isEqualTo("arn:aws:iam::000000000000:role/mem-role");
}
@Test
@Order(2)
void getMemory() {
GetMemoryResponse response = client.getMemory(GetMemoryRequest.builder().memoryId(memoryId).build());
assertThat(response.memory().name()).isEqualTo(memoryName);
assertThat(response.memory().encryptionKeyArn())
.isEqualTo("arn:aws:kms:us-east-1:000000000000:key/mem-key");
}
@Test
@Order(3)
void listMemories() {
ListMemoriesResponse response = client.listMemories(ListMemoriesRequest.builder().build());
assertThat(response.memories()).extracting(MemorySummary::id).contains(memoryId);
}
@Test
@Order(4)
void updateMemory() {
UpdateMemoryResponse response = client.updateMemory(UpdateMemoryRequest.builder()
.memoryId(memoryId).description("v2").eventExpiryDuration(60).build());
assertThat(response.memory().id()).isEqualTo(memoryId);
assertThat(response.memory().eventExpiryDuration()).isEqualTo(60);
GetMemoryResponse after = client.getMemory(GetMemoryRequest.builder().memoryId(memoryId).build());
assertThat(after.memory().eventExpiryDuration()).isEqualTo(60);
}
@Test
@Order(5)
void deleteMemory() {
DeleteMemoryResponse response = client.deleteMemory(DeleteMemoryRequest.builder()
.memoryId(memoryId).build());
assertThat(response.statusAsString()).isEqualTo("DELETING");
assertThatThrownBy(() -> client.getMemory(GetMemoryRequest.builder().memoryId(memoryId).build()))
.isInstanceOf(ResourceNotFoundException.class);
}
}
@@ -0,0 +1,67 @@
package com.floci.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.bedrockagentcorecontrol.BedrockAgentCoreControlClient;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.DeleteResourcePolicyRequest;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.GetResourcePolicyRequest;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.PutResourcePolicyRequest;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.ResourceNotFoundException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("Bedrock AgentCore resource policy")
class BedrockAgentCoreResourcePolicyTest {
private static final String RESOURCE_ARN =
"arn:aws:bedrock-agentcore:us-east-1:000000000000:gateway/example-1234567890";
@Test
void getMissingResourcePolicy() {
try (BedrockAgentCoreControlClient client = TestFixtures.bedrockAgentCoreControlClient()) {
assertThatThrownBy(() -> client.getResourcePolicy(GetResourcePolicyRequest.builder()
.resourceArn(RESOURCE_ARN)
.build()))
.isInstanceOf(ResourceNotFoundException.class);
}
}
@Test
void putAndGetResourcePolicy() {
try (BedrockAgentCoreControlClient client = TestFixtures.bedrockAgentCoreControlClient()) {
String policy = "{\"Version\":\"2012-10-17\",\"Statement\":[]}";
var put = client.putResourcePolicy(PutResourcePolicyRequest.builder()
.resourceArn(RESOURCE_ARN)
.policy(policy)
.build());
assertThat(put.policy()).isEqualTo(policy);
var get = client.getResourcePolicy(GetResourcePolicyRequest.builder()
.resourceArn(RESOURCE_ARN)
.build());
assertThat(get.policy()).isEqualTo(policy);
}
}
@Test
void deleteResourcePolicy() {
try (BedrockAgentCoreControlClient client = TestFixtures.bedrockAgentCoreControlClient()) {
String policy = "{\"Version\":\"2012-10-17\",\"Statement\":[]}";
client.putResourcePolicy(PutResourcePolicyRequest.builder()
.resourceArn(RESOURCE_ARN)
.policy(policy)
.build());
var deleted = client.deleteResourcePolicy(DeleteResourcePolicyRequest.builder()
.resourceArn(RESOURCE_ARN)
.build());
assertThat(deleted.sdkHttpResponse().statusCode()).isEqualTo(204);
assertThatThrownBy(() -> client.getResourcePolicy(GetResourcePolicyRequest.builder()
.resourceArn(RESOURCE_ARN)
.build()))
.isInstanceOf(ResourceNotFoundException.class);
}
}
}
@@ -0,0 +1,150 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.bedrockagentcorecontrol.BedrockAgentCoreControlClient;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
@DisplayName("Bedrock AgentCore Tagging + Workload Identity")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BedrockAgentCoreTagIdentityTest {
private static final String ROLE_ARN = "arn:aws:iam::000000000000:role/agent-runtime";
private static BedrockAgentCoreControlClient client;
private static String runtimeArn;
private static String identityName;
@BeforeAll
static void setup() {
client = TestFixtures.bedrockAgentCoreControlClient();
String name = "tagAgent" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
runtimeArn = client.createAgentRuntime(CreateAgentRuntimeRequest.builder()
.agentRuntimeName(name)
.agentRuntimeArtifact(AgentRuntimeArtifact.builder()
.containerConfiguration(ContainerConfiguration.builder()
.containerUri("public.ecr.aws/x/agent:latest").build())
.build())
.networkConfiguration(NetworkConfiguration.builder().networkMode(NetworkMode.PUBLIC).build())
.roleArn(ROLE_ARN)
.build()).agentRuntimeArn();
identityName = "wid" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
@AfterAll
static void cleanup() {
if (client != null) {
client.close();
}
}
@Test
@Order(1)
void tagRoundTrip() {
client.tagResource(TagResourceRequest.builder()
.resourceArn(runtimeArn).tags(Map.of("env", "prod", "team", "core")).build());
Map<String, String> tags = client.listTagsForResource(ListTagsForResourceRequest.builder()
.resourceArn(runtimeArn).build()).tags();
assertThat(tags).containsEntry("env", "prod").containsEntry("team", "core");
client.untagResource(UntagResourceRequest.builder()
.resourceArn(runtimeArn).tagKeys(List.of("env")).build());
Map<String, String> after = client.listTagsForResource(ListTagsForResourceRequest.builder()
.resourceArn(runtimeArn).build()).tags();
assertThat(after).doesNotContainKey("env").containsEntry("team", "core");
}
@Test
@Order(3)
void gatewayTagRoundTripViaSdk() {
String gwName = "gwtag" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
String gatewayArn = client.createGateway(CreateGatewayRequest.builder()
.name(gwName)
.protocolType(GatewayProtocolType.MCP)
.authorizerType(AuthorizerType.AWS_IAM)
.roleArn("arn:aws:iam::000000000000:role/gw")
.build()).gatewayArn();
try {
client.tagResource(TagResourceRequest.builder()
.resourceArn(gatewayArn).tags(Map.of("env", "prod")).build());
assertThat(client.listTagsForResource(ListTagsForResourceRequest.builder()
.resourceArn(gatewayArn).build()).tags()).containsEntry("env", "prod");
client.untagResource(UntagResourceRequest.builder()
.resourceArn(gatewayArn).tagKeys(List.of("env")).build());
assertThat(client.listTagsForResource(ListTagsForResourceRequest.builder()
.resourceArn(gatewayArn).build()).tags()).doesNotContainKey("env");
} finally {
// gatewayIdentifier is the id form; derive it from the ARN's last segment.
String gatewayId = gatewayArn.substring(gatewayArn.lastIndexOf('/') + 1);
try {
client.deleteGateway(DeleteGatewayRequest.builder().gatewayIdentifier(gatewayId).build());
} catch (Exception ignored) {
}
}
}
@Test
@Order(4)
void memoryTagRoundTripViaSdk() {
String memName = "memtag" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
CreateMemoryResponse created = client.createMemory(CreateMemoryRequest.builder()
.name(memName)
.eventExpiryDuration(30)
.tags(Map.of("team", "core"))
.build());
String memoryArn = created.memory().arn();
try {
// tags provided at create time surface through ListTagsForResource only
assertThat(client.listTagsForResource(ListTagsForResourceRequest.builder()
.resourceArn(memoryArn).build()).tags()).containsEntry("team", "core");
client.tagResource(TagResourceRequest.builder()
.resourceArn(memoryArn).tags(Map.of("env", "prod")).build());
assertThat(client.listTagsForResource(ListTagsForResourceRequest.builder()
.resourceArn(memoryArn).build()).tags())
.containsEntry("env", "prod").containsEntry("team", "core");
client.untagResource(UntagResourceRequest.builder()
.resourceArn(memoryArn).tagKeys(List.of("env")).build());
assertThat(client.listTagsForResource(ListTagsForResourceRequest.builder()
.resourceArn(memoryArn).build()).tags())
.doesNotContainKey("env").containsEntry("team", "core");
} finally {
try {
client.deleteMemory(DeleteMemoryRequest.builder()
.memoryId(created.memory().id()).build());
} catch (Exception ignored) {
}
}
}
@Test
@Order(2)
void workloadIdentityCrud() {
CreateWorkloadIdentityResponse created = client.createWorkloadIdentity(
CreateWorkloadIdentityRequest.builder().name(identityName).build());
assertThat(created.name()).isEqualTo(identityName);
assertThat(created.workloadIdentityArn()).contains(":bedrock-agentcore:");
GetWorkloadIdentityResponse got = client.getWorkloadIdentity(
GetWorkloadIdentityRequest.builder().name(identityName).build());
assertThat(got.name()).isEqualTo(identityName);
ListWorkloadIdentitiesResponse listed = client.listWorkloadIdentities(
ListWorkloadIdentitiesRequest.builder().build());
assertThat(listed.workloadIdentities()).extracting(WorkloadIdentityType::name)
.contains(identityName);
client.deleteWorkloadIdentity(DeleteWorkloadIdentityRequest.builder().name(identityName).build());
assertThatThrownBy(() -> client.getWorkloadIdentity(
GetWorkloadIdentityRequest.builder().name(identityName).build()))
.isInstanceOf(ResourceNotFoundException.class);
}
}
@@ -0,0 +1,222 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.bedrockagentcorecontrol.BedrockAgentCoreControlClient;
import software.amazon.awssdk.services.bedrockagentcorecontrol.model.*;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Bedrock AgentCore tools")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BedrockAgentCoreToolsTest {
private static BedrockAgentCoreControlClient client;
private static String browserName;
private static String browserId;
private static String profileName;
private static String profileId;
private static String codeInterpreterName;
private static String codeInterpreterId;
@BeforeAll
static void setup() {
client = TestFixtures.bedrockAgentCoreControlClient();
browserName = "browser" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
profileName = "profile" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
codeInterpreterName = "code" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
@AfterAll
static void cleanup() {
if (client != null) {
client.close();
}
}
@Test
@Order(1)
void createBrowser() {
CreateBrowserResponse response = client.createBrowser(CreateBrowserRequest.builder()
.name(browserName)
.networkConfiguration(BrowserNetworkConfiguration.builder()
.networkMode(BrowserNetworkMode.PUBLIC)
.build())
.build());
browserId = response.browserId();
assertThat(browserId).startsWith(browserName + "-");
assertThat(response.browserArn()).contains(":bedrock-agentcore:").contains(":browser-custom/");
assertThat(response.statusAsString()).isEqualTo("READY");
assertThat(response.createdAt()).isNotNull();
}
@Test
@Order(2)
void getBrowser() {
GetBrowserResponse response = client.getBrowser(GetBrowserRequest.builder()
.browserId(browserId)
.build());
assertThat(response.browserId()).isEqualTo(browserId);
assertThat(response.name()).isEqualTo(browserName);
assertThat(response.browserArn()).contains(":browser-custom/");
assertThat(response.networkConfiguration().networkModeAsString()).isEqualTo("PUBLIC");
assertThat(response.statusAsString()).isEqualTo("READY");
}
@Test
@Order(3)
void listBrowsers() {
ListBrowsersResponse response = client.listBrowsers(ListBrowsersRequest.builder()
.type(ResourceType.CUSTOM)
.maxResults(100)
.build());
assertThat(response.browserSummaries())
.anyMatch(summary -> browserId.equals(summary.browserId()));
}
@Test
@Order(4)
void deleteBrowser() {
String clientToken = UUID.randomUUID().toString();
DeleteBrowserRequest request = DeleteBrowserRequest.builder()
.browserId(browserId)
.clientToken(clientToken)
.build();
DeleteBrowserResponse response = client.deleteBrowser(request);
DeleteBrowserResponse replay = client.deleteBrowser(request);
assertThat(response.browserId()).isEqualTo(browserId);
assertThat(response.statusAsString()).isEqualTo("DELETING");
assertThat(response.lastUpdatedAt()).isNotNull();
assertThat(replay.browserId()).isEqualTo(browserId);
assertThat(replay.statusAsString()).isEqualTo("DELETING");
}
@Test
@Order(5)
void createBrowserProfile() {
CreateBrowserProfileResponse response = client.createBrowserProfile(CreateBrowserProfileRequest.builder()
.name(profileName)
.description("profile")
.build());
profileId = response.profileId();
assertThat(profileId).startsWith(profileName + "-");
assertThat(response.profileArn()).contains(":browser-profile/");
assertThat(response.statusAsString()).isEqualTo("READY");
assertThat(response.createdAt()).isNotNull();
}
@Test
@Order(6)
void getBrowserProfile() {
GetBrowserProfileResponse response = client.getBrowserProfile(GetBrowserProfileRequest.builder()
.profileId(profileId)
.build());
assertThat(response.profileId()).isEqualTo(profileId);
assertThat(response.name()).isEqualTo(profileName);
assertThat(response.description()).isEqualTo("profile");
assertThat(response.statusAsString()).isEqualTo("READY");
}
@Test
@Order(7)
void listBrowserProfiles() {
ListBrowserProfilesResponse response = client.listBrowserProfiles(ListBrowserProfilesRequest.builder()
.name(profileName)
.maxResults(100)
.build());
assertThat(response.profileSummaries())
.anyMatch(summary -> profileId.equals(summary.profileId()));
}
@Test
@Order(8)
void deleteBrowserProfile() {
String clientToken = UUID.randomUUID().toString();
DeleteBrowserProfileRequest request = DeleteBrowserProfileRequest.builder()
.profileId(profileId)
.clientToken(clientToken)
.build();
DeleteBrowserProfileResponse response = client.deleteBrowserProfile(request);
DeleteBrowserProfileResponse replay = client.deleteBrowserProfile(request);
assertThat(response.profileId()).isEqualTo(profileId);
assertThat(response.statusAsString()).isEqualTo("DELETING");
assertThat(response.lastUpdatedAt()).isNotNull();
assertThat(replay.profileId()).isEqualTo(profileId);
assertThat(replay.statusAsString()).isEqualTo("DELETING");
}
@Test
@Order(9)
void createCodeInterpreter() {
CreateCodeInterpreterResponse response = client.createCodeInterpreter(CreateCodeInterpreterRequest.builder()
.name(codeInterpreterName)
.networkConfiguration(CodeInterpreterNetworkConfiguration.builder()
.networkMode(CodeInterpreterNetworkMode.PUBLIC)
.build())
.build());
codeInterpreterId = response.codeInterpreterId();
assertThat(codeInterpreterId).startsWith(codeInterpreterName + "-");
assertThat(response.codeInterpreterArn()).contains(":code-interpreter-custom/");
assertThat(response.statusAsString()).isEqualTo("READY");
assertThat(response.createdAt()).isNotNull();
}
@Test
@Order(10)
void getCodeInterpreter() {
GetCodeInterpreterResponse response = client.getCodeInterpreter(GetCodeInterpreterRequest.builder()
.codeInterpreterId(codeInterpreterId)
.build());
assertThat(response.codeInterpreterId()).isEqualTo(codeInterpreterId);
assertThat(response.name()).isEqualTo(codeInterpreterName);
assertThat(response.networkConfiguration().networkModeAsString()).isEqualTo("PUBLIC");
assertThat(response.statusAsString()).isEqualTo("READY");
}
@Test
@Order(11)
void listCodeInterpreters() {
ListCodeInterpretersResponse response = client.listCodeInterpreters(ListCodeInterpretersRequest.builder()
.type(ResourceType.CUSTOM)
.maxResults(100)
.build());
assertThat(response.codeInterpreterSummaries())
.anyMatch(summary -> codeInterpreterId.equals(summary.codeInterpreterId()));
}
@Test
@Order(12)
void deleteCodeInterpreter() {
String clientToken = UUID.randomUUID().toString();
DeleteCodeInterpreterRequest request = DeleteCodeInterpreterRequest.builder()
.codeInterpreterId(codeInterpreterId)
.clientToken(clientToken)
.build();
DeleteCodeInterpreterResponse response = client.deleteCodeInterpreter(request);
DeleteCodeInterpreterResponse replay = client.deleteCodeInterpreter(request);
assertThat(response.codeInterpreterId()).isEqualTo(codeInterpreterId);
assertThat(response.statusAsString()).isEqualTo("DELETING");
assertThat(response.lastUpdatedAt()).isNotNull();
assertThat(replay.codeInterpreterId()).isEqualTo(codeInterpreterId);
assertThat(replay.statusAsString()).isEqualTo("DELETING");
}
}
@@ -0,0 +1,239 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.bedrock.BedrockClient;
import software.amazon.awssdk.services.bedrock.model.CreateGuardrailResponse;
import software.amazon.awssdk.services.bedrock.model.CreateGuardrailVersionResponse;
import software.amazon.awssdk.services.bedrock.model.GetGuardrailResponse;
import software.amazon.awssdk.services.bedrock.model.GuardrailContentFilterType;
import software.amazon.awssdk.services.bedrock.model.GuardrailFilterStrength;
import software.amazon.awssdk.services.bedrock.model.GuardrailStatus;
import software.amazon.awssdk.services.bedrock.model.GuardrailTopicType;
import software.amazon.awssdk.services.bedrock.model.ListGuardrailsResponse;
import software.amazon.awssdk.services.bedrock.model.ResourceNotFoundException;
import software.amazon.awssdk.services.bedrock.model.Tag;
import software.amazon.awssdk.services.bedrock.model.TooManyTagsException;
import software.amazon.awssdk.services.bedrock.model.UpdateGuardrailResponse;
import software.amazon.awssdk.services.bedrock.model.ValidationException;
import software.amazon.awssdk.services.kms.KmsClient;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("Bedrock guardrails")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BedrockGuardrailTest {
private static BedrockClient bedrock;
private static String name;
private static String guardrailId;
private static String guardrailArn;
private static String kmsKeyId;
private static String kmsKeyArn;
private static String kmsAlias;
@BeforeAll
static void setup() {
bedrock = TestFixtures.bedrockClient();
name = "sdk-test-guardrail-" + System.currentTimeMillis();
kmsAlias = "alias/sdk-test-guardrail-" + System.currentTimeMillis();
try (KmsClient kms = TestFixtures.kmsClient()) {
var key = kms.createKey(r -> r.description("sdk compatibility guardrail key")).keyMetadata();
kmsKeyId = key.keyId();
kmsKeyArn = key.arn();
kms.createAlias(r -> r.aliasName(kmsAlias).targetKeyId(kmsKeyId));
}
}
@AfterAll
static void cleanup() {
if (bedrock != null) {
if (guardrailId != null) {
try {
bedrock.deleteGuardrail(r -> r.guardrailIdentifier(guardrailId));
} catch (Exception e) {
System.out.println("Guardrail cleanup skipped: " + e.getMessage());
}
}
bedrock.close();
}
}
@Test
@Order(1)
void createGuardrail() {
CreateGuardrailResponse response = bedrock.createGuardrail(r -> r
.name(name)
.description("created by the sdk compatibility suite")
.blockedInputMessaging("Input blocked.")
.blockedOutputsMessaging("Output blocked.")
.kmsKeyId(kmsKeyId)
.topicPolicyConfig(t -> t.topicsConfig(topic -> topic
.name("Investments")
.definition("Advice about buying or selling securities.")
.type(GuardrailTopicType.DENY)))
.contentPolicyConfig(c -> c.filtersConfig(f -> f
.type(GuardrailContentFilterType.HATE)
.inputStrength(GuardrailFilterStrength.HIGH)
.outputStrength(GuardrailFilterStrength.HIGH)))
.tags(Tag.builder().key("suite").value("sdk-compat").build()));
guardrailId = response.guardrailId();
guardrailArn = response.guardrailArn();
assertThat(guardrailId).isNotBlank();
assertThat(guardrailArn).contains(":bedrock:").contains(":guardrail/" + guardrailId);
assertThat(response.version()).isEqualTo("DRAFT");
assertThat(response.createdAt()).isNotNull();
}
@Test
@Order(2)
void getGuardrailReadsBackTheReadShapes() {
GetGuardrailResponse response = bedrock.getGuardrail(r -> r.guardrailIdentifier(guardrailId));
assertThat(response.name()).isEqualTo(name);
assertThat(response.guardrailId()).isEqualTo(guardrailId);
assertThat(response.guardrailArn()).isEqualTo(guardrailArn);
assertThat(response.version()).isEqualTo("DRAFT");
// The terraform provider's create waiter polls until the guardrail leaves CREATING.
assertThat(response.status()).isEqualTo(GuardrailStatus.READY);
assertThat(response.blockedInputMessaging()).isEqualTo("Input blocked.");
assertThat(response.blockedOutputsMessaging()).isEqualTo("Output blocked.");
// A bare key id on the request comes back as a full KMS key ARN, which is the
// only shape the GetGuardrail response model accepts.
assertThat(response.kmsKeyArn()).isEqualTo(kmsKeyArn);
assertThat(response.topicPolicy().topics()).singleElement()
.satisfies(t -> assertThat(t.name()).isEqualTo("Investments"));
assertThat(response.contentPolicy().filters()).singleElement()
.satisfies(f -> assertThat(f.type()).isEqualTo(GuardrailContentFilterType.HATE));
}
@Test
@Order(3)
void updateGuardrail() {
UpdateGuardrailResponse response = bedrock.updateGuardrail(r -> r
.guardrailIdentifier(guardrailId)
.name(name)
.description("updated by the sdk compatibility suite")
.blockedInputMessaging("Input still blocked.")
.blockedOutputsMessaging("Output still blocked.")
.kmsKeyId(kmsAlias));
assertThat(response.guardrailId()).isEqualTo(guardrailId);
assertThat(response.version()).isEqualTo("DRAFT");
assertThat(response.updatedAt()).isNotNull();
GetGuardrailResponse updated = bedrock.getGuardrail(r -> r.guardrailIdentifier(guardrailId));
assertThat(updated.description()).isEqualTo("updated by the sdk compatibility suite");
// An alias resolves to the same key ARN a bare key id did.
assertThat(updated.kmsKeyArn()).isEqualTo(kmsKeyArn);
}
@Test
@Order(4)
void createGuardrailRejectsAnOverLongBlockedMessaging() {
assertThatThrownBy(() -> bedrock.createGuardrail(r -> r
.name(name + "-toolong")
.blockedInputMessaging("x".repeat(501))
.blockedOutputsMessaging("Output blocked.")))
.isInstanceOf(ValidationException.class);
}
@Test
@Order(5)
void createGuardrailVersionSnapshotsTheDraft() {
CreateGuardrailVersionResponse response = bedrock.createGuardrailVersion(r -> r
.guardrailIdentifier(guardrailId)
.description("first published version"));
assertThat(response.guardrailId()).isEqualTo(guardrailId);
assertThat(response.version()).isEqualTo("1");
GetGuardrailResponse published = bedrock.getGuardrail(r -> r
.guardrailIdentifier(guardrailId).guardrailVersion("1"));
assertThat(published.version()).isEqualTo("1");
assertThat(published.description()).isEqualTo("first published version");
assertThat(published.blockedInputMessaging()).isEqualTo("Input still blocked.");
}
@Test
@Order(6)
void listGuardrailsReturnsTheDraft() {
ListGuardrailsResponse response = bedrock.listGuardrails(r -> r.maxResults(100));
assertThat(response.guardrails())
.anySatisfy(g -> {
assertThat(g.id()).isEqualTo(guardrailId);
assertThat(g.arn()).isEqualTo(guardrailArn);
assertThat(g.status()).isEqualTo(GuardrailStatus.READY);
});
}
@Test
@Order(7)
void tagResourceRoundTrip() {
bedrock.tagResource(r -> r.resourceARN(guardrailArn)
.tags(Tag.builder().key("env").value("test").build()));
assertThat(bedrock.listTagsForResource(r -> r.resourceARN(guardrailArn)).tags())
.anySatisfy(t -> {
assertThat(t.key()).isEqualTo("env");
assertThat(t.value()).isEqualTo("test");
});
bedrock.untagResource(r -> r.resourceARN(guardrailArn).tagKeys("env"));
assertThat(bedrock.listTagsForResource(r -> r.resourceARN(guardrailArn)).tags())
.noneSatisfy(t -> assertThat(t.key()).isEqualTo("env"));
}
@Test
@Order(8)
void tagResourceRejectsMoreTagsThanOneResourceMayHold() {
// The 50 tag limit belongs to the resource, so it is measured against the total left
// after the merge rather than the size of the incoming request.
bedrock.tagResource(r -> r.resourceARN(guardrailArn).tags(tags("bulk-", 49)));
assertThatThrownBy(() -> bedrock.tagResource(r -> r.resourceARN(guardrailArn)
.tags(Tag.builder().key("one-too-many").value("x").build())))
.isInstanceOf(TooManyTagsException.class)
.satisfies(e -> assertThat(((TooManyTagsException) e).resourceName()).isEqualTo(guardrailArn));
bedrock.untagResource(r -> r.resourceARN(guardrailArn)
.tagKeys(tags("bulk-", 49).stream().map(Tag::key).toList()));
}
private static List<Tag> tags(String prefix, int count) {
List<Tag> tags = new ArrayList<>();
for (int i = 0; i < count; i++) {
tags.add(Tag.builder().key(prefix + i).value("v" + i).build());
}
return tags;
}
@Test
@Order(9)
void deleteGuardrailThenGetThrowsResourceNotFound() {
bedrock.deleteGuardrail(r -> r.guardrailIdentifier(guardrailId));
// The terraform provider's delete waiter matches this typed exception to treat
// the guardrail as gone.
String deletedId = guardrailId;
assertThatThrownBy(() -> bedrock.getGuardrail(r -> r.guardrailIdentifier(deletedId)))
.isInstanceOf(ResourceNotFoundException.class);
guardrailId = null;
}
}
@@ -0,0 +1,70 @@
package com.floci.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeAsyncClient;
import software.amazon.awssdk.services.bedrockruntime.model.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies Bedrock Runtime's ConverseStream against the real AWS SDK's event-stream decoder,
* not just Floci's own raw-byte assertions - proves the {@code application/vnd.amazon.eventstream}
* frames Floci emits actually round-trip through {@link BedrockRuntimeAsyncClient}'s generated
* unmarshaller end to end.
*/
@DisplayName("Bedrock Runtime ConverseStream")
class BedrockRuntimeConverseStreamTest {
private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0";
@Test
void converseStream_deliversTextDeltasAndCompletes() throws Exception {
try (BedrockRuntimeAsyncClient client = TestFixtures.bedrockRuntimeAsyncClient()) {
List<ConverseStreamOutput> received = new ArrayList<>();
AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch complete = new CountDownLatch(1);
ConverseStreamResponseHandler handler = ConverseStreamResponseHandler.builder()
.subscriber(received::add)
.onError(error::set)
.onComplete(complete::countDown)
.build();
ConverseStreamRequest request = ConverseStreamRequest.builder()
.modelId(MODEL_ID)
.messages(Message.builder()
.role(ConversationRole.USER)
.content(List.of(ContentBlock.fromText("hi")))
.build())
.build();
CompletableFuture<Void> future = client.converseStream(request, handler);
future.get(15, TimeUnit.SECONDS);
assertThat(complete.await(15, TimeUnit.SECONDS)).as("onComplete should fire").isTrue();
assertThat(error.get()).as("no stream error expected").isNull();
assertThat(received).isNotEmpty();
assertThat(received.get(0)).isInstanceOf(MessageStartEvent.class);
List<String> text = received.stream()
.filter(ContentBlockDeltaEvent.class::isInstance)
.map(ContentBlockDeltaEvent.class::cast)
.map(ContentBlockDeltaEvent::delta)
.map(ContentBlockDelta::text)
.toList();
assertThat(text)
.containsExactly("Floci stub response", " for model=", MODEL_ID);
assertThat(received).anyMatch(MessageStopEvent.class::isInstance);
}
}
}
@@ -0,0 +1,138 @@
package com.floci.test;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.budgets.BudgetsClient;
import software.amazon.awssdk.services.budgets.model.*;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class BudgetsTest {
private static final String ACCOUNT_ID = "000000000000";
@Test
void completeBudgetLifecycle() {
String budgetName = TestFixtures.uniqueName("budget");
Notification notification = Notification.builder()
.notificationType(NotificationType.ACTUAL)
.comparisonOperator(ComparisonOperator.GREATER_THAN)
.threshold(80d)
.thresholdType(ThresholdType.PERCENTAGE)
.build();
Subscriber firstSubscriber = Subscriber.builder()
.subscriptionType(SubscriptionType.EMAIL)
.address("billing@example.com")
.build();
try (BudgetsClient client = TestFixtures.budgetsClient()) {
client.createBudget(CreateBudgetRequest.builder()
.accountId(ACCOUNT_ID)
.budget(Budget.builder()
.budgetName(budgetName)
.budgetType(BudgetType.COST)
.timeUnit(TimeUnit.MONTHLY)
.budgetLimit(Spend.builder().amount(new java.math.BigDecimal("100.00")).unit("USD").build())
.build())
.notificationsWithSubscribers(NotificationWithSubscribers.builder()
.notification(notification)
.subscribers(firstSubscriber)
.build())
.resourceTags(ResourceTag.builder().key("managed_by").value("floci-test").build())
.build());
assertThat(client.describeBudget(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName)).budget().budgetName())
.isEqualTo(budgetName);
assertThat(client.describeBudgets(b -> b.accountId(ACCOUNT_ID).maxResults(1000)).budgets())
.extracting(Budget::budgetName).contains(budgetName);
assertThat(client.describeNotificationsForBudget(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName)).notifications())
.hasSize(1);
assertThat(client.describeSubscribersForNotification(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName).notification(notification)).subscribers())
.hasSize(1);
assertThat(client.describeBudgetNotificationsForAccount(b -> b.accountId(ACCOUNT_ID)).budgetNotificationsForAccount())
.anyMatch(item -> budgetName.equals(item.budgetName()));
Subscriber second = Subscriber.builder().subscriptionType(SubscriptionType.EMAIL).address("ops@example.com").build();
client.createSubscriber(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName).notification(notification).subscriber(second));
Subscriber replacement = Subscriber.builder().subscriptionType(SubscriptionType.EMAIL).address("finops@example.com").build();
client.updateSubscriber(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName).notification(notification)
.oldSubscriber(second).newSubscriber(replacement));
client.deleteSubscriber(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName).notification(notification).subscriber(replacement));
Notification updatedNotification = notification.toBuilder().threshold(90d).build();
client.updateNotification(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName)
.oldNotification(notification).newNotification(updatedNotification));
String budgetArn = "arn:aws:budgets::" + ACCOUNT_ID + ":budget/" + budgetName;
client.tagResource(b -> b.resourceARN(budgetArn).resourceTags(ResourceTag.builder().key("team").value("platform").build()));
assertThat(client.listTagsForResource(b -> b.resourceARN(budgetArn)).resourceTags())
.anyMatch(tag -> "team".equals(tag.key()));
client.untagResource(b -> b.resourceARN(budgetArn).resourceTagKeys("team"));
client.updateBudget(b -> b.accountId(ACCOUNT_ID).newBudget(Budget.builder()
.budgetName(budgetName).budgetType(BudgetType.COST).timeUnit(TimeUnit.MONTHLY)
.budgetLimit(Spend.builder().amount(new java.math.BigDecimal("150.00")).unit("USD").build()).build()));
assertThat(client.describeBudgetPerformanceHistory(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName)).budgetPerformanceHistory())
.isNotNull();
CreateBudgetActionResponse createdAction = client.createBudgetAction(CreateBudgetActionRequest.builder()
.accountId(ACCOUNT_ID)
.budgetName(budgetName)
.notificationType(NotificationType.ACTUAL)
.actionType(ActionType.APPLY_IAM_POLICY)
.actionThreshold(ActionThreshold.builder().actionThresholdType(ThresholdType.PERCENTAGE).actionThresholdValue(100d).build())
.definition(Definition.builder().iamActionDefinition(IamActionDefinition.builder()
.policyArn("arn:aws:iam::aws:policy/ReadOnlyAccess").users("alice").build()).build())
.executionRoleArn("arn:aws:iam::" + ACCOUNT_ID + ":role/BudgetExecutionRole")
.approvalModel(ApprovalModel.MANUAL)
.subscribers(firstSubscriber)
.resourceTags(ResourceTag.builder().key("kind").value("guardrail").build())
.build());
String actionId = createdAction.actionId();
assertThat(actionId).isNotBlank();
assertThat(client.describeBudgetAction(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName).actionId(actionId)).action().actionId())
.isEqualTo(actionId);
assertThat(client.describeBudgetActionsForAccount(b -> b.accountId(ACCOUNT_ID)).actions())
.extracting(Action::actionId).contains(actionId);
assertThat(client.describeBudgetActionsForBudget(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName)).actions())
.extracting(Action::actionId).contains(actionId);
client.updateBudgetAction(UpdateBudgetActionRequest.builder()
.accountId(ACCOUNT_ID).budgetName(budgetName).actionId(actionId)
.approvalModel(ApprovalModel.AUTOMATIC).build());
client.executeBudgetAction(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName).actionId(actionId)
.executionType(ExecutionType.APPROVE_BUDGET_ACTION));
Action executed = client.describeBudgetAction(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName).actionId(actionId)).action();
assertThat(executed.status()).isEqualTo(ActionStatus.EXECUTION_SUCCESS);
List<ActionHistory> histories = client.describeBudgetActionHistories(
b -> b.accountId(ACCOUNT_ID).budgetName(budgetName).actionId(actionId)).actionHistories();
assertThat(histories).isNotEmpty();
assertThat(histories).anySatisfy(history -> {
assertThat(history.eventType()).isEqualTo(EventType.EXECUTE_ACTION);
assertThat(history.status()).isEqualTo(ActionStatus.EXECUTION_SUCCESS);
});
String actionArn = budgetArn + "/action/" + actionId;
client.tagResource(b -> b.resourceARN(actionArn).resourceTags(ResourceTag.builder().key("action").value("yes").build()));
assertThat(client.listTagsForResource(b -> b.resourceARN(actionArn)).resourceTags()).isNotEmpty();
client.deleteBudgetAction(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName).actionId(actionId));
client.deleteNotification(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName).notification(updatedNotification));
client.deleteBudget(b -> b.accountId(ACCOUNT_ID).budgetName(budgetName));
}
}
@Test
void crossAccountBudgetAccessIsDenied() {
String caller = "123456789012";
String foreign = "210987654321";
try (BudgetsClient client = TestFixtures.budgetsClient(caller)) {
assertThatThrownBy(() -> client.describeBudget(b -> b.accountId(foreign).budgetName("foreign")))
.isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> client.listTagsForResource(b ->
b.resourceARN("arn:aws:budgets::" + foreign + ":budget/foreign")))
.isInstanceOf(AccessDeniedException.class);
}
}
}
@@ -0,0 +1,57 @@
package com.floci.test;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.interceptor.*;
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
import software.amazon.awssdk.services.cloudwatch.model.*;
import java.util.concurrent.atomic.AtomicReference;
class CWWireDebugTest {
@Test
void captureRequestBody() throws Exception {
AtomicReference<String> capturedBody = new AtomicReference<>();
ExecutionInterceptor interceptor = new ExecutionInterceptor() {
@Override
public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes attrs) {
try {
var body = context.requestBody();
if (body.isPresent()) {
String b = new String(body.get().contentStreamProvider().newStream().readAllBytes());
if (b.contains("PutMetricData")) {
capturedBody.set(b);
System.out.println("=== SDK PutMetricData request body ===");
System.out.println(b);
System.out.println("===");
}
}
} catch (Exception e) { e.printStackTrace(); }
}
};
try (CloudWatchClient cw = CloudWatchClient.builder()
.endpointOverride(TestFixtures.endpoint())
.region(Region.US_EAST_1)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("test", "test")))
.overrideConfiguration(ClientOverrideConfiguration.builder()
.addExecutionInterceptor(interceptor).build())
.build()) {
cw.putMetricData(r -> r
.namespace("WireDebugNS")
.metricData(d -> d
.metricName("WireMetric")
.statisticValues(s -> s.sampleCount(5.0).sum(150.0).minimum(20.0).maximum(40.0))
.unit(StandardUnit.COUNT)
)
);
System.out.println("Captured: " + capturedBody.get());
}
}
}
@@ -0,0 +1,192 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.acm.AcmClient;
import software.amazon.awssdk.services.acm.model.CertificateDetail;
import software.amazon.awssdk.services.acm.model.CertificateExport;
import software.amazon.awssdk.services.acm.model.CertificateStatus;
import software.amazon.awssdk.services.acm.model.CertificateTransparencyLoggingPreference;
import software.amazon.awssdk.services.acm.model.ResourceNotFoundException;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.CloudFormationException;
import software.amazon.awssdk.services.cloudformation.model.DescribeStacksRequest;
import software.amazon.awssdk.services.cloudformation.model.Output;
import software.amazon.awssdk.services.cloudformation.model.Parameter;
import software.amazon.awssdk.services.cloudformation.model.Stack;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Drives {@code CertificateExport} and {@code CertificateTransparencyLoggingPreference} on an
* {@code AWS::CertificateManager::Certificate} through the CloudFormation SDK and reads the result
* back through the ACM SDK: the logging preference changes in place, the export setting replaces
* the certificate, and the stack delete removes it.
*/
@DisplayName("CloudFormation AWS::CertificateManager::Certificate")
class CloudFormationAcmCertificateTest {
private static CloudFormationClient cloudFormation;
private static AcmClient acm;
private static String stackName;
private static String domainName;
@BeforeAll
static void setup() {
cloudFormation = TestFixtures.cloudFormationClient();
acm = TestFixtures.acmClient();
stackName = TestFixtures.uniqueName("compat-cfn-acm");
domainName = TestFixtures.uniqueName("cert") + ".example.com";
}
@AfterAll
static void cleanup() {
if (cloudFormation != null) {
try {
cloudFormation.deleteStack(r -> r.stackName(stackName));
} catch (Exception e) {
System.err.println("CloudFormation ACM cleanup skipped: " + e.getMessage());
}
cloudFormation.close();
}
if (acm != null) {
acm.close();
}
}
@Test
void certificateOptionsFollowTheTemplateThroughCreateAndUpdate() throws InterruptedException {
cloudFormation.createStack(r -> r
.stackName(stackName)
.templateBody(template())
.parameters(options("ENABLED", "DISABLED")));
assertThat(waitForTerminal(stackName, 60)).isEqualTo("CREATE_COMPLETE");
String arn = output("CertArn");
assertThat(arn).startsWith("arn:aws:acm:us-east-1:");
CertificateDetail created = describe(arn);
assertThat(created.domainName()).isEqualTo(domainName);
assertThat(created.status()).isEqualTo(CertificateStatus.ISSUED);
assertThat(created.options().export()).isEqualTo(CertificateExport.ENABLED);
assertThat(created.options().certificateTransparencyLoggingPreference())
.isEqualTo(CertificateTransparencyLoggingPreference.DISABLED);
cloudFormation.updateStack(r -> r
.stackName(stackName)
.templateBody(template())
.parameters(options("ENABLED", "ENABLED")));
assertThat(waitForTerminal(stackName, 60)).isEqualTo("UPDATE_COMPLETE");
assertThat(output("CertArn"))
.as("a transparency logging change updates the certificate in place")
.isEqualTo(arn);
assertThat(describe(arn).options().certificateTransparencyLoggingPreference())
.isEqualTo(CertificateTransparencyLoggingPreference.ENABLED);
cloudFormation.updateStack(r -> r
.stackName(stackName)
.templateBody(template())
.parameters(options("DISABLED", "ENABLED")));
assertThat(waitForTerminal(stackName, 60)).isEqualTo("UPDATE_COMPLETE");
String replacement = output("CertArn");
assertThat(replacement).as("an export change replaces the certificate").isNotEqualTo(arn);
assertThatThrownBy(() -> describe(arn)).isInstanceOf(ResourceNotFoundException.class);
CertificateDetail replaced = describe(replacement);
assertThat(replaced.domainName()).isEqualTo(domainName);
assertThat(replaced.options().export()).isEqualTo(CertificateExport.DISABLED);
assertThat(replaced.options().certificateTransparencyLoggingPreference())
.isEqualTo(CertificateTransparencyLoggingPreference.ENABLED);
cloudFormation.deleteStack(r -> r.stackName(stackName));
waitForDeleted(stackName, 60);
assertThatThrownBy(() -> describe(replacement)).isInstanceOf(ResourceNotFoundException.class);
}
private static String template() {
return """
{
"Parameters": {
"Export": {"Type": "String"},
"TransparencyLogging": {"Type": "String"}
},
"Resources": {
"Cert": {
"Type": "AWS::CertificateManager::Certificate",
"Properties": {
"DomainName": "%s",
"ValidationMethod": "DNS",
"CertificateExport": {"Ref": "Export"},
"CertificateTransparencyLoggingPreference": {"Ref": "TransparencyLogging"}
}
}
},
"Outputs": {
"CertArn": {"Value": {"Fn::GetAtt": ["Cert", "CertificateArn"]}}
}
}
""".formatted(domainName);
}
private static List<Parameter> options(String export, String transparencyLogging) {
return List.of(
Parameter.builder().parameterKey("Export").parameterValue(export).build(),
Parameter.builder().parameterKey("TransparencyLogging").parameterValue(transparencyLogging).build());
}
private static CertificateDetail describe(String arn) {
return acm.describeCertificate(r -> r.certificateArn(arn)).certificate();
}
private static String output(String key) {
Stack stack = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(stackName).build()).stacks().get(0);
return stack.outputs().stream()
.filter(o -> key.equals(o.outputKey()))
.map(Output::outputValue)
.findFirst()
.orElseThrow(() -> new AssertionError("stack " + stackName + " has no output " + key));
}
private static String waitForTerminal(String name, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
List<Stack> stacks = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(name).build()).stacks();
if (!stacks.isEmpty()) {
String status = stacks.get(0).stackStatusAsString();
if (!status.endsWith("_IN_PROGRESS")) {
return status;
}
}
Thread.sleep(500);
}
throw new AssertionError(
"Stack " + name + " did not reach a terminal state within " + maxSeconds + "s");
}
private static void waitForDeleted(String name, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
try {
List<Stack> stacks = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(name).build()).stacks();
if (stacks.isEmpty()
|| "DELETE_COMPLETE".equals(stacks.get(0).stackStatusAsString())) {
return;
}
} catch (CloudFormationException e) {
if (e.getMessage() != null && e.getMessage().contains("does not exist")) {
return;
}
throw e;
}
Thread.sleep(500);
}
throw new AssertionError(
"Stack " + name + " was not deleted within " + maxSeconds + "s");
}
}
@@ -0,0 +1,208 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.CloudFormationException;
import software.amazon.awssdk.services.cloudformation.model.DescribeStacksRequest;
import software.amazon.awssdk.services.cloudformation.model.Output;
import software.amazon.awssdk.services.cloudformation.model.Parameter;
import software.amazon.awssdk.services.cloudformation.model.Stack;
import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
import software.amazon.awssdk.services.cloudwatch.model.GetDashboardResponse;
import software.amazon.awssdk.services.cloudwatch.model.CloudWatchException;
import software.amazon.awssdk.services.cloudwatch.model.Tag;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Drives an {@code AWS::CloudWatch::Dashboard} through the CloudFormation SDK and reads it back
* through the CloudWatch SDK: {@code Ref} is the name, a body or tag change updates in place, a
* name change replaces the dashboard and removes the displaced one, and the stack delete removes it.
*/
@DisplayName("CloudFormation AWS::CloudWatch::Dashboard")
class CloudFormationCloudWatchDashboardTest {
private static CloudFormationClient cloudFormation;
private static CloudWatchClient cloudWatch;
private static String stackName;
private static String dashboardName;
@BeforeAll
static void setup() {
cloudFormation = TestFixtures.cloudFormationClient();
cloudWatch = TestFixtures.cloudWatchClient();
stackName = TestFixtures.uniqueName("compat-cfn-dashboard");
dashboardName = TestFixtures.uniqueName("compat-dashboard");
}
@AfterAll
static void cleanup() {
if (cloudFormation != null) {
try {
cloudFormation.deleteStack(r -> r.stackName(stackName));
waitForDeleted(stackName, 60);
} catch (Exception e) {
System.err.println("CloudFormation dashboard cleanup skipped: " + e.getMessage());
}
cloudFormation.close();
}
if (cloudWatch != null) {
cloudWatch.close();
}
}
@Test
void dashboardFollowsTheTemplateThroughCreateUpdateReplacementAndDelete() throws InterruptedException {
cloudFormation.createStack(r -> r
.stackName(stackName)
.templateBody(template())
.parameters(parameters(dashboardName, "Requests", "platform")));
assertThat(waitForTerminal(stackName, 60)).isEqualTo("CREATE_COMPLETE");
assertThat(output("DashboardRef")).as("Ref is the dashboard name").isEqualTo(dashboardName);
GetDashboardResponse created = get(dashboardName);
assertThat(created.dashboardBody())
.isEqualTo("{\"widgets\":[{\"type\":\"text\",\"properties\":{\"markdown\":\"Requests\"}}]}");
assertThat(created.dashboardArn()).endsWith(":dashboard/" + dashboardName);
assertThat(tags(created.dashboardArn())).containsExactlyEntriesOf(Map.of("team", "platform"));
cloudFormation.updateStack(r -> r
.stackName(stackName)
.templateBody(template())
.parameters(parameters(dashboardName, "Errors", "data")));
assertThat(waitForTerminal(stackName, 60)).isEqualTo("UPDATE_COMPLETE");
assertThat(output("DashboardRef")).as("a body or tag change updates in place").isEqualTo(dashboardName);
assertThat(get(dashboardName).dashboardBody()).contains("\"Errors\"");
assertThat(tags(created.dashboardArn())).containsExactlyEntriesOf(Map.of("team", "data"));
String renamed = dashboardName + "-v2";
cloudFormation.updateStack(r -> r
.stackName(stackName)
.templateBody(template())
.parameters(parameters(renamed, "Errors", "data")));
assertThat(waitForTerminal(stackName, 60)).isEqualTo("UPDATE_COMPLETE");
assertThat(output("DashboardRef")).as("a name change replaces the dashboard").isEqualTo(renamed);
assertThat(get(renamed).dashboardBody()).contains("\"Errors\"");
assertDashboardMissing(dashboardName, "the displaced dashboard is deleted once the update commits");
cloudFormation.deleteStack(r -> r.stackName(stackName));
waitForDeleted(stackName, 60);
assertDashboardMissing(renamed, "the stack delete removes the dashboard");
}
/**
* GetDashboard answers ResourceNotFound with HTTP 404 for a dashboard that does not exist. The
* code is asserted rather than the exception class: the SDK resolves the class from the JSON
* error type, and the code is what both the Query and JSON wire forms carry.
*/
private static void assertDashboardMissing(String name, String because) {
assertThatThrownBy(() -> get(name))
.as(because)
.isInstanceOfSatisfying(CloudWatchException.class, e -> {
assertThat(e.statusCode()).isEqualTo(404);
assertThat(e.awsErrorDetails().errorCode()).isEqualTo("ResourceNotFound");
});
}
private static GetDashboardResponse get(String name) {
return cloudWatch.getDashboard(r -> r.dashboardName(name));
}
private static Map<String, String> tags(String arn) {
return cloudWatch.listTagsForResource(r -> r.resourceARN(arn)).tags().stream()
.collect(Collectors.toMap(Tag::key, Tag::value));
}
private static String template() {
return """
{
"Parameters": {
"Name": {"Type": "String"},
"Title": {"Type": "String"},
"Team": {"Type": "String"}
},
"Resources": {
"Dashboard": {
"Type": "AWS::CloudWatch::Dashboard",
"Properties": {
"DashboardName": {"Ref": "Name"},
"DashboardBody": {"Fn::Join": ["", [
"{\\"widgets\\":[{\\"type\\":\\"text\\",\\"properties\\":{\\"markdown\\":\\"",
{"Ref": "Title"},
"\\"}}]}"
]]},
"Tags": [{"Key": "team", "Value": {"Ref": "Team"}}]
}
}
},
"Outputs": {
"DashboardRef": {"Value": {"Ref": "Dashboard"}}
}
}
""";
}
private static List<Parameter> parameters(String name, String title, String team) {
return List.of(
Parameter.builder().parameterKey("Name").parameterValue(name).build(),
Parameter.builder().parameterKey("Title").parameterValue(title).build(),
Parameter.builder().parameterKey("Team").parameterValue(team).build());
}
private static String output(String key) {
Stack stack = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(stackName).build()).stacks().get(0);
return stack.outputs().stream()
.filter(o -> key.equals(o.outputKey()))
.map(Output::outputValue)
.findFirst()
.orElseThrow(() -> new AssertionError("stack " + stackName + " has no output " + key));
}
private static String waitForTerminal(String name, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
List<Stack> stacks = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(name).build()).stacks();
if (!stacks.isEmpty()) {
String status = stacks.get(0).stackStatusAsString();
if (!status.endsWith("_IN_PROGRESS")) {
return status;
}
}
Thread.sleep(500);
}
throw new AssertionError(
"Stack " + name + " did not reach a terminal state within " + maxSeconds + "s");
}
private static void waitForDeleted(String name, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
try {
List<Stack> stacks = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(name).build()).stacks();
if (stacks.isEmpty()
|| "DELETE_COMPLETE".equals(stacks.get(0).stackStatusAsString())) {
return;
}
} catch (CloudFormationException e) {
if (e.getMessage() != null && e.getMessage().contains("does not exist")) {
return;
}
throw e;
}
Thread.sleep(500);
}
throw new AssertionError(
"Stack " + name + " was not deleted within " + maxSeconds + "s");
}
}
@@ -0,0 +1,195 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.CloudFormationException;
import software.amazon.awssdk.services.cloudformation.model.CreateStackRequest;
import software.amazon.awssdk.services.cloudformation.model.DeleteStackRequest;
import software.amazon.awssdk.services.cloudformation.model.DescribeStacksRequest;
import software.amazon.awssdk.services.cloudformation.model.Output;
import software.amazon.awssdk.services.cloudformation.model.Stack;
import software.amazon.awssdk.services.eventbridge.EventBridgeClient;
import software.amazon.awssdk.services.eventbridge.model.DescribeEventBusRequest;
import software.amazon.awssdk.services.eventbridge.model.DescribeEventBusResponse;
import software.amazon.awssdk.services.eventbridge.model.ListTagsForResourceRequest;
import software.amazon.awssdk.services.eventbridge.model.ResourceNotFoundException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("CloudFormation AWS::Events::EventBus")
class CloudFormationEventBusTest {
private static CloudFormationClient cloudFormation;
private static EventBridgeClient eventBridge;
private static String stackName;
private static String busName;
@BeforeAll
static void setup() {
cloudFormation = TestFixtures.cloudFormationClient();
eventBridge = TestFixtures.eventBridgeClient();
stackName = TestFixtures.uniqueName("compat-cfn-eventbus");
busName = TestFixtures.uniqueName("compat-eventbus");
}
@AfterAll
static void cleanup() {
if (cloudFormation != null) {
try {
cloudFormation.deleteStack(
DeleteStackRequest.builder().stackName(stackName).build());
} catch (Exception e) {
System.err.println("CloudFormation EventBus cleanup skipped: " + e.getMessage());
}
cloudFormation.close();
}
if (eventBridge != null) {
eventBridge.close();
}
}
@Test
void createsValidatesUpdatesAndDeletesEventBusThroughAwsSdk() throws InterruptedException {
cloudFormation.createStack(CreateStackRequest.builder()
.stackName(stackName)
.templateBody(template("before", "old", "remove"))
.build());
assertThat(waitForTerminal(stackName, 30)).isEqualTo("CREATE_COMPLETE");
Stack createdStack = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(stackName).build())
.stacks().get(0);
Map<String, String> outputs = createdStack.outputs().stream()
.collect(Collectors.toMap(Output::outputKey, Output::outputValue));
String busArn = "arn:aws:events:us-east-1:000000000000:event-bus/" + busName;
assertThat(outputs)
.containsEntry("BusRef", busName)
.containsEntry("BusName", busName)
.containsEntry("BusArn", busArn);
DescribeEventBusResponse created = eventBridge.describeEventBus(
DescribeEventBusRequest.builder().name(busName).build());
assertThat(created.name()).isEqualTo(busName);
assertThat(created.arn()).isEqualTo(busArn);
assertThat(created.description()).isEqualTo("before");
assertThat(tags(busArn))
.containsEntry("keep", "old")
.containsEntry("remove", "old");
cloudFormation.updateStack(r -> r
.stackName(stackName)
.templateBody(template("before", "old", "remove")));
assertThat(waitForTerminal(stackName, 30)).isEqualTo("UPDATE_COMPLETE");
cloudFormation.updateStack(r -> r
.stackName(stackName)
.templateBody(template("after", "new", "add")));
assertThat(waitForTerminal(stackName, 30)).isEqualTo("UPDATE_ROLLBACK_COMPLETE");
DescribeEventBusResponse afterRejectedUpdate = eventBridge.describeEventBus(
DescribeEventBusRequest.builder().name(busName).build());
assertThat(afterRejectedUpdate.description()).isEqualTo("before");
assertThat(tags(busArn))
.containsEntry("keep", "old")
.containsEntry("remove", "old")
.doesNotContainKey("add");
cloudFormation.deleteStack(DeleteStackRequest.builder().stackName(stackName).build());
waitForDeleted(stackName, 30);
assertThatThrownBy(() -> eventBridge.describeEventBus(
DescribeEventBusRequest.builder().name(busName).build()))
.isInstanceOf(ResourceNotFoundException.class);
}
private static Map<String, String> tags(String busArn) {
return eventBridge.listTagsForResource(ListTagsForResourceRequest.builder()
.resourceARN(busArn)
.build())
.tags().stream()
.collect(Collectors.toMap(
software.amazon.awssdk.services.eventbridge.model.Tag::key,
software.amazon.awssdk.services.eventbridge.model.Tag::value));
}
private static String template(String description, String keepValue, String secondTagKey) {
return """
{
"Resources": {
"Bus": {
"Type": "AWS::Events::EventBus",
"Properties": {
"Name": "%s",
"Description": "%s",
"Tags": [
{"Key": "keep", "Value": "%s"},
{"Key": "%s", "Value": "%s"}
]
}
},
"Rule": {
"Type": "AWS::Events::Rule",
"Properties": {
"Name": "%s",
"EventBusName": {"Ref": "Bus"},
"EventPattern": {"source": ["com.example.orders"]},
"State": "ENABLED"
}
}
},
"Outputs": {
"BusRef": {"Value": {"Ref": "Bus"}},
"BusArn": {"Value": {"Fn::GetAtt": ["Bus", "Arn"]}},
"BusName": {"Value": {"Fn::GetAtt": ["Bus", "Name"]}}
}
}
""".formatted(
busName, description, keepValue, secondTagKey, keepValue, busName + "-rule");
}
private static String waitForTerminal(String name, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
List<Stack> stacks = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(name).build()).stacks();
if (!stacks.isEmpty()) {
String status = stacks.get(0).stackStatusAsString();
if (!status.endsWith("_IN_PROGRESS")) {
return status;
}
}
Thread.sleep(500);
}
throw new AssertionError(
"Stack " + name + " did not reach a terminal state within " + maxSeconds + "s");
}
private static void waitForDeleted(String name, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
try {
List<Stack> stacks = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(name).build()).stacks();
if (stacks.isEmpty()
|| "DELETE_COMPLETE".equals(stacks.get(0).stackStatusAsString())) {
return;
}
} catch (CloudFormationException e) {
if (e.getMessage() != null && e.getMessage().contains("does not exist")) {
return;
}
throw e;
}
Thread.sleep(500);
}
throw new AssertionError(
"Stack " + name + " was not deleted within " + maxSeconds + "s");
}
}
@@ -0,0 +1,201 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.*;
import software.amazon.awssdk.services.lambda.LambdaClient;
import software.amazon.awssdk.services.lambda.model.ListEventSourceMappingsResponse;
import software.amazon.awssdk.services.sqs.SqsClient;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/**
* Verifies that CloudFormation AWS::Lambda::EventSourceMapping provisions and
* deletes an ESM backed by an SQS queue, matching the use-case from issue #593.
*/
@DisplayName("CloudFormation AWS::Lambda::EventSourceMapping")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CloudFormationEventSourceMappingTest {
private static final String STACK_NAME = "compat-cfn-esm-stack";
private static final String FUNC_NAME = "compat-cfn-esm-func";
private static final String QUEUE_NAME = "compat-cfn-esm-queue";
private static final String ROLE = "arn:aws:iam::000000000000:role/cfn-lambda-role";
private static final String ACCOUNT = "000000000000";
private static final String REGION = "us-east-1";
private static CloudFormationClient cfn;
private static LambdaClient lambda;
private static SqsClient sqs;
private static String esmUuid;
@BeforeAll
static void setup() {
cfn = TestFixtures.cloudFormationClient();
lambda = TestFixtures.lambdaClient();
sqs = TestFixtures.sqsClient();
}
@AfterAll
static void cleanup() {
try {
if (cfn != null) {
cfn.deleteStack(DeleteStackRequest.builder().stackName(STACK_NAME).build());
}
} catch (Exception ignored) {}
if (cfn != null) cfn.close();
if (lambda != null) lambda.close();
if (sqs != null) sqs.close();
}
@Test
@Order(1)
@DisplayName("CreateStack with Lambda + SQS + EventSourceMapping reaches CREATE_COMPLETE")
void createStack_withEventSourceMapping() throws InterruptedException {
String queueArn = "arn:aws:sqs:" + REGION + ":" + ACCOUNT + ":" + QUEUE_NAME;
String template = """
{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "%s"
}
},
"MyFunction": {
"Type": "AWS::Lambda::Function",
"Properties": {
"FunctionName": "%s",
"Runtime": "nodejs20.x",
"Handler": "index.handler",
"Role": "%s",
"Code": {
"ZipFile": "exports.handler = async (e) => ({ statusCode: 200 });"
}
}
},
"MyESM": {
"Type": "AWS::Lambda::EventSourceMapping",
"Properties": {
"FunctionName": "%s",
"EventSourceArn": { "Fn::GetAtt": ["MyQueue", "Arn"] },
"Enabled": true,
"BatchSize": 5
}
}
}
}
""".formatted(QUEUE_NAME, FUNC_NAME, ROLE, FUNC_NAME);
cfn.createStack(CreateStackRequest.builder()
.stackName(STACK_NAME)
.templateBody(template)
.build());
String status = waitForTerminal(STACK_NAME, 30);
assertThat(status).isEqualTo("CREATE_COMPLETE");
}
@Test
@Order(2)
@DisplayName("ESM resource appears in DescribeStackResources with CREATE_COMPLETE")
void esmResourceIsComplete() {
List<StackResource> resources = cfn.describeStackResources(
DescribeStackResourcesRequest.builder().stackName(STACK_NAME).build()
).stackResources();
StackResource esmResource = resources.stream()
.filter(r -> "AWS::Lambda::EventSourceMapping".equals(r.resourceType()))
.findFirst()
.orElseThrow(() -> new AssertionError("No EventSourceMapping resource found"));
assertThat(esmResource.resourceStatusAsString()).isEqualTo("CREATE_COMPLETE");
assertThat(esmResource.physicalResourceId()).isNotBlank();
esmUuid = esmResource.physicalResourceId();
}
@Test
@Order(3)
@DisplayName("ListEventSourceMappings returns ESM linked to the Lambda function")
void esmAppearsInLambdaList() {
ListEventSourceMappingsResponse resp = lambda.listEventSourceMappings(
r -> r.functionName(FUNC_NAME));
assertThat(resp.eventSourceMappings()).isNotEmpty();
boolean found = resp.eventSourceMappings().stream()
.anyMatch(e -> e.functionArn().contains(FUNC_NAME)
&& e.eventSourceArn().contains(QUEUE_NAME));
assertThat(found).as("ESM for queue %s not found in listing", QUEUE_NAME).isTrue();
}
@Test
@Order(4)
@DisplayName("GetEventSourceMapping by UUID matches the created ESM")
void getEventSourceMappingByUuid() {
assertThat(esmUuid).as("ESM UUID must have been captured in earlier test").isNotNull();
var esm = lambda.getEventSourceMapping(r -> r.uuid(esmUuid));
assertThat(esm.uuid()).isEqualTo(esmUuid);
assertThat(esm.functionArn()).contains(FUNC_NAME);
assertThat(esm.eventSourceArn()).contains(QUEUE_NAME);
assertThat(esm.batchSize()).isEqualTo(5);
assertThat(esm.state()).isIn("Enabled", "Enabling");
}
@Test
@Order(5)
@DisplayName("DeleteStack removes the ESM")
void deleteStack_removesEsm() throws InterruptedException {
assertThat(esmUuid).as("ESM UUID must have been captured in earlier test").isNotNull();
cfn.deleteStack(DeleteStackRequest.builder().stackName(STACK_NAME).build());
waitForDeleted(STACK_NAME, 30);
assertThatThrownBy(() -> lambda.getEventSourceMapping(r -> r.uuid(esmUuid)))
.isInstanceOf(software.amazon.awssdk.services.lambda.model.ResourceNotFoundException.class);
}
private String waitForTerminal(String stackName, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
List<Stack> stacks = cfn.describeStacks(
DescribeStacksRequest.builder().stackName(stackName).build()
).stacks();
if (!stacks.isEmpty()) {
String status = stacks.get(0).stackStatusAsString();
if (!status.endsWith("_IN_PROGRESS")) {
return status;
}
}
Thread.sleep(500);
}
throw new AssertionError("Stack " + stackName + " did not reach terminal state within " + maxSeconds + "s");
}
private void waitForDeleted(String stackName, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
try {
List<Stack> stacks = cfn.describeStacks(
DescribeStacksRequest.builder().stackName(stackName).build()
).stacks();
if (stacks.isEmpty() || "DELETE_COMPLETE".equals(stacks.get(0).stackStatusAsString())) {
return;
}
} catch (CloudFormationException e) {
if (e.getMessage() != null && e.getMessage().contains("does not exist")) {
return;
}
throw e;
}
Thread.sleep(500);
}
throw new AssertionError("Stack " + stackName + " was not deleted within " + maxSeconds + "s");
}
}
@@ -0,0 +1,212 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.CloudFormationException;
import software.amazon.awssdk.services.cloudformation.model.CreateStackRequest;
import software.amazon.awssdk.services.cloudformation.model.DeleteStackRequest;
import software.amazon.awssdk.services.cloudformation.model.DescribeStacksRequest;
import software.amazon.awssdk.services.cloudformation.model.Output;
import software.amazon.awssdk.services.cloudformation.model.Stack;
import software.amazon.awssdk.services.iam.IamClient;
import software.amazon.awssdk.services.iam.model.CreateLoginProfileRequest;
import software.amazon.awssdk.services.iam.model.DeleteLoginProfileRequest;
import software.amazon.awssdk.services.iam.model.DeleteUserRequest;
import software.amazon.awssdk.services.iam.model.GetUserRequest;
import software.amazon.awssdk.services.iam.model.GetUserResponse;
import software.amazon.awssdk.services.iam.model.NoSuchEntityException;
import software.amazon.awssdk.services.iam.model.User;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("CloudFormation AWS::IAM::User")
class CloudFormationIamUserTest {
private static final Logger LOG = Logger.getLogger(CloudFormationIamUserTest.class.getName());
private static CloudFormationClient cloudFormation;
private static IamClient iam;
private static String stackName;
private static String userName;
@BeforeAll
static void setup() {
cloudFormation = TestFixtures.cloudFormationClient();
iam = TestFixtures.iamClient();
stackName = TestFixtures.uniqueName("compat-cfn-iam-user");
userName = TestFixtures.uniqueName("compat-user");
}
@AfterAll
static void cleanup() {
if (cloudFormation != null) {
try {
cloudFormation.deleteStack(
DeleteStackRequest.builder().stackName(stackName).build());
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to delete CloudFormation IAM user test stack: " + stackName, e);
}
cloudFormation.close();
}
if (iam != null) {
try {
iam.deleteUser(DeleteUserRequest.builder().userName(userName).build());
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to delete IAM user directly during cleanup: " + userName, e);
}
iam.close();
}
}
@Test
void createsValidatesUpdatesAndDeletesIamUserThroughAwsSdk() throws InterruptedException {
cloudFormation.createStack(CreateStackRequest.builder()
.stackName(stackName)
.templateBody(template(userName, "/initial/"))
.build());
assertThat(waitForTerminal(stackName, 30)).isEqualTo("CREATE_COMPLETE");
Stack createdStack = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(stackName).build())
.stacks().get(0);
Map<String, String> outputs = createdStack.outputs().stream()
.collect(Collectors.toMap(Output::outputKey, Output::outputValue));
assertThat(outputs).containsEntry("UserRef", userName);
String userArn = outputs.get("UserArn");
assertThat(userArn).startsWith("arn:aws:iam::").endsWith(":user/initial/" + userName);
GetUserResponse userResponse = iam.getUser(GetUserRequest.builder().userName(userName).build());
User user = userResponse.user();
assertThat(user.userName()).isEqualTo(userName);
assertThat(user.path()).isEqualTo("/initial/");
assertThat(user.arn()).isEqualTo(userArn);
String userId = user.userId();
assertThat(userId).startsWith("AIDA");
// Update path on existing user
cloudFormation.updateStack(r -> r
.stackName(stackName)
.templateBody(template(userName, "/updated/")));
assertThat(waitForTerminal(stackName, 30)).isEqualTo("UPDATE_COMPLETE");
GetUserResponse updatedUserResponse = iam.getUser(GetUserRequest.builder().userName(userName).build());
assertThat(updatedUserResponse.user().path()).isEqualTo("/updated/");
assertThat(updatedUserResponse.user().userId()).isEqualTo(userId);
// Delete stack and verify user is removed
cloudFormation.deleteStack(DeleteStackRequest.builder().stackName(stackName).build());
waitForDeleted(stackName, 30);
assertThatThrownBy(() -> iam.getUser(GetUserRequest.builder().userName(userName).build()))
.isInstanceOf(NoSuchEntityException.class);
}
@Test
@DisplayName("Deleting a stack removes a user whose login profile was created through the IAM API")
void deleteStackRemovesUserWithLoginProfile() throws InterruptedException {
String profileStackName = TestFixtures.uniqueName("compat-cfn-iam-profile");
String profileUserName = TestFixtures.uniqueName("compat-profile-user");
try {
cloudFormation.createStack(CreateStackRequest.builder()
.stackName(profileStackName)
.templateBody(template(profileUserName, "/"))
.build());
assertThat(waitForTerminal(profileStackName, 30)).isEqualTo("CREATE_COMPLETE");
iam.createLoginProfile(CreateLoginProfileRequest.builder()
.userName(profileUserName).password("Sdk-Test-P4ssword!").build());
cloudFormation.deleteStack(DeleteStackRequest.builder().stackName(profileStackName).build());
waitForDeleted(profileStackName, 30);
assertThatThrownBy(() -> iam.getUser(GetUserRequest.builder().userName(profileUserName).build()))
.isInstanceOf(NoSuchEntityException.class);
} finally {
try {
cloudFormation.deleteStack(DeleteStackRequest.builder().stackName(profileStackName).build());
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to delete CloudFormation stack during cleanup: " + profileStackName, e);
}
try {
iam.deleteLoginProfile(DeleteLoginProfileRequest.builder().userName(profileUserName).build());
} catch (Exception ignored) {
// The stack delete normally removes the profile with the user, so this is only a fallback.
}
try {
iam.deleteUser(DeleteUserRequest.builder().userName(profileUserName).build());
} catch (Exception ignored) {
// The stack delete normally removes the user, so this is only a fallback.
}
}
}
private static String template(String name, String path) {
return """
{
"Resources": {
"AppUser": {
"Type": "AWS::IAM::User",
"Properties": {
"UserName": "%s",
"Path": "%s"
}
}
},
"Outputs": {
"UserRef": {"Value": {"Ref": "AppUser"}},
"UserArn": {"Value": {"Fn::GetAtt": ["AppUser", "Arn"]}}
}
}
""".formatted(name, path);
}
private static String waitForTerminal(String name, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
List<Stack> stacks = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(name).build()).stacks();
if (!stacks.isEmpty()) {
String status = stacks.get(0).stackStatusAsString();
if (!status.endsWith("_IN_PROGRESS")) {
return status;
}
}
Thread.sleep(500);
}
throw new AssertionError(
"Stack " + name + " did not reach a terminal state within " + maxSeconds + "s");
}
private static void waitForDeleted(String name, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
try {
List<Stack> stacks = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(name).build()).stacks();
if (stacks.isEmpty()
|| "DELETE_COMPLETE".equals(stacks.get(0).stackStatusAsString())) {
return;
}
} catch (CloudFormationException e) {
if (e.getMessage() != null && e.getMessage().contains("does not exist")) {
return;
}
throw e;
}
Thread.sleep(500);
}
throw new AssertionError(
"Stack " + name + " was not deleted within " + maxSeconds + "s");
}
}
@@ -0,0 +1,236 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.CloudFormationException;
import software.amazon.awssdk.services.cloudformation.model.DescribeStacksRequest;
import software.amazon.awssdk.services.cloudformation.model.Output;
import software.amazon.awssdk.services.cloudformation.model.Parameter;
import software.amazon.awssdk.services.cloudformation.model.Stack;
import software.amazon.awssdk.services.cloudformation.model.StackResource;
import software.amazon.awssdk.services.lambda.LambdaClient;
import software.amazon.awssdk.services.lambda.model.GetFunctionEventInvokeConfigResponse;
import software.amazon.awssdk.services.lambda.model.ResourceNotFoundException;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Drives an {@code AWS::Lambda::EventInvokeConfig} through the CloudFormation SDK and reads it
* back through the Lambda SDK: the physical id is {@code FunctionName|Qualifier}, a settings
* change updates in place, a qualifier change replaces the configuration and removes the
* displaced one, and the stack delete removes it.
*/
@DisplayName("CloudFormation AWS::Lambda::EventInvokeConfig")
class CloudFormationLambdaEventInvokeConfigTest {
private static final String ROLE = "arn:aws:iam::000000000000:role/cfn-lambda-role";
private static final String DLQ_ARN = "arn:aws:sqs:us-east-1:000000000000:compat-async-dlq";
private static CloudFormationClient cloudFormation;
private static LambdaClient lambda;
private static String functionStackName;
private static String stackName;
private static String functionName;
@BeforeAll
static void setup() {
cloudFormation = TestFixtures.cloudFormationClient();
lambda = TestFixtures.lambdaClient();
functionStackName = TestFixtures.uniqueName("compat-cfn-eic-fn");
stackName = TestFixtures.uniqueName("compat-cfn-eic");
functionName = TestFixtures.uniqueName("eic-fn");
}
@AfterAll
static void cleanup() {
if (cloudFormation != null) {
for (String name : List.of(stackName, functionStackName)) {
try {
cloudFormation.deleteStack(r -> r.stackName(name));
waitForDeleted(name, 60);
} catch (Exception e) {
System.err.println("CloudFormation event invoke config cleanup skipped: " + e.getMessage());
}
}
cloudFormation.close();
}
if (lambda != null) {
lambda.close();
}
}
@Test
void configurationFollowsTheTemplateThroughCreateUpdateReplacementAndDelete() throws InterruptedException {
cloudFormation.createStack(r -> r.stackName(functionStackName).templateBody(functionTemplate()));
assertThat(waitForTerminal(functionStackName, 60)).isEqualTo("CREATE_COMPLETE");
String version = output(functionStackName, "Version");
cloudFormation.createStack(r -> r
.stackName(stackName)
.templateBody(configTemplate())
.parameters(parameters("$LATEST", "1")));
assertThat(waitForTerminal(stackName, 60)).isEqualTo("CREATE_COMPLETE");
assertThat(output(stackName, "ConfigRef"))
.as("Ref is the composite FunctionName|Qualifier identifier")
.isEqualTo(functionName + "|$LATEST");
StackResource resource = cloudFormation.describeStackResources(r -> r.stackName(stackName))
.stackResources().stream()
.filter(sr -> "AWS::Lambda::EventInvokeConfig".equals(sr.resourceType()))
.findFirst()
.orElseThrow();
assertThat(resource.physicalResourceId()).isEqualTo(functionName + "|$LATEST");
GetFunctionEventInvokeConfigResponse created = get("$LATEST");
assertThat(created.functionArn()).endsWith(":function:" + functionName + ":$LATEST");
assertThat(created.maximumRetryAttempts()).isEqualTo(1);
assertThat(created.maximumEventAgeInSeconds()).isEqualTo(300);
assertThat(created.destinationConfig().onFailure().destination()).isEqualTo(DLQ_ARN);
cloudFormation.updateStack(r -> r
.stackName(stackName)
.templateBody(configTemplate())
.parameters(parameters("$LATEST", "0")));
assertThat(waitForTerminal(stackName, 60)).isEqualTo("UPDATE_COMPLETE");
assertThat(output(stackName, "ConfigRef"))
.as("a settings change updates the configuration in place")
.isEqualTo(functionName + "|$LATEST");
GetFunctionEventInvokeConfigResponse updated = get("$LATEST");
assertThat(updated.maximumRetryAttempts()).isEqualTo(0);
assertThat(updated.maximumEventAgeInSeconds()).isEqualTo(300);
cloudFormation.updateStack(r -> r
.stackName(stackName)
.templateBody(configTemplate())
.parameters(parameters(version, "0")));
assertThat(waitForTerminal(stackName, 60)).isEqualTo("UPDATE_COMPLETE");
assertThat(output(stackName, "ConfigRef"))
.as("a qualifier change replaces the configuration")
.isEqualTo(functionName + "|" + version);
assertThat(get(version).functionArn()).endsWith(":function:" + functionName + ":" + version);
assertThatThrownBy(() -> get("$LATEST"))
.as("the displaced configuration is deleted once the update commits")
.isInstanceOf(ResourceNotFoundException.class);
cloudFormation.deleteStack(r -> r.stackName(stackName));
waitForDeleted(stackName, 60);
assertThatThrownBy(() -> get(version)).isInstanceOf(ResourceNotFoundException.class);
}
private static GetFunctionEventInvokeConfigResponse get(String qualifier) {
return lambda.getFunctionEventInvokeConfig(r -> r.functionName(functionName).qualifier(qualifier));
}
private static String functionTemplate() {
return """
{
"Resources": {
"Fn": {
"Type": "AWS::Lambda::Function",
"Properties": {
"FunctionName": "%s",
"Runtime": "nodejs20.x",
"Handler": "index.handler",
"Role": "%s",
"Code": {"ZipFile": "exports.handler = async (e) => ({ statusCode: 200 });"}
}
},
"Ver": {
"Type": "AWS::Lambda::Version",
"Properties": {"FunctionName": {"Ref": "Fn"}}
}
},
"Outputs": {
"Version": {"Value": {"Fn::GetAtt": ["Ver", "Version"]}}
}
}
""".formatted(functionName, ROLE);
}
private static String configTemplate() {
return """
{
"Parameters": {
"Qualifier": {"Type": "String"},
"Retries": {"Type": "String"}
},
"Resources": {
"AsyncConfig": {
"Type": "AWS::Lambda::EventInvokeConfig",
"Properties": {
"FunctionName": "%s",
"Qualifier": {"Ref": "Qualifier"},
"MaximumRetryAttempts": {"Ref": "Retries"},
"MaximumEventAgeInSeconds": 300,
"DestinationConfig": {"OnFailure": {"Destination": "%s"}}
}
}
},
"Outputs": {
"ConfigRef": {"Value": {"Ref": "AsyncConfig"}}
}
}
""".formatted(functionName, DLQ_ARN);
}
private static List<Parameter> parameters(String qualifier, String retries) {
return List.of(
Parameter.builder().parameterKey("Qualifier").parameterValue(qualifier).build(),
Parameter.builder().parameterKey("Retries").parameterValue(retries).build());
}
private static String output(String stack, String key) {
Stack described = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(stack).build()).stacks().get(0);
return described.outputs().stream()
.filter(o -> key.equals(o.outputKey()))
.map(Output::outputValue)
.findFirst()
.orElseThrow(() -> new AssertionError("stack " + stack + " has no output " + key));
}
private static String waitForTerminal(String name, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
List<Stack> stacks = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(name).build()).stacks();
if (!stacks.isEmpty()) {
String status = stacks.get(0).stackStatusAsString();
if (!status.endsWith("_IN_PROGRESS")) {
return status;
}
}
Thread.sleep(500);
}
throw new AssertionError(
"Stack " + name + " did not reach a terminal state within " + maxSeconds + "s");
}
private static void waitForDeleted(String name, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
try {
List<Stack> stacks = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(name).build()).stacks();
if (stacks.isEmpty()
|| "DELETE_COMPLETE".equals(stacks.get(0).stackStatusAsString())) {
return;
}
} catch (CloudFormationException e) {
if (e.getMessage() != null && e.getMessage().contains("does not exist")) {
return;
}
throw e;
}
Thread.sleep(500);
}
throw new AssertionError(
"Stack " + name + " was not deleted within " + maxSeconds + "s");
}
}
@@ -0,0 +1,171 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.*;
import software.amazon.awssdk.services.lambda.LambdaClient;
import software.amazon.awssdk.services.lambda.model.GetFunctionResponse;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/**
* Verifies that CloudFormation AWS::Lambda::Function with inline ZipFile source code
* reaches CREATE_COMPLETE instead of failing with "Illegal base64 character".
* Reproduces https://github.com/floci-io/floci/issues/607
*/
@DisplayName("CloudFormation Lambda inline ZipFile")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CloudFormationLambdaInlineZipTest {
private static final String STACK_NAME = "compat-cfn-inline-zip-stack";
private static final String FUNC_NAME = "compat-cfn-inline-zip-fn";
private static final String ROLE = "arn:aws:iam::000000000000:role/cfn-lambda-role";
private static CloudFormationClient cfn;
private static LambdaClient lambda;
@BeforeAll
static void setup() {
cfn = TestFixtures.cloudFormationClient();
lambda = TestFixtures.lambdaClient();
}
@AfterAll
static void cleanup() {
if (cfn != null) {
try {
cfn.deleteStack(DeleteStackRequest.builder().stackName(STACK_NAME).build());
} catch (Exception ignored) {}
cfn.close();
}
if (lambda != null) {
lambda.close();
}
}
// ── Node.js inline source ──────────────────────────────────────────────────
@Test
@Order(1)
@DisplayName("CreateStack with Node.js ZipFile source reaches CREATE_COMPLETE")
void createStack_nodejsInlineZipFile() throws InterruptedException {
String template = """
{
"Resources": {
"MyFunction": {
"Type": "AWS::Lambda::Function",
"Properties": {
"FunctionName": "%s",
"Runtime": "nodejs20.x",
"Handler": "index.handler",
"Role": "%s",
"Code": {
"ZipFile": "exports.handler = async (e) => ({ statusCode: 200, body: 'ok' });"
}
}
}
}
}
""".formatted(FUNC_NAME, ROLE);
cfn.createStack(CreateStackRequest.builder()
.stackName(STACK_NAME)
.templateBody(template)
.build());
String status = waitForTerminal(STACK_NAME, 30);
assertThat(status)
.as("Stack must reach CREATE_COMPLETE, not fail with base64 error")
.isEqualTo("CREATE_COMPLETE");
}
@Test
@Order(2)
@DisplayName("Lambda function created by inline ZipFile stack is discoverable")
void lambdaFunctionExists() {
GetFunctionResponse fn = lambda.getFunction(r -> r.functionName(FUNC_NAME));
assertThat(fn.configuration().functionName()).isEqualTo(FUNC_NAME);
assertThat(fn.configuration().runtimeAsString()).isEqualTo("nodejs20.x");
assertThat(fn.configuration().handler()).isEqualTo("index.handler");
}
@Test
@Order(3)
@DisplayName("DescribeStackResources shows Lambda in CREATE_COMPLETE")
void stackResourceIsComplete() {
List<StackResource> resources = cfn.describeStackResources(
DescribeStackResourcesRequest.builder().stackName(STACK_NAME).build()
).stackResources();
assertThat(resources).hasSize(1);
StackResource r = resources.get(0);
assertThat(r.resourceType()).isEqualTo("AWS::Lambda::Function");
assertThat(r.resourceStatusAsString()).isEqualTo("CREATE_COMPLETE");
}
// ── Python inline source ───────────────────────────────────────────────────
@Test
@Order(4)
@DisplayName("CreateStack with Python ZipFile source reaches CREATE_COMPLETE")
void createStack_pythonInlineZipFile() throws InterruptedException {
String pythonStack = STACK_NAME + "-py";
String pythonFunc = FUNC_NAME + "-py";
String template = """
{
"Resources": {
"PyFunction": {
"Type": "AWS::Lambda::Function",
"Properties": {
"FunctionName": "%s",
"Runtime": "python3.12",
"Handler": "lambda_function.lambda_handler",
"Role": "%s",
"Code": {
"ZipFile": "def lambda_handler(event, context):\\n return {'statusCode': 200}"
}
}
}
}
}
""".formatted(pythonFunc, ROLE);
cfn.createStack(CreateStackRequest.builder()
.stackName(pythonStack)
.templateBody(template)
.build());
String status = waitForTerminal(pythonStack, 30);
assertThat(status)
.as("Python inline ZipFile stack must reach CREATE_COMPLETE")
.isEqualTo("CREATE_COMPLETE");
GetFunctionResponse fn = lambda.getFunction(r -> r.functionName(pythonFunc));
assertThat(fn.configuration().runtimeAsString()).isEqualTo("python3.12");
assertThat(fn.configuration().handler()).isEqualTo("lambda_function.lambda_handler");
cfn.deleteStack(DeleteStackRequest.builder().stackName(pythonStack).build());
}
// ── helpers ────────────────────────────────────────────────────────────────
private String waitForTerminal(String stackName, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
List<Stack> stacks = cfn.describeStacks(
DescribeStacksRequest.builder().stackName(stackName).build()
).stacks();
if (!stacks.isEmpty()) {
String status = stacks.get(0).stackStatusAsString();
if (!status.endsWith("_IN_PROGRESS")) {
return status;
}
}
Thread.sleep(500);
}
throw new AssertionError("Stack " + stackName + " did not reach a terminal state within " + maxSeconds + "s");
}
}
@@ -0,0 +1,270 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.CloudFormationException;
import software.amazon.awssdk.services.cloudformation.model.Output;
import software.amazon.awssdk.services.cloudformation.model.Parameter;
import software.amazon.awssdk.services.cloudformation.model.Stack;
import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient;
import software.amazon.awssdk.services.cloudwatchlogs.model.InputLogEvent;
import software.amazon.awssdk.services.cloudwatchlogs.model.LogStream;
import software.amazon.awssdk.services.cloudwatchlogs.model.OutputLogEvent;
import software.amazon.awssdk.services.cloudwatchlogs.model.ResourceNotFoundException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BooleanSupplier;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("CloudFormation AWS::Logs::LogStream")
class CloudFormationLogStreamTest {
private static CloudFormationClient cfn;
private static CloudWatchLogsClient logs;
private String stackName;
private boolean stackCreated;
private final List<String> logGroups = new ArrayList<>();
@BeforeAll
static void clients() {
cfn = TestFixtures.cloudFormationClient();
logs = TestFixtures.cloudWatchLogsClient();
}
@BeforeEach
void setup() {
stackName = TestFixtures.uniqueName("compat-cfn-log-stream");
}
@AfterEach
void cleanup() throws InterruptedException {
try {
deleteStack();
} finally {
for (String group : logGroups) {
try {
logs.deleteLogGroup(r -> r.logGroupName(group));
} catch (ResourceNotFoundException ignored) {
// Stack rollback may already have removed a group created by the template.
}
}
}
}
@AfterAll
static void closeClients() {
cfn.close();
logs.close();
}
@Test
void createsWritableStreamFromGroupReferenceAndDeletesItFromRetainedGroup() throws InterruptedException {
String group = "/test/" + stackName;
logGroups.add(group);
String template = """
{
"Resources": {
"Group": {
"Type": "AWS::Logs::LogGroup",
"DeletionPolicy": "Retain",
"Properties": {"LogGroupName": "%s"}
},
"Stream": {
"Type": "AWS::Logs::LogStream",
"Properties": {
"LogGroupName": {"Ref": "Group"},
"LogStreamName": "application"
}
}
},
"Outputs": {"StreamRef": {"Value": {"Ref": "Stream"}}}
}
""".formatted(group);
createStack(template, List.of());
assertIdentity("application");
assertThat(streamNames(group)).containsExactly("application");
assertWritable(group, output(), "created through CloudFormation");
deleteStack();
assertThat(streamNames(group))
.as("the retained group survives, but its CloudFormation-managed stream is deleted")
.isEmpty();
}
@Test
void movingNamedStreamToAnotherGroupRemovesTheOldStreamAndPreservesUnrelatedStreams()
throws InterruptedException {
String firstGroup = createLogGroup("first");
String secondGroup = createLogGroup("second");
logs.createLogStream(r -> r.logGroupName(firstGroup).logStreamName("unrelated"));
logs.createLogStream(r -> r.logGroupName(secondGroup).logStreamName("unrelated"));
String template = """
{
"Parameters": {"GroupName": {"Type": "String"}},
"Resources": {
"Stream": {
"Type": "AWS::Logs::LogStream",
"Properties": {
"LogGroupName": {"Ref": "GroupName"},
"LogStreamName": "application"
}
}
},
"Outputs": {"StreamRef": {"Value": {"Ref": "Stream"}}}
}
""";
createStack(template, groupParameter(firstGroup));
assertIdentity("application");
assertWritable(firstGroup, output(), "before replacement");
cfn.updateStack(r -> r.stackName(stackName).templateBody(template)
.parameters(groupParameter(secondGroup)));
awaitStatus("UPDATE_COMPLETE");
assertIdentity("application");
assertThat(streamNames(firstGroup)).containsExactly("unrelated");
assertThat(streamNames(secondGroup)).containsExactlyInAnyOrder("application", "unrelated");
assertWritable(secondGroup, output(), "after replacement");
deleteStack();
assertThat(streamNames(firstGroup)).containsExactly("unrelated");
assertThat(streamNames(secondGroup)).containsExactly("unrelated");
}
@Test
void noValueGeneratesANameUntilTheConditionSelectsAnExplicitName() throws InterruptedException {
String group = createLogGroup("conditional");
String template = """
{
"Parameters": {"UseName": {"Type": "String"}},
"Conditions": {"Named": {"Fn::Equals": [{"Ref": "UseName"}, "true"]}},
"Resources": {
"Stream": {
"Type": "AWS::Logs::LogStream",
"Properties": {
"LogGroupName": "%s",
"LogStreamName": {"Fn::If": ["Named", "application", {"Ref": "AWS::NoValue"}]}
}
}
},
"Outputs": {"StreamRef": {"Value": {"Ref": "Stream"}}}
}
""".formatted(group);
createStack(template, List.of(Parameter.builder().parameterKey("UseName").parameterValue("false").build()));
String generated = output();
assertThat(generated).startsWith(stackName + "-Stream-");
assertIdentity(generated);
assertThat(streamNames(group)).containsExactly(generated);
assertWritable(group, generated, "generated from NoValue");
cfn.updateStack(r -> r.stackName(stackName).templateBody(template)
.parameters(Parameter.builder().parameterKey("UseName").parameterValue("true").build()));
awaitStatus("UPDATE_COMPLETE");
assertIdentity("application");
assertThat(streamNames(group)).containsExactly("application");
deleteStack();
assertThat(streamNames(group)).isEmpty();
}
private void createStack(String template, List<Parameter> parameters) throws InterruptedException {
cfn.createStack(r -> r.stackName(stackName).templateBody(template).parameters(parameters));
stackCreated = true;
awaitStatus("CREATE_COMPLETE");
}
private String createLogGroup(String suffix) {
String group = "/test/" + stackName + "/" + suffix;
logs.createLogGroup(r -> r.logGroupName(group));
logGroups.add(group);
return group;
}
private static List<Parameter> groupParameter(String group) {
return List.of(Parameter.builder().parameterKey("GroupName").parameterValue(group).build());
}
private List<String> streamNames(String group) {
return logs.describeLogStreamsPaginator(r -> r.logGroupName(group)).logStreams().stream()
.map(LogStream::logStreamName).toList();
}
private String output() {
return cfn.describeStacks(r -> r.stackName(stackName)).stacks().get(0).outputs().stream()
.filter(o -> "StreamRef".equals(o.outputKey())).map(Output::outputValue)
.findFirst().orElseThrow();
}
private void assertIdentity(String streamName) {
assertThat(output()).as("Ref is the log stream name").isEqualTo(streamName);
assertThat(cfn.describeStackResource(r -> r.stackName(stackName).logicalResourceId("Stream"))
.stackResourceDetail().physicalResourceId()).isEqualTo(streamName);
}
private void assertWritable(String group, String stream, String message) throws InterruptedException {
logs.putLogEvents(r -> r.logGroupName(group).logStreamName(stream)
.logEvents(InputLogEvent.builder().timestamp(Instant.now().toEpochMilli()).message(message).build()));
await(() -> logs.getLogEvents(r -> r.logGroupName(group).logStreamName(stream).startFromHead(true))
.events().stream().map(OutputLogEvent::message).toList().equals(List.of(message)),
"the event written to " + group + "/" + stream);
}
private void awaitStatus(String expected) throws InterruptedException {
await(() -> {
Stack stack = cfn.describeStacks(r -> r.stackName(stackName)).stacks().get(0);
String status = stack.stackStatusAsString();
if (status.endsWith("_FAILED") || status.contains("ROLLBACK")) {
throw new AssertionError(stackName + " reached " + status + ": " + stack.stackStatusReason());
}
return expected.equals(status);
}, expected);
}
private void deleteStack() throws InterruptedException {
if (!stackCreated) {
return;
}
cfn.deleteStack(r -> r.stackName(stackName));
await(() -> {
try {
List<Stack> stacks = cfn.describeStacks(r -> r.stackName(stackName)).stacks();
if (stacks.isEmpty()) {
return true;
}
Stack stack = stacks.get(0);
if ("DELETE_FAILED".equals(stack.stackStatusAsString())) {
throw new AssertionError(stackName + " deletion failed: " + stack.stackStatusReason());
}
return "DELETE_COMPLETE".equals(stack.stackStatusAsString());
} catch (CloudFormationException e) {
if ("ValidationError".equals(e.awsErrorDetails().errorCode())
&& e.getMessage().contains("does not exist")) {
return true;
}
throw e;
}
}, "stack deletion");
stackCreated = false;
}
private void await(BooleanSupplier condition, String expected) throws InterruptedException {
long deadline = System.nanoTime() + 60_000_000_000L;
while (System.nanoTime() < deadline) {
if (condition.getAsBoolean()) {
return;
}
Thread.sleep(100);
}
throw new AssertionError(stackName + " timed out waiting for " + expected);
}
}
@@ -0,0 +1,328 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.CloudFormationException;
import software.amazon.awssdk.services.cloudformation.model.Output;
import software.amazon.awssdk.services.cloudformation.model.Parameter;
import software.amazon.awssdk.services.cloudformation.model.StackEvent;
import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
import software.amazon.awssdk.services.cloudwatch.model.Datapoint;
import software.amazon.awssdk.services.cloudwatch.model.Dimension;
import software.amazon.awssdk.services.cloudwatch.model.Statistic;
import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient;
import software.amazon.awssdk.services.cloudwatchlogs.model.InputLogEvent;
import software.amazon.awssdk.services.cloudwatchlogs.model.MetricFilter;
import software.amazon.awssdk.services.cloudwatchlogs.model.MetricTransformation;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BooleanSupplier;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
/** SDK checks of backing resources and emitted metrics, not just CFN success-shaped status. */
@DisplayName("CloudFormation AWS::Logs::MetricFilter")
class CloudFormationLogsMetricFilterTest {
private static CloudFormationClient cfn;
private static CloudWatchLogsClient logs;
private static CloudWatchClient metrics;
private String stack;
private String group;
private Set<String> priorEvents = Set.of();
@BeforeAll
static void clients() {
cfn = TestFixtures.cloudFormationClient();
logs = TestFixtures.cloudWatchLogsClient();
metrics = TestFixtures.cloudWatchClient();
}
@BeforeEach
void setup() {
stack = TestFixtures.uniqueName("compat-cfn-metric-filter");
group = "/test/" + stack;
logs.createLogGroup(r -> r.logGroupName(group));
logs.createLogStream(r -> r.logGroupName(group).logStreamName("events"));
}
@AfterEach
void cleanup() throws InterruptedException {
deleteStack();
logs.deleteLogGroup(r -> r.logGroupName(group));
}
@AfterAll
static void closeClients() {
cfn.close();
logs.close();
metrics.close();
}
@Test
void observedRetainReplacementDeletesFirstAtQuotaAndKeepsUnrelatedFilters() throws InterruptedException {
String template = template("", "");
create(template, "errors", "ERROR", "1");
assertIdentity("errors");
update(template("Retain", ""), "errors", "WARN", "2", "UPDATE_COMPLETE");
assertIdentity("errors");
assertThat(filter("errors").filterPattern()).isEqualTo("WARN");
assertSample("WARN", 2, null);
assertThat(cfn.getTemplate(r -> r.stackName(stack)).templateBody()).contains("\"UpdateReplacePolicy\": \"Retain\"");
for (int i = 0; i < 99; i++) {
String name = "unrelated-" + i;
logs.putMetricFilter(r -> r.logGroupName(group).filterName(name).filterPattern("UNRELATED")
.metricTransformations(MetricTransformation.builder().metricName("Untouched")
.metricNamespace(stack + "/unrelated").metricValue("7").build()));
}
Map<String, MetricFilter> unrelated = filters().stream().filter(f -> f.filterName().startsWith("unrelated-"))
.collect(Collectors.toMap(MetricFilter::filterName, f -> f));
update(template("Retain", ""), "renamed", "WARN", "2", "UPDATE_COMPLETE");
assertIdentity("renamed");
assertThat(filters()).hasSize(100).noneMatch(f -> "errors".equals(f.filterName()));
assertUnrelated(unrelated);
assertSequence(List.of("errors:UPDATE_IN_PROGRESS", "errors:DELETE_IN_PROGRESS", "errors:DELETE_COMPLETE",
"renamed:CREATE_IN_PROGRESS", "renamed:CREATE_COMPLETE", "renamed:UPDATE_COMPLETE"));
assertThat(newEvents()).noneMatch(e -> "DELETE_SKIPPED".equals(e.resourceStatusAsString()));
assertThat(newEvents()).anyMatch(e -> "DELETE_IN_PROGRESS".equals(e.resourceStatusAsString())
&& ("Requested update requires the replacement of the existing resource; "
+ "deleting existing resource, then creating a new one.").equals(e.resourceStatusReason()));
deleteStack();
assertThat(filters()).hasSize(99);
assertUnrelated(unrelated);
}
@Test
void failedReplacementRestoresActualDefinitionTemplateAndNameOnlyIdentity() throws InterruptedException {
String template = template("", "");
create(template, "old", "WARN", "2");
MetricFilter before = filter("old");
update(template, "invalid", "{", "9", "UPDATE_ROLLBACK_COMPLETE");
assertIdentity("old");
assertThat(filters()).hasSize(1);
MetricFilter after = filter("old");
assertThat(after.toBuilder().creationTime(before.creationTime()).build()).isEqualTo(before);
assertThat(after.creationTime()).isGreaterThan(before.creationTime());
assertThat(cfn.getTemplate(r -> r.stackName(stack)).templateBody()).isEqualTo(template);
assertSequence(List.of("old:UPDATE_IN_PROGRESS", "old:DELETE_IN_PROGRESS", "old:DELETE_COMPLETE",
"invalid:CREATE_IN_PROGRESS", "invalid:CREATE_FAILED", "invalid:UPDATE_FAILED",
"invalid:UPDATE_IN_PROGRESS", "invalid:DELETE_IN_PROGRESS", "invalid:DELETE_COMPLETE",
"invalid:CREATE_IN_PROGRESS", "old:CREATE_COMPLETE", "old:UPDATE_COMPLETE"));
assertSample("WARN", 2, null);
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void laterDependentFailureRestoresTheFilterThatActuallyMutated(boolean replacement) throws InterruptedException {
String template = template("", "");
create(template, "old", "WARN", "2");
MetricFilter before = filter("old");
String target = replacement ? "new" : "old";
update(template("", """
,"BadSecret":{"Type":"AWS::SecretsManager::Secret","DependsOn":"Filter","Properties":{
"Name":"%s-bad","SecretString":"explicit","GenerateSecretString":{"PasswordLength":32}}}
""".formatted(stack)), target, "FATAL", "9", "UPDATE_ROLLBACK_COMPLETE");
assertIdentity("old");
MetricFilter after = filter("old");
assertThat(after.toBuilder().creationTime(before.creationTime()).build()).isEqualTo(before);
assertThat(filters()).hasSize(1);
List<StackEvent> events = newEvents();
int changed = indexOf(events, "Filter", "UPDATE_COMPLETE", target);
int failed = indexOf(events, "BadSecret", "UPDATE_FAILED", null);
assertThat(changed).as("filter update completed before dependent failure").isGreaterThanOrEqualTo(0);
assertThat(failed).isGreaterThan(changed);
assertSample("WARN", 2, null);
}
@Test
void generatedNameStaysStableWhenPatternAndValueReallyChange() throws InterruptedException {
String template = """
{"Resources":{"Filter":{"Type":"AWS::Logs::MetricFilter","Properties":{
"LogGroupName":"%s","FilterPattern":"{ $.latency = * }",
"MetricTransformations":[{"MetricName":"Count","MetricNamespace":"%s","MetricValue":"$.latency",
"Dimensions":[{"Key":"Route","Value":"$.route"}],"Unit":"Count"}]}}},
"Outputs":{"FilterRef":{"Value":{"Ref":"Filter"}}}}
""".formatted(group, stack);
cfn.createStack(r -> r.stackName(stack).templateBody(template));
awaitStatus("CREATE_COMPLETE");
String name = output();
assertThat(name).startsWith(stack + "-Filter-");
captureEvents();
cfn.updateStack(r -> r.stackName(stack).templateBody(template.replace("$.latency = *", "$.latency > 10")
.replace("\"MetricValue\":\"$.latency\"", "\"MetricValue\":\"3\"")));
awaitStatus("UPDATE_COMPLETE");
assertIdentity(name);
assertThat(filters()).hasSize(1);
assertThat(filter(name).filterPattern()).isEqualTo("{ $.latency > 10 }");
assertThat(filter(name).metricTransformations().get(0).metricValue()).isEqualTo("3");
assertSample("{\"latency\":20,\"route\":\"/orders\"}", 3, "/orders");
}
@Test
void collidingReplacementNeverAdoptsOrRollbackDeletesUnrelatedFilter() throws InterruptedException {
create(template("", ""), "old", "WARN", "2");
logs.putMetricFilter(r -> r.logGroupName(group).filterName("taken").filterPattern("UNRELATED")
.metricTransformations(MetricTransformation.builder().metricName("Other")
.metricNamespace(stack + "/unrelated").metricValue("7").build()));
MetricFilter unrelated = filter("taken");
update(template("", ""), "taken", "FATAL", "9", "UPDATE_ROLLBACK_COMPLETE");
assertIdentity("old");
assertThat(filter("taken")).isEqualTo(unrelated);
assertThat(filters()).hasSize(2);
assertSample("WARN", 2, null);
deleteStack();
assertThat(filters()).containsExactly(unrelated);
}
private void create(String template, String name, String pattern, String value) throws InterruptedException {
cfn.createStack(r -> r.stackName(stack).templateBody(template).parameters(parameters(name, pattern, value)));
awaitStatus("CREATE_COMPLETE");
}
private void update(String template, String name, String pattern, String value, String status)
throws InterruptedException {
captureEvents();
cfn.updateStack(r -> r.stackName(stack).templateBody(template).parameters(parameters(name, pattern, value)));
awaitStatus(status);
}
private String template(String retain, String extra) {
return """
{"Parameters":{"FilterName":{"Type":"String"},"Pattern":{"Type":"String"},"Value":{"Type":"String"}},
"Resources":{"Filter":{"Type":"AWS::Logs::MetricFilter",%s"Properties":{
"LogGroupName":"%s","FilterName":{"Ref":"FilterName"},"FilterPattern":{"Ref":"Pattern"},
"MetricTransformations":[{"MetricName":"Count","MetricNamespace":"%s","MetricValue":{"Ref":"Value"},
"DefaultValue":7,"Unit":"Count"}],"ApplyOnTransformedLogs":false}}%s},
"Outputs":{"FilterRef":{"Value":{"Ref":"Filter"}}}}
""".formatted(retain.isEmpty() ? "" : "\"UpdateReplacePolicy\": \"" + retain + "\",", group, stack, extra);
}
private static List<Parameter> parameters(String name, String pattern, String value) {
return List.of(Parameter.builder().parameterKey("FilterName").parameterValue(name).build(),
Parameter.builder().parameterKey("Pattern").parameterValue(pattern).build(),
Parameter.builder().parameterKey("Value").parameterValue(value).build());
}
private List<MetricFilter> filters() {
return logs.describeMetricFiltersPaginator(r -> r.logGroupName(group)).metricFilters().stream().toList();
}
private MetricFilter filter(String name) {
return filters().stream().filter(f -> name.equals(f.filterName())).findFirst()
.orElseThrow(() -> new AssertionError("Missing backing filter " + name + ": " + filters()));
}
private void assertUnrelated(Map<String, MetricFilter> expected) {
assertThat(filters().stream().filter(f -> f.filterName().startsWith("unrelated-"))
.collect(Collectors.toMap(MetricFilter::filterName, f -> f))).isEqualTo(expected);
}
private String output() {
return cfn.describeStacks(r -> r.stackName(stack)).stacks().get(0).outputs().stream()
.filter(o -> "FilterRef".equals(o.outputKey())).map(Output::outputValue).findFirst().orElseThrow();
}
private void assertIdentity(String name) {
assertThat(output()).isEqualTo(name);
assertThat(cfn.describeStackResource(r -> r.stackName(stack).logicalResourceId("Filter"))
.stackResourceDetail().physicalResourceId()).isEqualTo(name);
}
private void captureEvents() {
priorEvents = cfn.describeStackEvents(r -> r.stackName(stack)).stackEvents().stream()
.map(StackEvent::eventId).collect(Collectors.toSet());
}
private List<StackEvent> newEvents() {
List<StackEvent> events = new ArrayList<>(cfn.describeStackEvents(r -> r.stackName(stack)).stackEvents());
Collections.reverse(events);
return events.stream().filter(e -> !priorEvents.contains(e.eventId())).toList();
}
private void assertSequence(List<String> expected) {
List<StackEvent> events = newEvents();
assertThat(events).extracting(StackEvent::eventId).doesNotHaveDuplicates().doesNotContainNull();
assertThat(events.stream().filter(e -> "Filter".equals(e.logicalResourceId()))
.map(e -> e.physicalResourceId() + ":" + e.resourceStatusAsString()).toList()).isEqualTo(expected);
}
private static int indexOf(List<StackEvent> events, String logical, String status, String physical) {
for (int i = 0; i < events.size(); i++) {
StackEvent e = events.get(i);
if (logical.equals(e.logicalResourceId()) && status.equals(e.resourceStatusAsString())
&& (physical == null || physical.equals(e.physicalResourceId()))) {
return i;
}
}
return -1;
}
private void awaitStatus(String status) throws InterruptedException {
await(() -> status.equals(cfn.describeStacks(r -> r.stackName(stack)).stacks().get(0).stackStatusAsString())
&& newEvents().stream().anyMatch(e -> stack.equals(e.logicalResourceId())
&& status.equals(e.resourceStatusAsString())), "new operation " + status);
}
private void await(BooleanSupplier condition, String expected) throws InterruptedException {
long deadline = System.nanoTime() + 60_000_000_000L;
while (System.nanoTime() < deadline) {
if (condition.getAsBoolean()) {
return;
}
Thread.sleep(100);
}
throw new AssertionError(stack + " timed out waiting for " + expected + "; events: " + newEvents());
}
private void assertSample(String message, double expected, String route) throws InterruptedException {
Instant timestamp = Instant.now();
logs.putLogEvents(r -> r.logGroupName(group).logStreamName("events")
.logEvents(InputLogEvent.builder().timestamp(timestamp.toEpochMilli()).message(message).build()));
await(() -> {
List<Datapoint> data = metrics.getMetricStatistics(r -> {
r.namespace(stack).metricName("Count").period(60).startTime(timestamp.minusSeconds(60))
.endTime(timestamp.plusSeconds(60)).statistics(Statistic.SUM, Statistic.SAMPLE_COUNT);
if (route != null) {
r.dimensions(Dimension.builder()
.name("Route").value(route).build());
}
}).datapoints();
return data.stream().mapToDouble(Datapoint::sum).sum() == expected
&& data.stream().mapToDouble(Datapoint::sampleCount).sum() == 1;
}, "one published sample of " + expected);
}
private void deleteStack() throws InterruptedException {
try {
cfn.deleteStack(r -> r.stackName(stack));
await(() -> {
try {
return "DELETE_COMPLETE".equals(cfn.describeStacks(r -> r.stackName(stack)).stacks().get(0)
.stackStatusAsString());
} catch (CloudFormationException e) {
if ("ValidationError".equals(e.awsErrorDetails().errorCode()) && e.getMessage().contains("does not exist")) {
return true;
}
throw e;
}
}, "stack deletion");
} catch (CloudFormationException e) {
if (!"ValidationError".equals(e.awsErrorDetails().errorCode()) || !e.getMessage().contains("does not exist")) {
throw e;
}
}
}
}
@@ -0,0 +1,272 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.CreateStackRequest;
import software.amazon.awssdk.services.cloudformation.model.DeleteStackRequest;
import software.amazon.awssdk.services.cloudformation.model.DescribeStacksRequest;
import software.amazon.awssdk.services.cloudformation.model.Stack;
import software.amazon.awssdk.services.cloudformation.model.StackStatus;
import software.amazon.awssdk.services.rds.RdsClient;
import software.amazon.awssdk.services.rds.model.DescribeDbInstancesRequest;
import software.amazon.awssdk.services.ssm.SsmClient;
import software.amazon.awssdk.services.ssm.model.DeleteParameterRequest;
import software.amazon.awssdk.services.ssm.model.ParameterType;
import software.amazon.awssdk.services.ssm.model.PutParameterRequest;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
@DisplayName("CloudFormation RDS credential dynamic references")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CloudFormationRdsDynamicReferenceTest {
private static final String USERNAME = "viewer";
private static final String PASSWORD = "compat-secret-123";
private static final String DATABASE = "app";
private static final List<String> STACK_NAMES = new ArrayList<>();
private static CloudFormationClient cloudFormation;
private static RdsClient rds;
private static SsmClient ssm;
private static String secureParameterName;
private static String plainParameterName;
@BeforeAll
static void setup() {
cloudFormation = TestFixtures.cloudFormationClient();
rds = TestFixtures.rdsClient();
ssm = TestFixtures.ssmClient();
String suffix = TestFixtures.uniqueName("cfn-rds-ref");
secureParameterName = "/" + suffix + "/secure";
plainParameterName = "/" + suffix + "/plain";
ssm.putParameter(PutParameterRequest.builder()
.name(secureParameterName)
.type(ParameterType.SECURE_STRING)
.value(PASSWORD)
.build());
ssm.putParameter(PutParameterRequest.builder()
.name(plainParameterName)
.type(ParameterType.STRING)
.value(PASSWORD)
.build());
}
@AfterAll
static void cleanup() {
if (cloudFormation != null) {
for (String stackName : STACK_NAMES) {
try {
cloudFormation.deleteStack(DeleteStackRequest.builder()
.stackName(stackName)
.build());
} catch (Exception e) {
System.err.printf("Unable to delete compatibility stack %s: %s%n",
stackName, e.getMessage());
}
}
cloudFormation.close();
}
if (ssm != null) {
deleteParameter(secureParameterName);
deleteParameter(plainParameterName);
ssm.close();
}
if (rds != null) {
rds.close();
}
}
@Test
@Order(1)
@DisplayName("ssm-secure resolves into an RDS master password")
void resolvesSecureSsmMasterPassword() throws Exception {
assumeTrue(!Boolean.parseBoolean(System.getenv()
.getOrDefault("FLOCI_SERVICES_RDS_MOCK", "false")),
"Container-backed RDS is required for the credential compatibility test");
assumeTrue(TestFixtures.isLambdaDispatchAvailable(),
"Docker dispatch is unavailable for the RDS container compatibility test");
String stackName = TestFixtures.uniqueName("cfn-rds-secure");
String instanceId = TestFixtures.uniqueName("cfn-rds-secure");
STACK_NAMES.add(stackName);
String template = """
{
"Resources": {
"Database": {
"Type": "AWS::RDS::DBInstance",
"Properties": {
"DBInstanceIdentifier": "%s",
"DBInstanceClass": "db.t3.micro",
"AllocatedStorage": 20,
"Engine": "postgres",
"DBName": "%s",
"MasterUsername": "%s",
"MasterUserPassword": "{{resolve:ssm-secure:%s}}"
}
}
}
}
""".formatted(instanceId, DATABASE, USERNAME, secureParameterName);
cloudFormation.createStack(CreateStackRequest.builder()
.stackName(stackName)
.templateBody(template)
.build());
assertThat(waitForTerminal(stackName, Duration.ofSeconds(90)))
.isEqualTo(StackStatus.CREATE_COMPLETE);
int port = rds.describeDBInstances(DescribeDbInstancesRequest.builder()
.dbInstanceIdentifier(instanceId)
.build())
.dbInstances()
.get(0)
.endpoint()
.port();
try (Connection connection = awaitPostgresConnection(port)) {
assertThat(connection.isValid(5)).isTrue();
}
}
@Test
@Order(2)
@DisplayName("ssm-secure is rejected for RDS MasterUsername")
void rejectsSecureSsmMasterUsername() throws InterruptedException {
String stackName = TestFixtures.uniqueName("cfn-rds-secure-user");
String instanceId = TestFixtures.uniqueName("cfn-rds-secure-user");
STACK_NAMES.add(stackName);
String template = """
{
"Resources": {
"Database": {
"Type": "AWS::RDS::DBInstance",
"Properties": {
"DBInstanceIdentifier": "%s",
"DBInstanceClass": "db.t3.micro",
"AllocatedStorage": 20,
"Engine": "postgres",
"MasterUsername": "{{resolve:ssm-secure:%s}}",
"MasterUserPassword": "%s"
}
}
}
}
""".formatted(instanceId, secureParameterName, PASSWORD);
cloudFormation.createStack(CreateStackRequest.builder()
.stackName(stackName)
.templateBody(template)
.build());
assertThat(waitForTerminal(stackName, Duration.ofSeconds(30)))
.isEqualTo(StackStatus.ROLLBACK_COMPLETE);
}
@Test
@Order(3)
@DisplayName("ssm-secure requires a SecureString parameter")
void rejectsPlaintextParameterForSecureReference() throws InterruptedException {
String stackName = TestFixtures.uniqueName("cfn-rds-wrong-type");
String instanceId = TestFixtures.uniqueName("cfn-rds-wrong-type");
STACK_NAMES.add(stackName);
String template = """
{
"Resources": {
"Database": {
"Type": "AWS::RDS::DBInstance",
"Properties": {
"DBInstanceIdentifier": "%s",
"DBInstanceClass": "db.t3.micro",
"AllocatedStorage": 20,
"Engine": "postgres",
"MasterUsername": "%s",
"MasterUserPassword": "{{resolve:ssm-secure:%s}}"
}
}
}
}
""".formatted(instanceId, USERNAME, plainParameterName);
cloudFormation.createStack(CreateStackRequest.builder()
.stackName(stackName)
.templateBody(template)
.build());
assertThat(waitForTerminal(stackName, Duration.ofSeconds(30)))
.isEqualTo(StackStatus.ROLLBACK_COMPLETE);
}
private static StackStatus waitForTerminal(String stackName, Duration timeout)
throws InterruptedException {
Instant deadline = Instant.now().plus(timeout);
while (Instant.now().isBefore(deadline)) {
List<Stack> stacks = cloudFormation.describeStacks(DescribeStacksRequest.builder()
.stackName(stackName)
.build())
.stacks();
if (!stacks.isEmpty() && !stacks.get(0).stackStatusAsString().endsWith("_IN_PROGRESS")) {
return stacks.get(0).stackStatus();
}
Thread.sleep(500);
}
throw new AssertionError("Stack " + stackName + " did not reach a terminal state within " + timeout);
}
private static Connection awaitPostgresConnection(int port) throws Exception {
Instant deadline = Instant.now().plus(Duration.ofSeconds(60));
SQLException lastFailure = null;
while (Instant.now().isBefore(deadline)) {
try {
Properties properties = new Properties();
properties.setProperty("user", USERNAME);
properties.setProperty("password", PASSWORD);
properties.setProperty("sslmode", "disable");
properties.setProperty("connectTimeout", "5");
return DriverManager.getConnection(
"jdbc:postgresql://" + TestFixtures.proxyHost() + ":" + port + "/" + DATABASE,
properties);
} catch (SQLException e) {
lastFailure = e;
Thread.sleep(1000);
}
}
throw lastFailure != null
? lastFailure
: new SQLException("Timed out waiting for the RDS proxy");
}
private static void deleteParameter(String parameterName) {
if (parameterName == null) {
return;
}
try {
ssm.deleteParameter(DeleteParameterRequest.builder()
.name(parameterName)
.build());
} catch (Exception e) {
System.err.printf("Unable to delete compatibility parameter %s: %s%n",
parameterName, e.getMessage());
}
}
}
@@ -0,0 +1,169 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.core.waiters.WaiterResponse;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.CloudFormationException;
import software.amazon.awssdk.services.cloudformation.model.CreateStackRequest;
import software.amazon.awssdk.services.cloudformation.model.DeleteStackRequest;
import software.amazon.awssdk.services.cloudformation.model.DescribeStacksRequest;
import software.amazon.awssdk.services.cloudformation.model.DescribeStacksResponse;
import software.amazon.awssdk.services.cloudformation.model.Output;
import software.amazon.awssdk.services.cloudformation.model.Stack;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* End-to-end compatibility test provisioning an AWS::Redshift::Cluster
* via CloudFormationClient and validating connectivity over JDBC.
*/
@DisplayName("CloudFormation AWS::Redshift::Cluster")
class CloudFormationRedshiftClusterTest {
private static CloudFormationClient cloudFormation;
private static String stackName;
private static String clusterId;
@BeforeAll
static void setup() {
assumeTrue(TestFixtures.isLambdaDispatchAvailable(),
"Docker dispatch is unavailable for the Redshift container compatibility test");
cloudFormation = TestFixtures.cloudFormationClient();
stackName = TestFixtures.uniqueName("compat-cfn-rs");
clusterId = stackName + "-wh";
}
@AfterAll
static void cleanup() {
if (cloudFormation != null && stackName != null) {
try {
cloudFormation.deleteStack(DeleteStackRequest.builder().stackName(stackName).build());
waitForDeleted(stackName, 60);
} catch (Exception e) {
System.err.println("CloudFormation Redshift cleanup skipped: " + e.getMessage());
}
cloudFormation.close();
}
}
@Test
void provisionsRedshiftClusterAndConnectsViaJdbc() throws Exception {
String template = """
{
"Resources": {
"Warehouse": {
"Type": "AWS::Redshift::Cluster",
"Properties": {
"ClusterIdentifier": "%s",
"NodeType": "ra3.large",
"MasterUsername": "admin",
"MasterUserPassword": "Secret12345",
"ClusterType": "single-node",
"DBName": "dev"
}
}
},
"Outputs": {
"Addr": {"Value": {"Fn::GetAtt": ["Warehouse", "Endpoint.Address"]}},
"Port": {"Value": {"Fn::GetAtt": ["Warehouse", "Endpoint.Port"]}},
"Ns": {"Value": {"Fn::GetAtt": ["Warehouse", "ClusterNamespaceArn"]}}
}
}""".formatted(clusterId);
cloudFormation.createStack(CreateStackRequest.builder()
.stackName(stackName)
.templateBody(template)
.build());
WaiterResponse<DescribeStacksResponse> waiterResponse = cloudFormation.waiter()
.waitUntilStackCreateComplete(r -> r.stackName(stackName));
Stack stack = waiterResponse.matched().response()
.orElseGet(() -> cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(stackName).build()))
.stacks().get(0);
Map<String, String> outputs = stack.outputs().stream()
.collect(Collectors.toMap(Output::outputKey, Output::outputValue));
String addr = outputs.get("Addr");
String portStr = outputs.get("Port");
String ns = outputs.get("Ns");
assertThat(addr).isNotBlank();
assertThat(portStr).isNotBlank();
int port = Integer.parseInt(portStr);
assertThat(port).isPositive();
assertThat(ns).startsWith("arn:aws:redshift:");
String connectHost = "host.docker.internal".equalsIgnoreCase(addr)
? TestFixtures.proxyHost()
: addr;
try (Connection conn = awaitPostgresConnection(connectHost, port, "admin", "Secret12345");
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT 1")) {
assertThat(rs.next()).isTrue();
assertThat(rs.getInt(1)).isEqualTo(1);
}
}
private static Connection awaitPostgresConnection(String host, int port, String username, String password) throws Exception {
Instant deadline = Instant.now().plus(Duration.ofSeconds(60));
SQLException lastFailure = null;
while (Instant.now().isBefore(deadline)) {
try {
Properties properties = new Properties();
properties.setProperty("user", username);
properties.setProperty("password", password);
properties.setProperty("sslmode", "disable");
properties.setProperty("connectTimeout", "5");
return DriverManager.getConnection(
"jdbc:postgresql://" + host + ":" + port + "/dev",
properties);
} catch (SQLException e) {
lastFailure = e;
Thread.sleep(1000);
}
}
throw lastFailure != null
? lastFailure
: new SQLException("Timed out waiting for Redshift connection at " + host + ":" + port);
}
private static void waitForDeleted(String name, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
try {
List<Stack> stacks = cloudFormation.describeStacks(
DescribeStacksRequest.builder().stackName(name).build()).stacks();
if (stacks.isEmpty()
|| "DELETE_COMPLETE".equals(stacks.get(0).stackStatusAsString())) {
return;
}
} catch (CloudFormationException e) {
if (e.getMessage() != null && e.getMessage().contains("does not exist")) {
return;
}
throw e;
}
Thread.sleep(500);
}
}
}
@@ -0,0 +1,170 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.*;
import software.amazon.awssdk.services.sqs.SqsClient;
import software.amazon.awssdk.services.sqs.model.QueueDoesNotExistException;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/**
* Verifies CloudFormation StackSets through the real AWS SDK: a stack set created in the
* administration account provisions instances into target accounts, the management responses
* unmarshal cleanly, and the provisioned resources land in each target account's namespace.
*/
@DisplayName("CloudFormation StackSets cross-account provisioning")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CloudFormationStackSetsTest {
private static final String ACCOUNT_B = "222222222222";
private static final String ACCOUNT_C = "333333333333";
private static final String REGION = "us-east-1";
private static CloudFormationClient cfn;
private static String stackSetName;
private static String queueName;
@BeforeAll
static void setup() {
cfn = TestFixtures.cloudFormationClient();
stackSetName = TestFixtures.uniqueName("compat-stackset");
queueName = TestFixtures.uniqueName("compat-ss-queue");
}
@AfterAll
static void teardown() {
try {
cfn.deleteStackInstances(r -> r.stackSetName(stackSetName)
.accounts(ACCOUNT_B, ACCOUNT_C).regions(REGION).retainStacks(false));
} catch (Exception e) {
System.err.println("teardown: deleteStackInstances for " + stackSetName + " failed: " + e.getMessage());
}
try {
cfn.deleteStackSet(r -> r.stackSetName(stackSetName));
} catch (Exception e) {
System.err.println("teardown: deleteStackSet for " + stackSetName + " failed: " + e.getMessage());
}
cfn.close();
}
private static SqsClient sqsFor(String account) {
return SqsClient.builder()
.endpointOverride(TestFixtures.endpoint())
.region(Region.US_EAST_1)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(account, "test")))
.build();
}
private static String queueTemplate(String queue) {
return "{\"Resources\":{\"Q\":{\"Type\":\"AWS::SQS::Queue\","
+ "\"Properties\":{\"QueueName\":\"" + queue + "\"}}}}";
}
@Test
@Order(1)
void createStackSet() {
CreateStackSetResponse resp = cfn.createStackSet(r -> r
.stackSetName(stackSetName)
.templateBody(queueTemplate(queueName)));
assertThat(resp.stackSetId()).isNotBlank();
}
@Test
@Order(2)
void describeStackSetReturnsDefinition() {
DescribeStackSetResponse resp = cfn.describeStackSet(r -> r.stackSetName(stackSetName));
StackSet ss = resp.stackSet();
assertThat(ss.stackSetName()).isEqualTo(stackSetName);
assertThat(ss.status()).isEqualTo(StackSetStatus.ACTIVE);
assertThat(ss.templateBody()).contains(queueName);
assertThat(ss.autoDeployment()).isNull();
}
@Test
@Order(3)
void createStackInstancesProvisionsIntoTargetAccounts() {
CreateStackInstancesResponse resp = cfn.createStackInstances(r -> r
.stackSetName(stackSetName)
.accounts(ACCOUNT_B, ACCOUNT_C)
.regions(REGION));
assertThat(resp.operationId()).isNotBlank();
// The queue materializes in each target account's namespace...
try (SqsClient b = sqsFor(ACCOUNT_B); SqsClient c = sqsFor(ACCOUNT_C)) {
assertThat(b.getQueueUrl(g -> g.queueName(queueName)).queueUrl())
.contains("/" + ACCOUNT_B + "/" + queueName);
assertThat(c.getQueueUrl(g -> g.queueName(queueName)).queueUrl())
.contains("/" + ACCOUNT_C + "/" + queueName);
}
// ...and not in the administration account.
try (SqsClient admin = TestFixtures.sqsClient()) {
assertThatThrownBy(() -> admin.getQueueUrl(g -> g.queueName(queueName)))
.isInstanceOf(QueueDoesNotExistException.class);
}
}
@Test
@Order(4)
void listStackInstancesReportsTargets() {
List<StackInstanceSummary> summaries = cfn.listStackInstances(r -> r.stackSetName(stackSetName))
.summaries();
assertThat(summaries).hasSize(2);
assertThat(summaries).extracting(StackInstanceSummary::account)
.containsExactlyInAnyOrder(ACCOUNT_B, ACCOUNT_C);
assertThat(summaries).allSatisfy(s -> assertThat(s.region()).isEqualTo(REGION));
// DescribeStackInstance (singular) — the operation DPS's state machine actually calls.
StackInstance inst = cfn.describeStackInstance(r -> r.stackSetName(stackSetName)
.stackInstanceAccount(ACCOUNT_B).stackInstanceRegion(REGION)).stackInstance();
assertThat(inst.account()).isEqualTo(ACCOUNT_B);
assertThat(inst.region()).isEqualTo(REGION);
assertThat(inst.stackId()).isNotBlank();
}
@Test
@Order(5)
void listStackSetOperationsRecordsCreate() {
List<StackSetOperationSummary> ops = cfn.listStackSetOperations(r -> r.stackSetName(stackSetName))
.summaries();
assertThat(ops).isNotEmpty();
assertThat(ops).extracting(StackSetOperationSummary::action)
.contains(StackSetOperationAction.CREATE);
String opId = ops.stream().filter(o -> o.action() == StackSetOperationAction.CREATE)
.findFirst().orElseThrow().operationId();
StackSetOperation op = cfn.describeStackSetOperation(r -> r.stackSetName(stackSetName)
.operationId(opId)).stackSetOperation();
assertThat(op.operationId()).isEqualTo(opId);
assertThat(op.action()).isEqualTo(StackSetOperationAction.CREATE);
assertThat(op.status()).isEqualTo(StackSetOperationStatus.SUCCEEDED);
}
@Test
@Order(6)
void deleteStackInstancesRemovesResources() {
cfn.deleteStackInstances(r -> r.stackSetName(stackSetName)
.accounts(ACCOUNT_B, ACCOUNT_C).regions(REGION).retainStacks(false));
assertThat(cfn.listStackInstances(r -> r.stackSetName(stackSetName)).summaries()).isEmpty();
try (SqsClient b = sqsFor(ACCOUNT_B)) {
assertThatThrownBy(() -> b.getQueueUrl(g -> g.queueName(queueName)))
.isInstanceOf(QueueDoesNotExistException.class);
}
}
@Test
@Order(7)
void deleteStackSetSucceedsWhenEmpty() {
cfn.deleteStackSet(r -> r.stackSetName(stackSetName));
assertThatThrownBy(() -> cfn.describeStackSet(r -> r.stackSetName(stackSetName)))
.isInstanceOf(StackSetNotFoundException.class);
}
}
@@ -0,0 +1,162 @@
package com.floci.test;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.DescribeStackResourcesRequest;
import software.amazon.awssdk.services.cloudformation.model.DescribeStacksRequest;
import software.amazon.awssdk.services.cloudformation.model.Stack;
import software.amazon.awssdk.services.cloudformation.model.StackResource;
import software.amazon.awssdk.services.sfn.SfnClient;
import software.amazon.awssdk.services.sfn.model.DescribeStateMachineResponse;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("CloudFormation Step Functions update")
class CloudFormationStepFunctionsUpdateTest {
private static final Logger LOGGER =
Logger.getLogger(CloudFormationStepFunctionsUpdateTest.class.getName());
private static final String ROLE_ARN =
"arn:aws:iam::000000000000:role/cfn-step-functions-update-role";
private static CloudFormationClient cloudFormation;
private static SfnClient stepFunctions;
private static String stackName;
@BeforeAll
static void setup() {
cloudFormation = TestFixtures.cloudFormationClient();
stepFunctions = TestFixtures.sfnClient();
stackName = TestFixtures.uniqueName("cfn-sfn-update");
}
@AfterAll
static void cleanup() {
if (cloudFormation != null) {
if (stackName != null) {
try {
cloudFormation.deleteStack(request -> request.stackName(stackName));
} catch (Exception e) {
LOGGER.log(Level.WARNING, "Failed to clean up CloudFormation update test", e);
}
}
cloudFormation.close();
}
if (stepFunctions != null) {
stepFunctions.close();
}
}
@Test
void sdkUpdatesStateMachineInPlace() throws InterruptedException {
String stateMachineName = TestFixtures.uniqueName("cfn-managed-sfn");
String initialDefinition =
"{\"StartAt\":\"Initial\",\"States\":{\"Initial\":"
+ "{\"Type\":\"Pass\",\"End\":true}}}";
String updatedDefinition =
"{\"StartAt\":\"Updated\",\"States\":{\"Updated\":"
+ "{\"Type\":\"Pass\",\"End\":true}}}";
cloudFormation.createStack(request -> request
.stackName(stackName)
.templateBody(template(
stateMachineName, initialDefinition, "initial")));
assertThat(waitForTerminalStatus(stackName, 30))
.isEqualTo("CREATE_COMPLETE");
StackResource initialResource = stateMachineResource(stackName);
String stateMachineArn = initialResource.physicalResourceId();
DescribeStateMachineResponse initial =
stepFunctions.describeStateMachine(
request -> request.stateMachineArn(stateMachineArn));
cloudFormation.updateStack(request -> request
.stackName(stackName)
.templateBody(template(
stateMachineName, updatedDefinition, "updated")));
assertThat(waitForTerminalStatus(stackName, 30))
.isEqualTo("UPDATE_COMPLETE");
StackResource updatedResource = stateMachineResource(stackName);
assertThat(updatedResource.physicalResourceId())
.isEqualTo(stateMachineArn);
assertThat(updatedResource.resourceStatusAsString())
.isEqualTo("UPDATE_COMPLETE");
DescribeStateMachineResponse updated =
stepFunctions.describeStateMachine(
request -> request.stateMachineArn(stateMachineArn));
assertThat(updated.definition()).isEqualTo(updatedDefinition);
assertThat(updated.revisionId()).isNotEqualTo(initial.revisionId());
}
private static StackResource stateMachineResource(String name) {
List<StackResource> resources = cloudFormation.describeStackResources(
DescribeStackResourcesRequest.builder()
.stackName(name)
.build()).stackResources();
return resources.stream()
.filter(resource -> "AWS::StepFunctions::StateMachine"
.equals(resource.resourceType()))
.findFirst()
.orElseThrow();
}
private static String waitForTerminalStatus(
String name, int maxSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + maxSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
List<Stack> stacks = cloudFormation.describeStacks(
DescribeStacksRequest.builder()
.stackName(name)
.build()).stacks();
if (!stacks.isEmpty()) {
String status = stacks.get(0).stackStatusAsString();
if (!status.endsWith("_IN_PROGRESS")) {
return status;
}
}
Thread.sleep(250);
}
throw new AssertionError(
"Stack " + name
+ " did not reach a terminal state within "
+ maxSeconds + " seconds");
}
private static String template(
String stateMachineName,
String definition,
String marker) {
String escapedDefinition = definition.replace("\\", "\\\\")
.replace("\"", "\\\"");
return """
{
"Resources": {
"StateMachine": {
"Type": "AWS::StepFunctions::StateMachine",
"Properties": {
"StateMachineName": "%s",
"RoleArn": "%s",
"DefinitionString": "%s"
}
}
},
"Outputs": {
"Marker": {"Value": "%s"}
}
}
""".formatted(
stateMachineName,
ROLE_ARN,
escapedDefinition,
marker);
}
}
@@ -0,0 +1,79 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.core.interceptor.Context;
import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
import software.amazon.awssdk.core.interceptor.ExecutionInterceptor;
import software.amazon.awssdk.http.SdkHttpRequest;
import software.amazon.awssdk.services.cloudformation.CloudFormationClient;
import software.amazon.awssdk.services.cloudformation.model.ListStacksResponse;
import static org.assertj.core.api.Assertions.*;
/**
* Ensures that requests to non-S3 service hostnames (e.g. cloudformation.amazonaws.com)
* are not incorrectly hijacked by the S3 virtual host filter.
*
* <p>The TCP connection lands on the configured Floci endpoint; an execution
* interceptor overrides the Host header so Floci sees the request as though it
* came from {@code cloudformation.us-east-1.amazonaws.com}. This decouples the
* test from DNS setup so it works equally against localhost, a docker service
* name (e.g. {@code http://floci:4566}), or a remote Floci deployment.
*/
@DisplayName("CloudFormation Virtual Host")
class CloudFormationVirtualHostTests {
private static final String VIRTUAL_HOST = "cloudformation.us-east-1.amazonaws.com";
private static CloudFormationClient cfn;
@BeforeAll
static void setup() {
cfn = CloudFormationClient.builder()
.endpointOverride(TestFixtures.endpoint())
.region(software.amazon.awssdk.regions.Region.US_EAST_1)
.credentialsProvider(software.amazon.awssdk.auth.credentials.StaticCredentialsProvider.create(
software.amazon.awssdk.auth.credentials.AwsBasicCredentials.create("test", "test")))
.overrideConfiguration(c -> c.addExecutionInterceptor(new HostHeaderSpoofInterceptor(VIRTUAL_HOST)))
.build();
}
@AfterAll
static void cleanup() {
if (cfn != null) {
cfn.close();
}
}
@Test
@DisplayName("ListStacks - virtual host request succeeds")
void listStacksVirtualHost() {
ListStacksResponse resp = cfn.listStacks();
assertThat(resp.sdkHttpResponse().isSuccessful()).isTrue();
}
/**
* Rewrites the Host header on outbound requests so Floci sees the incoming
* request as virtual-hosted under {@code hostHeader}, while the underlying
* TCP connection still goes to the endpoint configured via
* {@code endpointOverride}.
*/
private static final class HostHeaderSpoofInterceptor implements ExecutionInterceptor {
private final String hostHeader;
HostHeaderSpoofInterceptor(String hostHeader) {
this.hostHeader = hostHeader;
}
@Override
public SdkHttpRequest modifyHttpRequest(Context.ModifyHttpRequest context,
ExecutionAttributes executionAttributes) {
return context.httpRequest().toBuilder()
.putHeader("Host", hostHeader)
.build();
}
}
}
@@ -0,0 +1,131 @@
package com.floci.test;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudfront.CloudFrontClient;
import software.amazon.awssdk.services.cloudfront.model.CreateDistributionResponse;
import software.amazon.awssdk.services.cloudfront.model.CustomHeaders;
import software.amazon.awssdk.services.cloudfront.model.DistributionConfig;
import software.amazon.awssdk.services.cloudfront.model.GetDistributionConfigResponse;
import software.amazon.awssdk.services.cloudfront.model.Origin;
import software.amazon.awssdk.services.cloudfront.model.OriginCustomHeader;
import software.amazon.awssdk.services.cloudfront.model.S3OriginConfig;
import software.amazon.awssdk.services.cloudfront.model.ViewerProtocolPolicy;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
class CloudFrontOriginCustomHeadersTest {
@Test
void roundTripsOriginCustomHeadersWithTheAwsSdk() {
assumeFalse(
TestFixtures.isRealAws(),
"The immediate distribution lifecycle in this test is emulator-specific");
String suffix = TestFixtures.uniqueName("cloudfront-origin-headers");
String distributionId = null;
try (CloudFrontClient cloudFront = TestFixtures.cloudFrontClient()) {
Origin configuredOrigin = Origin.builder()
.id("configured-origin")
.domainName("configured-content.s3.amazonaws.com")
.customHeaders(CustomHeaders.builder()
.quantity(1)
.items(OriginCustomHeader.builder()
.headerName("X-Origin-Verify")
.headerValue("configured-value")
.build())
.build())
.s3OriginConfig(S3OriginConfig.builder()
.originAccessIdentity("")
.build())
.build();
Origin emptyOrigin = Origin.builder()
.id("empty-origin")
.domainName("empty-content.s3.amazonaws.com")
.s3OriginConfig(S3OriginConfig.builder()
.originAccessIdentity("")
.build())
.build();
DistributionConfig configuration = DistributionConfig.builder()
.callerReference("distribution-reference-" + suffix)
.enabled(true)
.origins(origins -> origins
.quantity(2)
.items(configuredOrigin, emptyOrigin))
.defaultCacheBehavior(behavior -> behavior
.targetOriginId(configuredOrigin.id())
.viewerProtocolPolicy(ViewerProtocolPolicy.ALLOW_ALL))
.build();
CreateDistributionResponse created = cloudFront.createDistribution(
request -> request.distributionConfig(configuration));
distributionId = created.distribution().id();
assertOriginShapes(created.distribution()
.distributionConfig()
.origins()
.items());
GetDistributionConfigResponse fetched = cloudFront.getDistributionConfig(
request -> request.id(created.distribution().id()));
assertOriginShapes(fetched.distributionConfig().origins().items());
var listed = cloudFront.listDistributions(request -> request.maxItems("100"))
.distributionList()
.items()
.stream()
.filter(summary -> created.distribution().id().equals(summary.id()))
.findFirst()
.orElseThrow();
assertOriginShapes(listed.origins().items());
} finally {
if (distributionId != null) {
try (CloudFrontClient cloudFront = TestFixtures.cloudFrontClient()) {
String id = distributionId;
GetDistributionConfigResponse current = cloudFront.getDistributionConfig(
request -> request.id(id));
var disabled = current.distributionConfig()
.toBuilder()
.enabled(false)
.build();
var updated = cloudFront.updateDistribution(request -> request
.id(id)
.ifMatch(current.eTag())
.distributionConfig(disabled));
cloudFront.deleteDistribution(request -> request
.id(id)
.ifMatch(updated.eTag()));
}
}
}
}
private static void assertOriginShapes(List<Origin> origins) {
Origin configured = originById(origins, "configured-origin");
assertNotNull(configured.customHeaders());
assertEquals(1, configured.customHeaders().quantity());
assertEquals(1, configured.customHeaders().items().size());
assertEquals(
"X-Origin-Verify",
configured.customHeaders().items().get(0).headerName());
assertEquals(
"configured-value",
configured.customHeaders().items().get(0).headerValue());
Origin empty = originById(origins, "empty-origin");
assertNotNull(empty.customHeaders());
assertEquals(0, empty.customHeaders().quantity());
assertFalse(empty.customHeaders().hasItems());
}
private static Origin originById(List<Origin> origins, String id) {
return origins.stream()
.filter(origin -> id.equals(origin.id()))
.findFirst()
.orElseThrow();
}
}
@@ -0,0 +1,174 @@
package com.floci.test;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudfront.CloudFrontClient;
import software.amazon.awssdk.services.cloudfront.model.CreateResponseHeadersPolicyResponse;
import software.amazon.awssdk.services.cloudfront.model.NoSuchResponseHeadersPolicyException;
import software.amazon.awssdk.services.cloudfront.model.PreconditionFailedException;
import software.amazon.awssdk.services.cloudfront.model.ResponseHeadersPolicyConfig;
import software.amazon.awssdk.services.cloudfront.model.ResponseHeadersPolicyType;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
class CloudFrontResponseHeadersPolicyTest {
@Test
void roundTripsResponseHeadersPolicyWithTheAwsSdk() {
assumeFalse(
TestFixtures.isRealAws(),
"The immediate policy lifecycle in this test is emulator-specific");
String suffix = TestFixtures.uniqueName("cloudfront-response-headers");
String policyId = null;
String etag = null;
try (CloudFrontClient cloudFront = TestFixtures.cloudFrontClient()) {
CreateResponseHeadersPolicyResponse created =
cloudFront.createResponseHeadersPolicy(request -> request
.responseHeadersPolicyConfig(policyConfig(
suffix, "created", "created-value")));
policyId = created.responseHeadersPolicy().id();
etag = created.eTag();
assertNotNull(policyId);
assertNotNull(etag);
assertTrue(created.location().endsWith("/" + policyId));
assertNotNull(created.responseHeadersPolicy().lastModifiedTime());
assertEquals("policy-" + suffix,
created.responseHeadersPolicy()
.responseHeadersPolicyConfig()
.name());
String id = policyId;
var fetched = cloudFront.getResponseHeadersPolicy(
request -> request.id(id));
assertEquals(etag, fetched.eTag());
assertEquals("created",
fetched.responseHeadersPolicy()
.responseHeadersPolicyConfig()
.comment());
assertEquals("created-value",
fetched.responseHeadersPolicy()
.responseHeadersPolicyConfig()
.customHeadersConfig()
.items()
.get(0)
.value());
assertEquals(1,
fetched.responseHeadersPolicy()
.responseHeadersPolicyConfig()
.customHeadersConfig()
.quantity());
assertEquals("X-Floci-Policy",
fetched.responseHeadersPolicy()
.responseHeadersPolicyConfig()
.customHeadersConfig()
.items()
.get(0)
.header());
assertTrue(fetched.responseHeadersPolicy()
.responseHeadersPolicyConfig()
.customHeadersConfig()
.items()
.get(0)
.override());
var fetchedConfig = cloudFront.getResponseHeadersPolicyConfig(
request -> request.id(id));
assertEquals(etag, fetchedConfig.eTag());
assertEquals("created-value",
fetchedConfig.responseHeadersPolicyConfig()
.customHeadersConfig()
.items()
.get(0)
.value());
assertEquals(0.0,
fetchedConfig.responseHeadersPolicyConfig()
.serverTimingHeadersConfig()
.samplingRate());
assertTrue(fetchedConfig.responseHeadersPolicyConfig()
.serverTimingHeadersConfig()
.enabled());
var listed = cloudFront.listResponseHeadersPolicies(request -> request
.type(ResponseHeadersPolicyType.CUSTOM)
.maxItems("100"))
.responseHeadersPolicyList();
assertTrue(listed.items().stream().anyMatch(summary ->
id.equals(summary.responseHeadersPolicy().id())
&& summary.type() == ResponseHeadersPolicyType.CUSTOM));
assertTrue(listed.quantity() >= 1);
assertThrows(PreconditionFailedException.class,
() -> cloudFront.updateResponseHeadersPolicy(request -> request
.id(id)
.ifMatch("stale-etag")
.responseHeadersPolicyConfig(policyConfig(
suffix, "stale", "stale-value"))));
var updated = cloudFront.updateResponseHeadersPolicy(request -> request
.id(id)
.ifMatch(created.eTag())
.responseHeadersPolicyConfig(policyConfig(
suffix, "updated", "updated-value")));
etag = updated.eTag();
assertNotNull(etag);
assertNotEquals(created.eTag(), etag);
assertEquals("updated",
updated.responseHeadersPolicy()
.responseHeadersPolicyConfig()
.comment());
assertEquals("updated-value",
updated.responseHeadersPolicy()
.responseHeadersPolicyConfig()
.customHeadersConfig()
.items()
.get(0)
.value());
String currentEtag = etag;
cloudFront.deleteResponseHeadersPolicy(request -> request
.id(id)
.ifMatch(currentEtag));
policyId = null;
assertThrows(NoSuchResponseHeadersPolicyException.class,
() -> cloudFront.getResponseHeadersPolicy(
request -> request.id(id)));
} finally {
if (policyId != null && etag != null) {
try (CloudFrontClient cloudFront =
TestFixtures.cloudFrontClient()) {
String id = policyId;
String currentEtag = cloudFront
.getResponseHeadersPolicyConfig(
request -> request.id(id))
.eTag();
cloudFront.deleteResponseHeadersPolicy(request -> request
.id(id)
.ifMatch(currentEtag));
}
}
}
}
private static ResponseHeadersPolicyConfig policyConfig(
String suffix, String comment, String headerValue) {
return ResponseHeadersPolicyConfig.builder()
.name("policy-" + suffix)
.comment(comment)
.customHeadersConfig(custom -> custom
.quantity(1)
.items(header -> header
.header("X-Floci-Policy")
.value(headerValue)
.override(true)))
.serverTimingHeadersConfig(serverTiming -> serverTiming
.enabled(true)
.samplingRate(0.0))
.build();
}
}
@@ -0,0 +1,193 @@
package com.floci.test;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudfront.CloudFrontClient;
import software.amazon.awssdk.services.cloudfront.model.CreateDistributionResponse;
import software.amazon.awssdk.services.cloudfront.model.CreateKeyGroupResponse;
import software.amazon.awssdk.services.cloudfront.model.CreatePublicKeyResponse;
import software.amazon.awssdk.services.cloudfront.model.DistributionConfig;
import software.amazon.awssdk.services.cloudfront.model.GetDistributionConfigResponse;
import software.amazon.awssdk.services.cloudfront.model.GetDistributionResponse;
import software.amazon.awssdk.services.cloudfront.model.S3OriginConfig;
import software.amazon.awssdk.services.cloudfront.model.ViewerProtocolPolicy;
import java.security.KeyPairGenerator;
import java.util.Base64;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
class CloudFrontTrustedKeyGroupsTest {
@Test
void roundTripsTrustedKeyGroupsWithTheAwsSdk() throws Exception {
assumeFalse(
TestFixtures.isRealAws(),
"The immediate distribution lifecycle in this test is emulator-specific");
String suffix = TestFixtures.uniqueName("cloudfront-trust");
String publicKeyId = null;
String publicKeyEtag = null;
String keyGroupId = null;
String keyGroupEtag = null;
String distributionId = null;
String encodedPublicKey = rsaPublicKeyPem();
try (CloudFrontClient cloudFront = TestFixtures.cloudFrontClient()) {
CreatePublicKeyResponse publicKey = cloudFront.createPublicKey(
request -> request.publicKeyConfig(config -> config
.callerReference("key-reference-" + suffix)
.name("key-" + suffix)
.encodedKey(encodedPublicKey)));
String createdPublicKeyId = publicKey.publicKey().id();
publicKeyId = createdPublicKeyId;
publicKeyEtag = publicKey.eTag();
CreateKeyGroupResponse keyGroup = cloudFront.createKeyGroup(
request -> request.keyGroupConfig(config -> config
.name("group-" + suffix)
.items(createdPublicKeyId)));
String createdKeyGroupId = keyGroup.keyGroup().id();
keyGroupId = createdKeyGroupId;
keyGroupEtag = keyGroup.eTag();
assertEquals(
List.of(createdPublicKeyId),
keyGroup.keyGroup().keyGroupConfig().items());
assertEquals(
List.of(createdPublicKeyId),
cloudFront.getKeyGroupConfig(
request -> request.id(createdKeyGroupId))
.keyGroupConfig()
.items());
var listedKeyGroup =
cloudFront.listKeyGroups(request -> request.maxItems("100"))
.keyGroupList()
.items()
.stream()
.map(summary -> summary.keyGroup())
.filter(group -> createdKeyGroupId.equals(group.id()))
.findFirst()
.orElseThrow();
assertEquals(
List.of(createdPublicKeyId),
listedKeyGroup.keyGroupConfig().items());
DistributionConfig configuration = DistributionConfig.builder()
.callerReference("distribution-reference-" + suffix)
.enabled(true)
.origins(origins -> origins
.quantity(1)
.items(origin -> origin
.id("origin")
.domainName(
"signed-content.s3.amazonaws.com")
.s3OriginConfig(
S3OriginConfig.builder()
.originAccessIdentity("")
.build())))
.defaultCacheBehavior(behavior -> behavior
.targetOriginId("origin")
.viewerProtocolPolicy(
ViewerProtocolPolicy.ALLOW_ALL)
.trustedKeyGroups(groups -> groups
.enabled(true)
.quantity(1)
.items(createdKeyGroupId)))
.build();
CreateDistributionResponse created =
cloudFront.createDistribution(
request -> request.distributionConfig(configuration));
String createdDistributionId = created.distribution().id();
distributionId = createdDistributionId;
GetDistributionResponse fetched =
cloudFront.getDistribution(
request -> request.id(createdDistributionId));
assertTrue(fetched.distribution()
.activeTrustedKeyGroups().enabled());
assertEquals(
keyGroupId,
fetched.distribution()
.activeTrustedKeyGroups()
.items()
.get(0)
.keyGroupId());
assertEquals(
publicKeyId,
fetched.distribution()
.activeTrustedKeyGroups()
.items()
.get(0)
.keyPairIds()
.items()
.get(0));
assertFalse(fetched.distribution()
.activeTrustedSigners().enabled());
GetDistributionConfigResponse fetchedConfig =
cloudFront.getDistributionConfig(
request -> request.id(createdDistributionId));
assertEquals(
keyGroupId,
fetchedConfig.distributionConfig()
.defaultCacheBehavior()
.trustedKeyGroups()
.items()
.get(0));
} finally {
try (CloudFrontClient cloudFront =
TestFixtures.cloudFrontClient()) {
if (distributionId != null) {
String id = distributionId;
GetDistributionConfigResponse current =
cloudFront.getDistributionConfig(
request -> request.id(id));
var disabled = current.distributionConfig()
.toBuilder()
.enabled(false)
.build();
var updated = cloudFront.updateDistribution(
request -> request
.id(id)
.ifMatch(current.eTag())
.distributionConfig(disabled));
cloudFront.deleteDistribution(
request -> request
.id(id)
.ifMatch(updated.eTag()));
}
if (keyGroupId != null) {
String id = keyGroupId;
String etag = keyGroupEtag;
cloudFront.deleteKeyGroup(
request -> request
.id(id)
.ifMatch(etag));
}
if (publicKeyId != null) {
String id = publicKeyId;
String etag = publicKeyEtag;
cloudFront.deletePublicKey(
request -> request
.id(id)
.ifMatch(etag));
}
}
}
}
private static String rsaPublicKeyPem() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
byte[] encoded = generator.generateKeyPair()
.getPublic()
.getEncoded();
return "-----BEGIN PUBLIC KEY-----\n"
+ Base64.getMimeEncoder().encodeToString(encoded)
+ "\n-----END PUBLIC KEY-----";
}
}
@@ -0,0 +1,183 @@
package com.floci.test;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudhsmv2.CloudHsmV2Client;
import software.amazon.awssdk.services.cloudhsmv2.model.*;
import java.util.List;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemWriter;
import java.io.StringWriter;
import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.cert.X509Certificate;
import java.util.Date;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class CloudHsmV2Test {
private final CloudHsmV2Client client = TestFixtures.cloudHsmV2Client();
@Test
public void testFullLifecycle() {
// 1. Create Cluster
CreateClusterResponse createClusterResponse = client.createCluster(r -> r
.hsmType("hsm1.medium")
.subnetIds("subnet-1", "subnet-2")
.mode(ClusterMode.FIPS)
.networkType(NetworkType.IPV4)
.backupRetentionPolicy(b -> b.type("DAYS").value("30"))
);
Cluster cluster = createClusterResponse.cluster();
assertThat(cluster.clusterId()).startsWith("cluster-");
assertThat(cluster.hsmType()).isEqualTo("hsm1.medium");
assertThat(cluster.stateAsString()).isEqualTo("UNINITIALIZED");
assertThat(cluster.certificates().clusterCsr()).isNotBlank();
String clusterId = cluster.clusterId();
// 2. Initialize Cluster
DescribeClustersResponse initDescribe = client.describeClusters(r -> r
.filters(java.util.Map.of("clusterIds", List.of(clusterId))));
String csr = initDescribe.clusters().get(0).certificates().clusterCsr();
String[] certs;
try { certs = generateCerts(csr); } catch (Exception e) { throw new RuntimeException(e); }
String signedCert = certs[0];
String trustAnchor = certs[1];
InitializeClusterResponse initResp = client.initializeCluster(r -> r
.clusterId(clusterId)
.signedCert(signedCert)
.trustAnchor(trustAnchor)
);
assertThat(initResp.stateAsString()).isEqualTo("INITIALIZED");
// 3. Create HSM
CreateHsmResponse hsmResp = client.createHsm(r -> r
.clusterId(clusterId)
.availabilityZone("us-east-1a")
.ipAddress("10.0.1.5")
);
Hsm hsm = hsmResp.hsm();
assertThat(hsm.hsmId()).startsWith("hsm-");
assertThat(hsm.stateAsString()).isEqualTo("ACTIVE");
String hsmId = hsm.hsmId();
// 4. Describe Clusters
DescribeClustersResponse descClusters = client.describeClusters(r -> r
.filters(java.util.Map.of("clusterIds", List.of(clusterId)))
);
assertThat(descClusters.clusters()).hasSize(1);
assertThat(descClusters.clusters().get(0).stateAsString()).isEqualTo("ACTIVE");
// 5. Describe Backups (auto-created on CreateHsm/CreateCluster)
DescribeBackupsResponse descBackups = client.describeBackups(r -> r
.filters(java.util.Map.of("clusterIds", List.of(clusterId)))
);
assertThat(descBackups.backups()).isNotEmpty();
String backupId = descBackups.backups().get(0).backupId();
// 6. Delete HSM
DeleteHsmResponse delHsm = client.deleteHsm(r -> r
.clusterId(clusterId)
.hsmId(hsmId)
);
assertThat(delHsm.hsmId()).isEqualTo(hsmId);
// 7. Delete Cluster
DeleteClusterResponse delCluster = client.deleteCluster(r -> r
.clusterId(clusterId)
);
assertThat(delCluster.cluster().stateAsString()).isEqualTo("DELETE_IN_PROGRESS");
// 8. Delete Backup
DeleteBackupResponse delBackup = client.deleteBackup(r -> r
.backupId(backupId)
);
assertThat(delBackup.backup().backupStateAsString()).isEqualTo("PENDING_DELETION");
}
@Test
public void testDeleteHsmSelectors() {
CreateClusterResponse createClusterResponse = client.createCluster(r -> r
.hsmType("hsm1.medium")
.subnetIds("subnet-1", "subnet-2")
);
String clusterId = createClusterResponse.cluster().clusterId();
DescribeClustersResponse initDescribe = client.describeClusters(r -> r
.filters(java.util.Map.of("clusterIds", List.of(clusterId))));
String csr = initDescribe.clusters().get(0).certificates().clusterCsr();
String[] certs;
try { certs = generateCerts(csr); } catch (Exception e) { throw new RuntimeException(e); }
String signedCert = certs[0];
String trustAnchor = certs[1];
client.initializeCluster(r -> r.clusterId(clusterId).signedCert(signedCert).trustAnchor(trustAnchor));
Hsm hsm = client.createHsm(r -> r.clusterId(clusterId).availabilityZone("us-east-1b")).hsm();
// Test exactly one selector rule
assertThatThrownBy(() -> client.deleteHsm(r -> r
.clusterId(clusterId)
.hsmId(hsm.hsmId())
.eniId(hsm.eniId())
)).hasMessageContaining("Exactly one of HsmId, EniId, or EniIp must be specified");
// Test successful delete with EniId
DeleteHsmResponse delResp = client.deleteHsm(r -> r
.clusterId(clusterId)
.eniId(hsm.eniId())
);
assertThat(delResp.hsmId()).isEqualTo(hsm.hsmId());
}
private String[] generateCerts(String csrPem) throws Exception {
java.security.Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider());
java.security.KeyPairGenerator keyGen = java.security.KeyPairGenerator.getInstance("RSA", "BC");
keyGen.initialize(2048, new java.security.SecureRandom());
java.security.KeyPair caKeyPair = keyGen.generateKeyPair();
org.bouncycastle.asn1.x500.X500Name caName = new org.bouncycastle.asn1.x500.X500Name("CN=Floci Test CA");
long now = System.currentTimeMillis();
java.util.Date startDate = new java.util.Date(now);
java.util.Date endDate = new java.util.Date(now + 365L * 24 * 3600 * 1000);
java.math.BigInteger serial = java.math.BigInteger.valueOf(now);
org.bouncycastle.cert.X509v3CertificateBuilder caBuilder = new org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder(
caName, serial, startDate, endDate, caName, caKeyPair.getPublic());
org.bouncycastle.operator.ContentSigner caSigner = new org.bouncycastle.operator.jcajce.JcaContentSignerBuilder("SHA256WithRSA")
.setProvider("BC").build(caKeyPair.getPrivate());
org.bouncycastle.cert.X509CertificateHolder caHolder = caBuilder.build(caSigner);
java.io.StringWriter caSw = new java.io.StringWriter();
try (org.bouncycastle.openssl.jcajce.JcaPEMWriter pw = new org.bouncycastle.openssl.jcajce.JcaPEMWriter(caSw)) {
pw.writeObject(caHolder);
}
String trustAnchor = caSw.toString();
org.bouncycastle.pkcs.PKCS10CertificationRequest csr;
try (org.bouncycastle.openssl.PEMParser parser = new org.bouncycastle.openssl.PEMParser(new java.io.StringReader(csrPem))) {
csr = (org.bouncycastle.pkcs.PKCS10CertificationRequest) parser.readObject();
}
java.security.PublicKey csrPublicKey = new org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter().setProvider("BC").getPublicKey(csr.getSubjectPublicKeyInfo());
org.bouncycastle.cert.X509v3CertificateBuilder certBuilder = new org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder(
caName, serial.add(java.math.BigInteger.ONE), startDate, endDate, csr.getSubject(), csrPublicKey);
org.bouncycastle.cert.X509CertificateHolder certHolder = certBuilder.build(caSigner);
java.io.StringWriter certSw = new java.io.StringWriter();
try (org.bouncycastle.openssl.jcajce.JcaPEMWriter pw = new org.bouncycastle.openssl.jcajce.JcaPEMWriter(certSw)) {
pw.writeObject(certHolder);
}
String signedCert = certSw.toString();
return new String[]{signedCert, trustAnchor};
}
}
@@ -0,0 +1,196 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.servicediscovery.ServiceDiscoveryClient;
import software.amazon.awssdk.services.servicediscovery.model.*;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
/**
* Cloud Map (servicediscovery) validated through the real AWS SDK v2 client.
* Exercises the Phase 1 flow: create namespace → poll operation → create service →
* register instance → discover → deregister → delete.
*/
@DisplayName("Cloud Map")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CloudMapTest {
private static ServiceDiscoveryClient sd;
private static String namespaceName;
private static String serviceName;
private static String namespaceId;
private static String serviceId;
private static final String INSTANCE_ID = "i-cm-sdk-1";
@BeforeAll
static void setup() {
sd = TestFixtures.serviceDiscoveryClient();
namespaceName = TestFixtures.uniqueName("cm-ns");
serviceName = TestFixtures.uniqueName("cm-svc");
}
@AfterAll
static void cleanup() {
try {
sd.deregisterInstance(r -> r.serviceId(serviceId).instanceId(INSTANCE_ID));
} catch (Exception ignored) {}
try {
sd.deleteService(r -> r.id(serviceId));
} catch (Exception ignored) {}
try {
sd.deleteNamespace(r -> r.id(namespaceId));
} catch (Exception ignored) {}
sd.close();
}
private static void awaitSuccess(String operationId) {
for (int i = 0; i < 20; i++) {
GetOperationResponse op = sd.getOperation(r -> r.operationId(operationId));
if (op.operation().status() == OperationStatus.SUCCESS) {
return;
}
if (op.operation().status() == OperationStatus.FAIL) {
fail("Operation " + operationId + " failed: " + op.operation().errorMessage());
}
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
}
fail("Operation " + operationId + " did not reach SUCCESS");
}
@Test
@Order(1)
void createHttpNamespace() {
CreateHttpNamespaceResponse resp = sd.createHttpNamespace(
r -> r.name(namespaceName).description("floci sdk compat"));
assertThat(resp.operationId()).isNotBlank();
awaitSuccess(resp.operationId());
}
@Test
@Order(2)
void listNamespacesFindsIt() {
ListNamespacesResponse resp = sd.listNamespaces(ListNamespacesRequest.builder().build());
NamespaceSummary found = resp.namespaces().stream()
.filter(n -> n.name().equals(namespaceName))
.findFirst().orElseThrow();
namespaceId = found.id();
assertThat(namespaceId).startsWith("ns-");
assertThat(found.type()).isEqualTo(NamespaceType.HTTP);
assertThat(found.arn()).contains("servicediscovery");
}
@Test
@Order(3)
void getNamespace() {
GetNamespaceResponse resp = sd.getNamespace(r -> r.id(namespaceId));
assertThat(resp.namespace().name()).isEqualTo(namespaceName);
assertThat(resp.namespace().properties().httpProperties().httpName()).isEqualTo(namespaceName);
}
@Test
@Order(4)
void createService() {
CreateServiceResponse resp = sd.createService(
r -> r.name(serviceName).namespaceId(namespaceId).type(ServiceTypeOption.HTTP));
serviceId = resp.service().id();
assertThat(serviceId).startsWith("srv-");
assertThat(resp.service().name()).isEqualTo(serviceName);
assertThat(resp.service().namespaceId()).isEqualTo(namespaceId);
}
@Test
@Order(5)
void registerInstance() {
RegisterInstanceResponse resp = sd.registerInstance(r -> r
.serviceId(serviceId)
.instanceId(INSTANCE_ID)
.attributes(Map.of("AWS_INSTANCE_IPV4", "10.0.0.7", "AWS_INSTANCE_PORT", "9090")));
assertThat(resp.operationId()).isNotBlank();
awaitSuccess(resp.operationId());
}
@Test
@Order(6)
void listInstances() {
ListInstancesResponse resp = sd.listInstances(r -> r.serviceId(serviceId));
assertThat(resp.instances()).hasSize(1);
InstanceSummary summary = resp.instances().get(0);
assertThat(summary.id()).isEqualTo(INSTANCE_ID);
assertThat(summary.attributes()).containsEntry("AWS_INSTANCE_IPV4", "10.0.0.7");
}
@Test
@Order(7)
void getInstance() {
GetInstanceResponse resp = sd.getInstance(r -> r.serviceId(serviceId).instanceId(INSTANCE_ID));
assertThat(resp.instance().id()).isEqualTo(INSTANCE_ID);
assertThat(resp.instance().attributes()).containsEntry("AWS_INSTANCE_PORT", "9090");
}
@Test
@Order(8)
void discoverInstances() {
DiscoverInstancesResponse resp = sd.discoverInstances(r -> r
.namespaceName(namespaceName)
.serviceName(serviceName)
.healthStatus(HealthStatusFilter.HEALTHY_OR_ELSE_ALL));
assertThat(resp.instances()).hasSize(1);
HttpInstanceSummary inst = resp.instances().get(0);
assertThat(inst.instanceId()).isEqualTo(INSTANCE_ID);
assertThat(inst.healthStatus()).isEqualTo(HealthStatus.HEALTHY);
assertThat(resp.instancesRevision()).isNotNull();
long revision = sd.discoverInstancesRevision(r -> r
.namespaceName(namespaceName).serviceName(serviceName)).instancesRevision();
assertThat(revision).isEqualTo(resp.instancesRevision());
}
@Test
@Order(9)
void getInstancesHealthStatus() {
GetInstancesHealthStatusResponse resp = sd.getInstancesHealthStatus(r -> r.serviceId(serviceId));
assertThat(resp.status()).containsEntry(INSTANCE_ID, HealthStatus.HEALTHY);
}
@Test
@Order(10)
void listOperationsFilteredByNamespace() {
ListOperationsResponse resp = sd.listOperations(r -> r.filters(
OperationFilter.builder()
.name(OperationFilterName.NAMESPACE_ID)
.condition(FilterCondition.EQ)
.values(namespaceId)
.build()));
assertThat(resp.operations()).isNotEmpty();
}
@Test
@Order(11)
void deleteServiceWhileInstancesRegisteredFails() {
assertThatThrownBy(() -> sd.deleteService(r -> r.id(serviceId)))
.isInstanceOf(ResourceInUseException.class);
}
@Test
@Order(12)
void teardown() {
DeregisterInstanceResponse dereg = sd.deregisterInstance(
r -> r.serviceId(serviceId).instanceId(INSTANCE_ID));
assertThat(dereg.operationId()).isNotBlank();
awaitSuccess(dereg.operationId());
sd.deleteService(r -> r.id(serviceId));
DeleteNamespaceResponse del = sd.deleteNamespace(r -> r.id(namespaceId));
assertThat(del.operationId()).isNotBlank();
}
@Test
@Order(13)
void getMissingNamespaceThrows() {
assertThatThrownBy(() -> sd.getNamespace(r -> r.id("ns-doesnotexist00000")))
.isInstanceOf(NamespaceNotFoundException.class);
}
}
@@ -0,0 +1,112 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.cloudtrail.CloudTrailClient;
import software.amazon.awssdk.services.cloudtrail.model.CreateTrailResponse;
import software.amazon.awssdk.services.cloudtrail.model.DescribeTrailsResponse;
import software.amazon.awssdk.services.cloudtrail.model.GetTrailStatusResponse;
import software.amazon.awssdk.services.cloudtrail.model.Trail;
import software.amazon.awssdk.services.cloudtrail.model.TrailNotFoundException;
import software.amazon.awssdk.services.cloudtrail.model.UpdateTrailResponse;
import static org.assertj.core.api.Assertions.*;
@DisplayName("CloudTrail")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CloudTrailTest {
private static CloudTrailClient cloudTrail;
private static String trailName;
private static String bucketName;
private static String updatedBucketName;
@BeforeAll
static void setup() {
cloudTrail = TestFixtures.cloudTrailClient();
trailName = TestFixtures.uniqueName("ct-sdk");
bucketName = TestFixtures.uniqueName("ct-bucket");
updatedBucketName = TestFixtures.uniqueName("ct-bucket-updated");
}
@AfterAll
static void cleanup() {
if (cloudTrail != null) {
try {
cloudTrail.deleteTrail(r -> r.name(trailName));
} catch (Exception ignored) {
}
cloudTrail.close();
}
}
@Test
@Order(1)
void createTrail() {
CreateTrailResponse response = cloudTrail.createTrail(r -> r
.name(trailName)
.s3BucketName(bucketName)
.includeGlobalServiceEvents(true)
.isMultiRegionTrail(true));
assertThat(response.name()).isEqualTo(trailName);
assertThat(response.s3BucketName()).isEqualTo(bucketName);
assertThat(response.trailARN()).contains(":cloudtrail:").endsWith(":trail/" + trailName);
assertThat(response.includeGlobalServiceEvents()).isTrue();
assertThat(response.isMultiRegionTrail()).isTrue();
}
@Test
@Order(2)
void describeTrailsByName() {
DescribeTrailsResponse response = cloudTrail.describeTrails(r -> r.trailNameList(trailName));
assertThat(response.trailList()).hasSize(1);
Trail trail = response.trailList().get(0);
assertThat(trail.name()).isEqualTo(trailName);
assertThat(trail.homeRegion()).isEqualTo("us-east-1");
assertThat(trail.s3BucketName()).isEqualTo(bucketName);
}
@Test
@Order(3)
void updateTrail() {
UpdateTrailResponse response = cloudTrail.updateTrail(r -> r
.name(trailName)
.s3BucketName(updatedBucketName)
.includeGlobalServiceEvents(false));
assertThat(response.name()).isEqualTo(trailName);
assertThat(response.s3BucketName()).isEqualTo(updatedBucketName);
assertThat(response.includeGlobalServiceEvents()).isFalse();
}
@Test
@Order(4)
void startAndStopLogging() {
cloudTrail.startLogging(r -> r.name(trailName));
GetTrailStatusResponse started = cloudTrail.getTrailStatus(r -> r.name(trailName));
assertThat(started.isLogging()).isTrue();
assertThat(started.latestDeliveryTime()).isNull();
cloudTrail.stopLogging(r -> r.name(trailName));
GetTrailStatusResponse stopped = cloudTrail.getTrailStatus(r -> r.name(trailName));
assertThat(stopped.isLogging()).isFalse();
}
@Test
@Order(5)
void putEventSelectorsAcceptsTrailName() {
cloudTrail.putEventSelectors(r -> r.trailName(trailName));
}
@Test
@Order(6)
void deleteTrail() {
cloudTrail.deleteTrail(r -> r.name(trailName));
assertThatThrownBy(() -> cloudTrail.getTrailStatus(r -> r.name(trailName)))
.isInstanceOf(TrailNotFoundException.class);
}
}
@@ -0,0 +1,92 @@
package com.floci.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient;
import software.amazon.awssdk.services.cloudwatchlogs.model.InvalidParameterException;
import software.amazon.awssdk.services.cloudwatchlogs.model.LimitExceededException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
@DisplayName("CloudWatch Logs cross-account management APIs")
class CloudWatchLogsCrossAccountPolicyTest {
private static final String DESTINATION_NAME = "floci-sdk-cross-account-destination";
private static final String POLICY_NAME = "floci-sdk-cross-account-transformer";
@Test
@DisplayName("uses AWS SDK models for destination and account policy operations")
void destinationAndAccountPolicyOperationsUseAwsSdk() {
assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only destination ARNs and persistent test resources");
try (CloudWatchLogsClient logs = TestFixtures.cloudWatchLogsClient()) {
var putDestination = logs.putDestination(request -> request
.destinationName(DESTINATION_NAME)
.targetArn("arn:aws:kinesis:us-east-1:000000000000:stream/floci-sdk-logs")
.roleArn("arn:aws:iam::000000000000:role/floci-sdk-logs"));
assertThat(putDestination.destination()).isNotNull();
assertThat(putDestination.destination().destinationName()).isEqualTo(DESTINATION_NAME);
assertThat(putDestination.destination().arn()).contains(":logs:us-east-1:");
logs.putDestinationPolicy(request -> request
.destinationName(DESTINATION_NAME)
.accessPolicy("{\"Version\":\"2012-10-17\",\"Statement\":[]}"));
var putPolicy = logs.putAccountPolicy(request -> request
.policyName(POLICY_NAME)
.policyType("TRANSFORMER_POLICY")
.policyDocument("[{\"parseJSON\":{}}]")
.selectionCriteria("LogGroupNamePrefix = \"/floci/sdk-cross-account/\"")
.scope("ALL"));
assertThat(putPolicy.accountPolicy()).isNotNull();
assertThat(putPolicy.accountPolicy().policyName()).isEqualTo(POLICY_NAME);
assertThat(putPolicy.accountPolicy().policyTypeAsString()).isEqualTo("TRANSFORMER_POLICY");
var policies = logs.describeAccountPolicies(request -> request.policyType("TRANSFORMER_POLICY"));
assertThat(policies.accountPolicies())
.anySatisfy(policy -> {
assertThat(policy.policyName()).isEqualTo(POLICY_NAME);
assertThat(policy.selectionCriteria())
.isEqualTo("LogGroupNamePrefix = \"/floci/sdk-cross-account/\"");
});
var fieldIndex = logs.putAccountPolicy(request -> request
.policyName("floci-sdk-field-index-quoted-value")
.policyType("FIELD_INDEX_POLICY")
.policyDocument("{\"Fields\":[\"requestId\"]}")
.selectionCriteria("LogGroupNamePrefix = \"/DataSourceName/DataSourceType/\"")
.scope("ALL"));
assertThat(fieldIndex.accountPolicy().selectionCriteria())
.isEqualTo("LogGroupNamePrefix = \"/DataSourceName/DataSourceType/\"");
assertThatThrownBy(() -> logs.putAccountPolicy(request -> request
.policyName("floci-sdk-field-index-global")
.policyType("FIELD_INDEX_POLICY")
.policyDocument("{\"Fields\":[\"requestId\"]}")
.scope("ALL")))
.isInstanceOfSatisfying(LimitExceededException.class, error ->
assertThat(error.awsErrorDetails().errorCode()).isEqualTo("LimitExceededException"));
}
}
@Test
@DisplayName("returns the modeled SDK error for mixed field-index selection criteria")
void mixedFieldIndexCriteriaReturnsSdkInvalidParameterException() {
assumeFalse(TestFixtures.isRealAws(), "Avoids mutating account-level policies in real AWS");
try (CloudWatchLogsClient logs = TestFixtures.cloudWatchLogsClient()) {
assertThatThrownBy(() -> logs.putAccountPolicy(request -> request
.policyName("floci-sdk-invalid-mixed-index")
.policyType("FIELD_INDEX_POLICY")
.policyDocument("{\"Fields\":[\"requestId\"]}")
.selectionCriteria("LogGroupNamePrefix = \"/floci/\" AND DataSourceName = \"amazon_vpc\" AND DataSourceType = \"flow\"")
.scope("ALL")))
.isInstanceOfSatisfying(InvalidParameterException.class, error ->
assertThat(error.awsErrorDetails().errorCode()).isEqualTo("InvalidParameterException"));
}
}
}
@@ -0,0 +1,97 @@
package com.floci.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient;
import software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup;
import software.amazon.awssdk.services.cloudwatchlogs.model.ValidationException;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("CloudWatch Logs deletion protection")
class CloudWatchLogsDeletionProtectionTest {
@Test
@DisplayName("defaults off and can be enabled and disabled by name")
void defaultsOffAndUpdatesByName() {
String groupName = "/test/" + TestFixtures.uniqueName("logs-deletion-protection-default");
try (CloudWatchLogsClient logs = TestFixtures.cloudWatchLogsClient()) {
try {
logs.createLogGroup(request -> request.logGroupName(groupName));
assertThat(findLogGroup(logs, groupName))
.get()
.extracting(LogGroup::deletionProtectionEnabled)
.isEqualTo(false);
logs.putLogGroupDeletionProtection(request -> request
.logGroupIdentifier(groupName)
.deletionProtectionEnabled(true));
assertThat(findLogGroup(logs, groupName))
.get()
.extracting(LogGroup::deletionProtectionEnabled)
.isEqualTo(true);
logs.putLogGroupDeletionProtection(request -> request
.logGroupIdentifier(groupName)
.deletionProtectionEnabled(false));
assertThat(findLogGroup(logs, groupName))
.get()
.extracting(LogGroup::deletionProtectionEnabled)
.isEqualTo(false);
} finally {
deleteIfPresent(logs, groupName);
}
}
}
@Test
@DisplayName("create-time protection blocks deletion until disabled by ARN")
void createTimeProtectionBlocksDeletionUntilDisabledByArn() {
String groupName = "/test/" + TestFixtures.uniqueName("logs-deletion-protection-create");
try (CloudWatchLogsClient logs = TestFixtures.cloudWatchLogsClient()) {
try {
logs.createLogGroup(request -> request
.logGroupName(groupName)
.deletionProtectionEnabled(true));
LogGroup group = findLogGroup(logs, groupName).orElseThrow();
assertThat(group.deletionProtectionEnabled()).isTrue();
assertThatThrownBy(() -> logs.deleteLogGroup(request -> request.logGroupName(groupName)))
.isInstanceOfSatisfying(ValidationException.class, error -> {
assertThat(error.awsErrorDetails().errorCode()).isEqualTo("ValidationException");
assertThat(error.getMessage()).containsIgnoringCase("deletion protection");
});
logs.putLogGroupDeletionProtection(request -> request
.logGroupIdentifier(group.arn())
.deletionProtectionEnabled(false));
logs.deleteLogGroup(request -> request.logGroupName(groupName));
assertThat(findLogGroup(logs, groupName)).isEmpty();
} finally {
deleteIfPresent(logs, groupName);
}
}
}
private static Optional<LogGroup> findLogGroup(CloudWatchLogsClient logs, String groupName) {
return logs.describeLogGroups(request -> request.logGroupNamePrefix(groupName))
.logGroups()
.stream()
.filter(group -> groupName.equals(group.logGroupName()))
.findFirst();
}
private static void deleteIfPresent(CloudWatchLogsClient logs, String groupName) {
if (findLogGroup(logs, groupName).isEmpty()) {
return;
}
logs.putLogGroupDeletionProtection(request -> request
.logGroupIdentifier(groupName)
.deletionProtectionEnabled(false));
logs.deleteLogGroup(request -> request.logGroupName(groupName));
}
}
@@ -0,0 +1,48 @@
package com.floci.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient;
import software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("CloudWatch Logs DescribeLogGroups")
class CloudWatchLogsDescribeLogGroupsTest {
@Test
@DisplayName("returns the AWS LogGroup ARN fields with their distinct formats")
void returnsAwsLogGroupArnFields() {
String groupName = "/test/" + TestFixtures.uniqueName("logs-describe-arn");
try (CloudWatchLogsClient logs = TestFixtures.cloudWatchLogsClient()) {
try {
logs.createLogGroup(request -> request.logGroupName(groupName));
LogGroup group = logs.describeLogGroups(request -> request
.logGroupNamePrefix(groupName))
.logGroups()
.stream()
.filter(candidate -> groupName.equals(candidate.logGroupName()))
.findFirst()
.orElseThrow();
assertThat(group.arn())
.isEqualTo("arn:aws:logs:us-east-1:000000000000:log-group:" + groupName + ":*");
assertThat(group.logGroupArn())
.isEqualTo("arn:aws:logs:us-east-1:000000000000:log-group:" + groupName);
assertThat(group.logGroupArn()).doesNotEndWith(":*");
} finally {
deleteIfPresent(logs, groupName);
}
}
}
private static void deleteIfPresent(CloudWatchLogsClient logs, String groupName) {
logs.describeLogGroups(request -> request.logGroupNamePrefix(groupName))
.logGroups()
.stream()
.filter(group -> groupName.equals(group.logGroupName()))
.findFirst()
.ifPresent(group -> logs.deleteLogGroup(request -> request.logGroupName(groupName)));
}
}
@@ -0,0 +1,134 @@
package com.floci.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient;
import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogStreamsResponse;
import software.amazon.awssdk.services.cloudwatchlogs.model.InputLogEvent;
import software.amazon.awssdk.services.cloudwatchlogs.model.InvalidParameterException;
import software.amazon.awssdk.services.cloudwatchlogs.model.LogStream;
import software.amazon.awssdk.services.cloudwatchlogs.model.OrderBy;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("CloudWatch Logs DescribeLogStreams ordering and pagination")
class CloudWatchLogsDescribeStreamsOrderingTest {
@Test
@DisplayName("orderBy LastEventTime descending with limit 1 returns the most recently active stream")
void lastEventTimeDescendingWithLimitReturnsMostRecentStream() {
String groupName = "/test/" + TestFixtures.uniqueName("logs-describe-streams-order");
try (CloudWatchLogsClient logs = TestFixtures.cloudWatchLogsClient()) {
try {
logs.createLogGroup(request -> request.logGroupName(groupName));
// Alphabetical order deliberately disagrees with event recency, so a
// name-sorted result (the pre-fix behavior) fails the assertion.
long base = System.currentTimeMillis();
createStreamWithEvent(logs, groupName, "a-oldest", base - 2000);
createStreamWithEvent(logs, groupName, "b-newest", base);
createStreamWithEvent(logs, groupName, "c-middle", base - 1000);
DescribeLogStreamsResponse response = logs.describeLogStreams(request -> request
.logGroupName(groupName)
.orderBy(OrderBy.LAST_EVENT_TIME)
.descending(true)
.limit(1));
assertThat(response.logStreams())
.as("limit is honored on the wire")
.hasSize(1);
assertThat(response.logStreams().get(0).logStreamName())
.as("descending LastEventTime puts the most recently active stream first")
.isEqualTo("b-newest");
assertThat(response.nextToken())
.as("more streams remain, so a next page is advertised")
.isNotNull();
} finally {
deleteIfPresent(logs, groupName);
}
}
}
@Test
@DisplayName("the SDK paginator walks every stream once and terminates")
void paginatorWalksEveryStreamOnceAndTerminates() {
String groupName = "/test/" + TestFixtures.uniqueName("logs-describe-streams-paging");
try (CloudWatchLogsClient logs = TestFixtures.cloudWatchLogsClient()) {
try {
logs.createLogGroup(request -> request.logGroupName(groupName));
for (int i = 0; i < 5; i++) {
int index = i;
logs.createLogStream(request -> request
.logGroupName(groupName)
.logStreamName("stream-" + index));
}
List<String> names = new ArrayList<>();
logs.describeLogStreamsPaginator(request -> request
.logGroupName(groupName)
.limit(2))
.stream()
.forEach(page -> page.logStreams().stream()
.map(LogStream::logStreamName)
.forEach(names::add));
assertThat(names)
.as("every stream is seen exactly once, name-ascending by default")
.containsExactly("stream-0", "stream-1", "stream-2", "stream-3", "stream-4");
} finally {
deleteIfPresent(logs, groupName);
}
}
}
@Test
@DisplayName("orderBy LastEventTime combined with a name prefix is rejected like real AWS")
void lastEventTimeWithPrefixIsRejected() {
String groupName = "/test/" + TestFixtures.uniqueName("logs-describe-streams-reject");
try (CloudWatchLogsClient logs = TestFixtures.cloudWatchLogsClient()) {
try {
logs.createLogGroup(request -> request.logGroupName(groupName));
assertThatThrownBy(() -> logs.describeLogStreams(request -> request
.logGroupName(groupName)
.logStreamNamePrefix("stream")
.orderBy(OrderBy.LAST_EVENT_TIME)))
.as("real AWS rejects LastEventTime ordering with a logStreamNamePrefix")
.isInstanceOf(InvalidParameterException.class);
} finally {
deleteIfPresent(logs, groupName);
}
}
}
private static void createStreamWithEvent(CloudWatchLogsClient logs, String groupName,
String streamName, long timestamp) {
logs.createLogStream(request -> request
.logGroupName(groupName)
.logStreamName(streamName));
logs.putLogEvents(request -> request
.logGroupName(groupName)
.logStreamName(streamName)
.logEvents(InputLogEvent.builder()
.timestamp(timestamp)
.message("event for " + streamName)
.build()));
}
private static void deleteIfPresent(CloudWatchLogsClient logs, String groupName) {
boolean present = logs.describeLogGroups(request -> request.logGroupNamePrefix(groupName))
.logGroups()
.stream()
.anyMatch(group -> groupName.equals(group.logGroupName()));
if (present) {
logs.deleteLogGroup(request -> request.logGroupName(groupName));
}
}
}
@@ -0,0 +1,124 @@
package com.floci.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient;
import software.amazon.awssdk.services.cloudwatchlogs.model.FilterLogEventsResponse;
import software.amazon.awssdk.services.cloudwatchlogs.model.FilteredLogEvent;
import software.amazon.awssdk.services.cloudwatchlogs.model.InputLogEvent;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("CloudWatch Logs FilterLogEvents pagination")
class CloudWatchLogsFilterPaginationTest {
@Test
@DisplayName("the SDK paginator walks every match once and terminates")
void paginatorWalksEveryMatchOnceAndTerminates() {
String groupName = "/test/" + TestFixtures.uniqueName("logs-filter-pagination");
String streamName = "stream-1";
try (CloudWatchLogsClient logs = TestFixtures.cloudWatchLogsClient()) {
try {
logs.createLogGroup(request -> request.logGroupName(groupName));
logs.createLogStream(request -> request
.logGroupName(groupName)
.logStreamName(streamName));
long base = System.currentTimeMillis();
List<InputLogEvent> events = new ArrayList<>();
for (int i = 0; i < 5; i++) {
int index = i;
events.add(InputLogEvent.builder()
.timestamp(base + index)
.message("msg-" + index)
.build());
}
logs.putLogEvents(request -> request
.logGroupName(groupName)
.logStreamName(streamName)
.logEvents(events));
// The paginator is the point of the test. This SDK's FilterLogEventsIterable
// keeps requesting while nextToken is non-empty and, unlike GetLogEventsIterable,
// has no guard against being handed the same token twice. A response that
// advertises a next page on the final page therefore never terminates here.
List<String> messages = new ArrayList<>();
List<FilterLogEventsResponse> pages = new ArrayList<>();
logs.filterLogEventsPaginator(request -> request
.logGroupName(groupName)
.limit(2))
.stream()
.forEach(page -> {
pages.add(page);
page.events().stream().map(FilteredLogEvent::message).forEach(messages::add);
});
assertThat(messages)
.as("every match is seen exactly once, oldest first")
.containsExactly("msg-0", "msg-1", "msg-2", "msg-3", "msg-4");
assertThat(pages).hasSize(3);
assertThat(pages.get(pages.size() - 1).nextToken())
.as("an absent token is how FilterLogEvents signals the end")
.isNull();
} finally {
deleteIfPresent(logs, groupName);
}
}
}
@Test
@DisplayName("a token from a previous page resumes rather than restarting")
void tokenResumesRatherThanRestarting() {
String groupName = "/test/" + TestFixtures.uniqueName("logs-filter-pagination-resume");
String streamName = "stream-1";
try (CloudWatchLogsClient logs = TestFixtures.cloudWatchLogsClient()) {
try {
logs.createLogGroup(request -> request.logGroupName(groupName));
logs.createLogStream(request -> request
.logGroupName(groupName)
.logStreamName(streamName));
long base = System.currentTimeMillis();
logs.putLogEvents(request -> request
.logGroupName(groupName)
.logStreamName(streamName)
.logEvents(
InputLogEvent.builder().timestamp(base).message("first").build(),
InputLogEvent.builder().timestamp(base + 1).message("second").build(),
InputLogEvent.builder().timestamp(base + 2).message("third").build()));
FilterLogEventsResponse firstPage = logs.filterLogEvents(request -> request
.logGroupName(groupName)
.limit(2));
assertThat(firstPage.events()).extracting(FilteredLogEvent::message)
.containsExactly("first", "second");
assertThat(firstPage.nextToken()).isNotNull();
FilterLogEventsResponse secondPage = logs.filterLogEvents(request -> request
.logGroupName(groupName)
.limit(2)
.nextToken(firstPage.nextToken()));
assertThat(secondPage.events()).extracting(FilteredLogEvent::message)
.containsExactly("third");
assertThat(secondPage.nextToken()).isNull();
} finally {
deleteIfPresent(logs, groupName);
}
}
}
private static void deleteIfPresent(CloudWatchLogsClient logs, String groupName) {
boolean present = logs.describeLogGroups(request -> request.logGroupNamePrefix(groupName))
.logGroups()
.stream()
.anyMatch(group -> groupName.equals(group.logGroupName()));
if (present) {
logs.deleteLogGroup(request -> request.logGroupName(groupName));
}
}
}
@@ -0,0 +1,186 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient;
import software.amazon.awssdk.services.cloudwatchlogs.model.*;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/**
* CloudWatch Logs Insights compatibility: StartQuery / GetQueryResults / StopQuery.
*
* <p>Seeds a log group with structured JSON events and exercises the same query shape real
* clients send — filter on a nested JSON field ({@code params.job_id}), drop a log level,
* sort by {@code @timestamp}, and dedup. StartQuery times are epoch <em>seconds</em> while
* event timestamps are millis.
*/
@DisplayName("CloudWatch Logs Insights")
class CloudWatchLogsInsightsTest {
private static CloudWatchLogsClient logs;
private static final String GROUP = "/test/" + TestFixtures.uniqueName("insights");
private static final String STREAM = "stream-01";
private static String jobId;
private static long baseMs;
@BeforeAll
static void setUp() {
logs = TestFixtures.cloudWatchLogsClient();
logs.createLogGroup(b -> b.logGroupName(GROUP));
logs.createLogStream(b -> b.logGroupName(GROUP).logStreamName(STREAM));
baseMs = System.currentTimeMillis();
jobId = TestFixtures.uniqueName("job");
logs.putLogEvents(b -> b
.logGroupName(GROUP)
.logStreamName(STREAM)
.logEvents(
InputLogEvent.builder().timestamp(baseMs - 3000).message(json("INFO", "job started", jobId)).build(),
InputLogEvent.builder().timestamp(baseMs - 2000).message(json("TRACE", "verbose noise", jobId)).build(),
InputLogEvent.builder().timestamp(baseMs - 1000).message(json("INFO", "unrelated", "other-job")).build(),
InputLogEvent.builder().timestamp(baseMs).message(json("ERROR", "job boom", jobId)).build()
));
}
@AfterAll
static void tearDown() {
if (logs != null) {
try {
logs.deleteLogGroup(b -> b.logGroupName(GROUP));
} catch (RuntimeException ignored) {
// best-effort cleanup
}
logs.close();
}
}
@Test
@DisplayName("StartQuery returns a queryId")
void startQueryReturnsQueryId() {
StartQueryResponse start = startTestQuery();
assertThat(start.queryId()).isNotBlank();
}
@Test
@DisplayName("GetQueryResults filters by JSON field, drops TRACE, sorts desc and dedups")
void getQueryResultsFiltersSortsDedups() {
// On real AWS, events are not queryable for several seconds after ingestion; retry the
// full start+poll cycle until rows appear or the ~60s deadline elapses.
GetQueryResultsResponse res = pollUntilComplete(startTestQuery().queryId());
long deadline = System.currentTimeMillis() + 60_000;
while (res.results().isEmpty() && System.currentTimeMillis() < deadline) {
res = pollUntilComplete(startTestQuery().queryId());
}
assertThat(res.status()).isEqualTo(QueryStatus.COMPLETE);
// job_id filter keeps 3 events; level != TRACE drops 1 -> 2 rows.
assertThat(res.results()).hasSize(2);
// Every row projects @timestamp and @message.
assertThat(res.results()).allSatisfy(row -> {
assertThat(row).anySatisfy(f -> assertThat(f.field()).isEqualTo("@timestamp"));
assertThat(row).anySatisfy(f -> assertThat(f.field()).isEqualTo("@message"));
});
// sort @timestamp desc -> newest (ERROR "job boom") first, then INFO "job started".
assertThat(messageOf(res.results().get(0))).contains("job boom");
assertThat(messageOf(res.results().get(1))).contains("job started");
// The TRACE line and the unrelated job must be excluded.
List<String> messages = res.results().stream()
.map(CloudWatchLogsInsightsTest::messageOf)
.toList();
assertThat(messages).noneMatch(m -> m.contains("verbose noise") || m.contains("unrelated"));
}
@Test
@DisplayName("StopQuery on a finished query fails with InvalidParameterException")
void stopFinishedQueryFailsNotRunning() {
StartQueryResponse start = startTestQuery();
pollUntilComplete(start.queryId()); // ensure the query has ended
// AWS: stopping a query that is not running returns an error ("...is not running").
assertThatThrownBy(() -> logs.stopQuery(b -> b.queryId(start.queryId())))
.isInstanceOfSatisfying(CloudWatchLogsException.class,
e -> assertThat(e.awsErrorDetails().errorCode()).isEqualTo("InvalidParameterException"));
}
@Test
@DisplayName("StopQuery on a running query returns success")
void stopRunningQuerySucceeds() {
StartQueryResponse start = startTestQuery();
// Only meaningful where the server models an async Running window: real AWS, or Floci with a
// non-zero query-completion-delay (FLOCI_SERVICES_CLOUDWATCHLOGS_QUERY_COMPLETION_DELAY_MS=2000).
// With Floci's default (instant completion) there is no Running phase, so this case is skipped
// rather than asserted.
String status = logs.getQueryResults(b -> b.queryId(start.queryId())).statusAsString();
Assumptions.assumeTrue("Running".equals(status) || "Scheduled".equals(status),
"no Running window (instant completion) — nothing to stop");
// On real AWS the query can complete in the gap between the status check and the stop call;
// treat InvalidParameterException from stopQuery as a valid outcome rather than a failure.
try {
StopQueryResponse stop = logs.stopQuery(b -> b.queryId(start.queryId()));
assertThat(stop.success()).isTrue();
} catch (CloudWatchLogsException e) {
if ("InvalidParameterException".equals(e.awsErrorDetails().errorCode())) {
Assumptions.abort("query completed before stop");
}
throw e;
}
}
// ──────────────────────────── helpers ────────────────────────────
private static StartQueryResponse startTestQuery() {
long nowSec = baseMs / 1000;
return logs.startQuery(b -> b
.logGroupNames(GROUP)
.startTime(nowSec - 300)
.endTime(nowSec + 300)
.queryString(query()));
}
private static String query() {
return "fields @timestamp, @message"
+ " | filter params.job_id = '" + jobId + "'"
+ " | filter level != 'TRACE'"
+ " | sort @timestamp desc"
+ " | dedup @timestamp, @message";
}
private static GetQueryResultsResponse pollUntilComplete(String queryId) {
GetQueryResultsResponse res = null;
for (int i = 0; i < 75; i++) { // ~15s budget; real AWS Insights queries run asynchronously
res = logs.getQueryResults(b -> b.queryId(queryId));
QueryStatus status = res.status();
if (status == QueryStatus.COMPLETE || status == QueryStatus.FAILED || status == QueryStatus.CANCELLED) {
break;
}
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
return res;
}
private static String messageOf(List<ResultField> row) {
return row.stream()
.filter(f -> "@message".equals(f.field()))
.map(ResultField::value)
.findFirst()
.orElse("");
}
private static String json(String level, String message, String job) {
return "{\"level\":\"" + level + "\",\"message\":\"" + message + "\",\"params\":{\"job_id\":\"" + job + "\"}}";
}
}
@@ -0,0 +1,424 @@
package com.floci.test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
import software.amazon.awssdk.services.cloudwatch.model.Datapoint;
import software.amazon.awssdk.services.cloudwatch.model.Dimension;
import software.amazon.awssdk.services.cloudwatch.model.Metric;
import software.amazon.awssdk.services.cloudwatch.model.MetricDataQuery;
import software.amazon.awssdk.services.cloudwatch.model.MetricDataResult;
import software.amazon.awssdk.services.cloudwatch.model.MetricStat;
import software.amazon.awssdk.services.cloudwatch.model.Statistic;
import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient;
import software.amazon.awssdk.services.cloudwatchlogs.model.InputLogEvent;
import software.amazon.awssdk.services.cloudwatchlogs.model.InvalidParameterException;
import software.amazon.awssdk.services.cloudwatchlogs.model.MetricFilter;
import software.amazon.awssdk.services.cloudwatchlogs.model.MetricFilterMatchRecord;
import software.amazon.awssdk.services.cloudwatchlogs.model.MetricTransformation;
import software.amazon.awssdk.services.cloudwatchlogs.model.ResourceNotFoundException;
import software.amazon.awssdk.services.cloudwatchlogs.model.StandardUnit;
import software.amazon.awssdk.services.sts.StsClient;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Metric filters through the CloudWatch Logs SDK: put, describe, test and delete, and the metric a
* filter publishes for matching log events, read back through the CloudWatch SDK.
*/
@DisplayName("CloudWatch Logs metric filters")
class CloudWatchLogsMetricFilterTest {
private static CloudWatchLogsClient logs;
private static CloudWatchClient cloudWatch;
private static String group;
private static String namespace;
@BeforeAll
static void setup() {
logs = TestFixtures.cloudWatchLogsClient();
cloudWatch = TestFixtures.cloudWatchClient();
group = "/test/" + TestFixtures.uniqueName("metric-filter");
namespace = TestFixtures.uniqueName("MetricFilterTest");
logs.createLogGroup(r -> r.logGroupName(group));
logs.createLogStream(r -> r.logGroupName(group).logStreamName("web"));
}
@AfterAll
static void cleanup() {
if (logs != null) {
try {
logs.deleteLogGroup(r -> r.logGroupName(group));
} catch (Exception e) {
System.err.println("metric filter cleanup skipped: " + e.getMessage());
}
logs.close();
}
if (cloudWatch != null) {
cloudWatch.close();
}
}
@Test
void aFilterIsStoredDescribedTestedAndPublishesItsMetric() throws InterruptedException {
logs.putMetricFilter(r -> r
.logGroupName(group)
.filterName("volume")
.filterPattern("[..., status_code, size]")
.metricTransformations(MetricTransformation.builder()
.metricName("Volume")
.metricNamespace(namespace)
.metricValue("$size")
.dimensions(Map.of("Status", "$status_code"))
.unit(StandardUnit.BYTES)
.build()));
List<MetricFilter> described = logs.describeMetricFilters(r -> r.logGroupName(group)).metricFilters();
assertThat(described).hasSize(1);
MetricFilter filter = described.get(0);
assertThat(filter.filterName()).isEqualTo("volume");
assertThat(filter.logGroupName()).isEqualTo(group);
assertThat(filter.filterPattern()).isEqualTo("[..., status_code, size]");
assertThat(filter.creationTime()).isPositive();
MetricTransformation t = filter.metricTransformations().get(0);
assertThat(t.metricName()).isEqualTo("Volume");
assertThat(t.metricNamespace()).isEqualTo(namespace);
assertThat(t.metricValue()).isEqualTo("$size");
assertThat(t.dimensions()).containsExactlyEntriesOf(Map.of("Status", "$status_code"));
assertThat(t.unit()).isEqualTo(StandardUnit.BYTES);
assertThat(logs.describeMetricFilters(r -> r.metricName("Volume").metricNamespace(namespace)).metricFilters())
.extracting(MetricFilter::logGroupName)
.contains(group);
assertThat(logs.describeLogGroups(r -> r.logGroupNamePrefix(group)).logGroups().get(0).metricFilterCount())
.isEqualTo(1);
List<MetricFilterMatchRecord> matches = logs.testMetricFilter(r -> r
.filterPattern("[..., status_code=200, size]")
.logEventMessages(
"127.0.0.1 - frank [10/Oct/2000:13:25:15 -0700] \"GET /apache_pb.gif HTTP/1.0\" 200 1534",
"127.0.0.1 - frank [10/Oct/2000:13:35:22 -0700] \"GET /apache_pb.gif HTTP/1.0\" 500 5324"))
.matches();
assertThat(matches).hasSize(1);
assertThat(matches.get(0).eventNumber()).isEqualTo(1);
assertThat(matches.get(0).extractedValues()).containsEntry("$size", "1534").containsEntry("$status_code", "200")
.containsEntry("$1", "127.0.0.1");
List<MetricFilterMatchRecord> jsonMatches = logs.testMetricFilter(r -> r
.filterPattern("{ $.latency = * }")
.logEventMessages("plain text", "{\"latency\":50}")).matches();
assertThat(jsonMatches).hasSize(1);
assertThat(jsonMatches.get(0).eventNumber()).isEqualTo(2);
assertThat(jsonMatches.get(0).extractedValues()).isEmpty();
long now = System.currentTimeMillis();
logs.putLogEvents(r -> r.logGroupName(group).logStreamName("web").logEvents(
InputLogEvent.builder().timestamp(now).message(
"127.0.0.1 - frank [10/Oct/2000:13:25:15 -0700] \"GET /index.html HTTP/1.0\" 200 1534").build(),
InputLogEvent.builder().timestamp(now + 1).message(
"127.0.0.1 - frank [10/Oct/2000:13:35:22 -0700] \"GET /index.html HTTP/1.0\" 500 5324").build(),
InputLogEvent.builder().timestamp(now + 2).message(
"127.0.0.1 - frank [10/Oct/2000:13:50:35 -0700] \"GET /index.html HTTP/1.0\" 200 4355").build()));
long deadline = System.nanoTime() + 10_000_000_000L;
List<Datapoint> datapoints;
do {
datapoints = cloudWatch.getMetricStatistics(r -> r
.namespace(namespace)
.metricName("Volume")
.dimensions(Dimension.builder().name("Status").value("200").build())
.startTime(Instant.ofEpochMilli(now).minusSeconds(120))
.endTime(Instant.ofEpochMilli(now).plusSeconds(120))
.period(300)
.statistics(Statistic.SUM, Statistic.SAMPLE_COUNT)).datapoints();
if (datapoints.stream().mapToDouble(Datapoint::sampleCount).sum() >= 2) {
break;
}
Thread.sleep(50);
} while (System.nanoTime() < deadline);
assertThat(datapoints.stream().mapToDouble(Datapoint::sum).sum()).isEqualTo(1534 + 4355);
assertThat(datapoints.stream().mapToDouble(Datapoint::sampleCount).sum()).isEqualTo(2);
logs.deleteMetricFilter(r -> r.logGroupName(group).filterName("volume"));
assertThat(logs.describeMetricFilters(r -> r.logGroupName(group)).metricFilters()).isEmpty();
assertThatThrownBy(() -> logs.deleteMetricFilter(r -> r.logGroupName(group).filterName("volume")))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
void theApiRejectsWhatAwsRejects() {
assertThatThrownBy(() -> logs.putMetricFilter(r -> r
.logGroupName(group)
.filterName("broken")
.filterPattern("{ $.a = }")
.metricTransformations(MetricTransformation.builder()
.metricName("A").metricNamespace(namespace).metricValue("1").build())))
.isInstanceOf(InvalidParameterException.class)
.hasMessageContaining("Invalid filter pattern");
assertThatThrownBy(() -> logs.putMetricFilter(r -> r
.logGroupName("/test/no-such-group")
.filterName("x")
.filterPattern("ERROR")
.metricTransformations(MetricTransformation.builder()
.metricName("A").metricNamespace(namespace).metricValue("1").build())))
.isInstanceOf(ResourceNotFoundException.class);
assertThatThrownBy(() -> logs.putMetricFilter(r -> r
.logGroupName(group)
.filterName("dims")
.filterPattern("ERROR")
.metricTransformations(MetricTransformation.builder()
.metricName("A").metricNamespace(namespace).metricValue("1")
.dimensions(Map.of("a", "$.a")).build())))
.as("dimensions need a JSON or space-delimited pattern")
.isInstanceOf(InvalidParameterException.class);
assertThatThrownBy(() -> logs.describeMetricFilters(r -> r.metricNamespace(namespace)))
.isInstanceOf(InvalidParameterException.class)
.hasMessageContaining("must contain both MetricName and MetricNamespace");
assertThatThrownBy(() -> logs.describeMetricFilters(r -> r.metricName("A")))
.isInstanceOf(InvalidParameterException.class)
.hasMessageContaining("must contain both MetricName and MetricNamespace");
}
@Test
void recordedDefaultsAndQuietTrafficReadIdenticallyOverJsonAndQuery() throws Exception {
JsonNode fixture = publishingFixture().get("metricDefaults");
String isolated = createIsolatedGroup();
Instant minute = Instant.ofEpochSecond((Instant.now().getEpochSecond() / 60 - 30) * 60);
try {
int index = 0;
for (JsonNode scenario : fixture.get("cases")) {
String metric = "defaults" + index++;
put(isolated, metric, fixture, null, List.of());
for (JsonNode batch : scenario.get("batches")) {
List<String> messages = new ArrayList<>();
batch.forEach(m -> messages.add(m.asText()));
ingest(isolated, minute, messages);
}
assertSeries(metric, minute, List.of(), scenario.get("expected"));
minute = minute.plusSeconds(60);
}
put(isolated, "quiet", fixture, null, List.of());
ingest(isolated, minute, List.of("INFO", "INFO", "INFO"));
JsonNode quiet = new ObjectMapper().readTree("{\"Sum\":21,\"SampleCount\":3}");
assertSeries("quiet", minute, List.of(), quiet);
assertSeries("quiet", minute, List.of(), quiet);
assertSeries("quiet", minute.plusSeconds(60), List.of(), null);
} finally {
logs.deleteLogGroup(r -> r.logGroupName(isolated));
}
}
@Test
void recordedExtractionAndOrdinaryDimensionSeriesReadIdenticallyOverJsonAndQuery() throws Exception {
JsonNode fixture = publishingFixture();
String isolated = createIsolatedGroup();
Instant minute = Instant.ofEpochSecond((Instant.now().getEpochSecond() / 60 - 20) * 60);
try {
JsonNode extraction = fixture.get("extraction");
put(isolated, "extraction", extraction, null, List.of());
List<Instant> minutes = new ArrayList<>();
for (JsonNode scenario : extraction.get("cases")) {
minutes.add(minute);
ingest(isolated, minute, List.of(scenario.get("message").toString()));
minute = minute.plusSeconds(60);
}
// The publisher writes batches in order: once the last scenario's control is visible,
// the earlier scenarios, including the one that must publish nothing, are settled.
List<JsonNode> cases = new ArrayList<>();
extraction.get("cases").forEach(cases::add);
int last = cases.size() - 1;
assertSeries("extraction", minutes.get(last), List.of(), cases.get(last).get("expected"));
for (int i = 0; i < cases.size(); i++) {
assertSeries("extraction", minutes.get(i), List.of(), cases.get(i).get("expected"));
}
JsonNode dimensions = fixture.get("ordinaryDimensions");
put(isolated, "dimensions", dimensions, Map.of("A", "$.a", "B", "$.b"), List.of());
List<String> messages = new ArrayList<>();
dimensions.get("messages").forEach(m -> messages.add(m.toString()));
ingest(isolated, minute, messages);
for (JsonNode series : dimensions.get("expectedSeries")) {
assertSeries("dimensions", minute, dimensions(series.get("dimensions"), ""), series);
}
for (JsonNode series : dimensions.get("absentSeries")) {
assertSeries("dimensions", minute, dimensions(series.get("dimensions"), ""), null);
}
assertThat(cloudWatch.listMetrics(r -> r.namespace(namespace).metricName("dimensions")).metrics())
.extracting(m -> m.dimensions().stream().map(d -> d.name() + "=" + d.value()).sorted().toList())
.containsExactlyInAnyOrder(List.of(), List.of("A=alpha", "B=beta"));
} finally {
logs.deleteLogGroup(r -> r.logGroupName(isolated));
}
}
@Test
void recordedSystemDimensionsDoNotAttachToPatternNonmatchDefaults() throws Exception {
JsonNode fixture = publishingFixture().get("systemDimensions");
String isolated = createIsolatedGroup();
String account;
try (StsClient sts = TestFixtures.stsClient()) {
account = sts.getCallerIdentity().account();
}
Instant minute = Instant.ofEpochSecond((Instant.now().getEpochSecond() / 60 - 10) * 60);
try {
int index = 0;
for (JsonNode scenario : fixture.get("cases")) {
List<String> fields = new ArrayList<>();
scenario.get("emitSystemFieldDimensions").forEach(f -> fields.add(f.asText()));
String metric = "system" + index++;
put(isolated, metric, fixture, null, fields);
ingest(isolated, minute, List.of("ERROR", "INFO"));
for (JsonNode series : scenario.get("expectedSeries")) {
assertSeries(metric, minute, dimensions(series.get("dimensions"), account), series);
}
assertThat(cloudWatch.listMetrics(r -> r.namespace(namespace).metricName(metric)).metrics()).hasSize(2);
minute = minute.plusSeconds(60);
}
} finally {
logs.deleteLogGroup(r -> r.logGroupName(isolated));
}
}
@Test
void quietDefaultsDriveASumAlarmWithoutLaterIngestion() throws Exception {
String isolated = createIsolatedGroup();
String alarm = TestFixtures.uniqueName("metric-filter-alarm");
try {
put(isolated, "alarm", publishingFixture().get("metricDefaults"), null, List.of());
cloudWatch.putMetricAlarm(r -> r.alarmName(alarm).namespace(namespace).metricName("alarm")
.statistic(Statistic.SUM).period(60).evaluationPeriods(1).threshold(20.0)
.comparisonOperator("GreaterThanThreshold").treatMissingData("notBreaching").actionsEnabled(false));
Instant minute = Instant.ofEpochSecond((Instant.now().getEpochSecond() / 60 - 1) * 60);
ingest(isolated, minute, List.of("INFO", "INFO", "INFO"));
assertSeries("alarm", minute, List.of(), new ObjectMapper().readTree("{\"Sum\":21,\"SampleCount\":3}"));
long deadline = System.nanoTime() + 25_000_000_000L;
String state;
do {
state = cloudWatch.describeAlarms(r -> r.alarmNames(alarm)).metricAlarms().get(0).stateValueAsString();
if ("ALARM".equals(state)) {
break;
}
Thread.sleep(100);
} while (System.nanoTime() < deadline);
assertThat(state).isEqualTo("ALARM");
} finally {
cloudWatch.deleteAlarms(r -> r.alarmNames(alarm));
logs.deleteLogGroup(r -> r.logGroupName(isolated));
}
}
private static JsonNode publishingFixture() throws Exception {
return MetricFilterFixture.load();
}
private static String createIsolatedGroup() {
String isolated = group + "-" + TestFixtures.uniqueName("publishing");
logs.createLogGroup(r -> r.logGroupName(isolated));
try {
logs.createLogStream(r -> r.logGroupName(isolated).logStreamName("s"));
} catch (RuntimeException failure) {
logs.deleteLogGroup(r -> r.logGroupName(isolated));
throw failure;
}
return isolated;
}
private static void put(String isolated, String metric, JsonNode definition,
Map<String, String> dimensions, List<String> systemFields) {
logs.putMetricFilter(r -> r.logGroupName(isolated).filterName("probe")
.filterPattern(definition.get("filterPattern").asText()).emitSystemFieldDimensions(systemFields)
.metricTransformations(MetricTransformation.builder().metricNamespace(namespace).metricName(metric)
.metricValue(definition.get("metricValue").asText())
.defaultValue(definition.has("defaultValue") ? definition.get("defaultValue").asDouble() : null)
.dimensions(dimensions).unit(StandardUnit.COUNT).build()));
}
private static void ingest(String isolated, Instant minute, List<String> messages) {
List<InputLogEvent> events = new ArrayList<>();
for (int i = 0; i < messages.size(); i++) {
events.add(InputLogEvent.builder().timestamp(minute.toEpochMilli() + i).message(messages.get(i)).build());
}
assertThat(logs.putLogEvents(r -> r.logGroupName(isolated).logStreamName("s").logEvents(events)))
.satisfies(response -> assertThat(response.rejectedLogEventsInfo()).isNull());
}
private static List<Dimension> dimensions(JsonNode values, String account) {
List<Dimension> dimensions = new ArrayList<>();
values.fields().forEachRemaining(e -> dimensions.add(Dimension.builder().name(e.getKey()).value(
e.getKey().equals("@aws.region") ? "us-east-1"
: e.getValue().asText().replace("<CALLER_ACCOUNT>", account)).build()));
return dimensions;
}
/**
* A present series is awaited: the publisher writes it shortly after PutLogEvents returns, as
* AWS does. An absent series is asserted once; callers await a later positive control first.
* The window ends at second 59 so a sample stamped on the next minute is never in scope.
*/
private static void assertSeries(String metric, Instant minute, List<Dimension> dimensions, JsonNode expected)
throws Exception {
boolean present = expected != null && expected.has("Sum");
List<Statistic> statistics = MetricFilterQueryAssertions.STATISTICS.stream().map(Statistic::fromValue).toList();
List<Datapoint> points = statistics(metric, minute, dimensions, statistics);
long deadline = System.nanoTime() + 10_000_000_000L;
while (present && points.isEmpty() && System.nanoTime() < deadline) {
Thread.sleep(50);
points = statistics(metric, minute, dimensions, statistics);
}
assertThat(points).as(metric + " at " + minute + " " + dimensions).hasSize(present ? 1 : 0);
List<String> recorded = present ? MetricFilterQueryAssertions.recorded(expected) : List.of();
if (present) {
Datapoint point = points.get(0);
assertThat(point.timestamp()).isEqualTo(minute);
for (String stat : recorded) {
assertThat(value(point, stat)).as(stat).isEqualTo(expected.get(stat).asDouble());
}
assertThat(point.unitAsString()).isEqualTo("Count");
}
List<MetricDataQuery> queries = new ArrayList<>();
for (String stat : present ? recorded : MetricFilterQueryAssertions.STATISTICS) {
queries.add(MetricDataQuery.builder().id(stat.toLowerCase()).returnData(true).metricStat(
MetricStat.builder().period(60).stat(stat).metric(Metric.builder().namespace(namespace)
.metricName(metric).dimensions(dimensions).build()).build()).build());
}
List<MetricDataResult> results = cloudWatch.getMetricData(r -> r.startTime(minute)
.endTime(minute.plusSeconds(59)).metricDataQueries(queries)).metricDataResults();
assertThat(results).extracting(MetricDataResult::id)
.containsExactlyInAnyOrderElementsOf(queries.stream().map(MetricDataQuery::id).toList());
for (MetricDataResult result : results) {
assertThat(result.statusCodeAsString()).isEqualTo("Complete");
assertThat(result.timestamps()).containsExactlyElementsOf(present ? List.of(minute) : List.of());
if (present) {
String stat = recorded.stream().filter(name -> name.toLowerCase().equals(result.id())).findFirst().orElseThrow();
assertThat(result.values()).containsExactly(expected.get(stat).asDouble());
} else {
assertThat(result.values()).isEmpty();
}
}
MetricFilterQueryAssertions.assertSeries(namespace, metric, minute, dimensions, expected);
}
private static List<Datapoint> statistics(String metric, Instant minute, List<Dimension> dimensions,
List<Statistic> statistics) {
return cloudWatch.getMetricStatistics(r -> r.namespace(namespace).metricName(metric).dimensions(dimensions)
.startTime(minute).endTime(minute.plusSeconds(59)).period(60).statistics(statistics)).datapoints();
}
private static double value(Datapoint point, String stat) {
return switch (stat) {
case "Sum" -> point.sum();
case "SampleCount" -> point.sampleCount();
case "Minimum" -> point.minimum();
case "Maximum" -> point.maximum();
default -> throw new IllegalArgumentException(stat);
};
}
}
@@ -0,0 +1,188 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.cloudwatch.CloudWatchClient;
import software.amazon.awssdk.services.cloudwatch.model.*;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static org.assertj.core.api.Assertions.*;
@DisplayName("CloudWatch Metrics")
class CloudWatchTest {
private static CloudWatchClient cloudWatchClient;
@BeforeAll
static void setUp() {
cloudWatchClient = TestFixtures.cloudWatchClient();
}
@AfterAll
static void tearDown() {
if (cloudWatchClient != null) {
cloudWatchClient.close();
}
}
@Test
@DisplayName("PutMetricData with simple value")
void testPutMetricDataSimpleValue() {
String namespace = "JavaTestSimple";
PutMetricDataRequest request = PutMetricDataRequest.builder()
.namespace(namespace)
.metricData(datum -> datum
.metricName("RequestCount")
.value(42.0)
.unit(StandardUnit.COUNT)
)
.build();
assertThatNoException().isThrownBy(() ->
cloudWatchClient.putMetricData(request)
);
}
@Test
@DisplayName("ListMetrics returns metrics")
void testListMetrics() {
String namespace = "JavaTestList";
// Setup
cloudWatchClient.putMetricData(request -> request
.namespace(namespace)
.metricData(datum -> datum
.metricName("TestMetric")
.value(1.0)
.unit(StandardUnit.COUNT)
)
);
ListMetricsResponse response = cloudWatchClient.listMetrics(request -> request
.namespace(namespace)
);
assertThat(response.metrics()).isNotEmpty();
}
@Test
@DisplayName("GetMetricStatistics returns aggregated data")
void testGetMetricStatistics() {
String namespace = "JavaTestStats";
// Setup
cloudWatchClient.putMetricData(request -> request
.namespace(namespace)
.metricData(datum -> datum
.metricName("StatsMetric")
.value(100.0)
.unit(StandardUnit.COUNT)
)
);
Instant now = Instant.now();
GetMetricStatisticsResponse response = cloudWatchClient.getMetricStatistics(request -> request
.namespace(namespace)
.metricName("StatsMetric")
.startTime(now.minus(1, ChronoUnit.HOURS))
.endTime(now.plus(1, ChronoUnit.MINUTES))
.period(3600)
.statistics(Statistic.SUM)
);
assertThat(response.datapoints()).isNotEmpty();
}
@Test
@DisplayName("PutMetricData with StatisticValues")
void testPutMetricDataWithStatisticValues() {
String namespace = "JavaTestStatisticValues";
Instant now = Instant.now();
// Put metric data with pre-calculated statistics
cloudWatchClient.putMetricData(request -> request
.namespace(namespace)
.metricData(datum -> datum
.metricName("AggregatedMetric")
.statisticValues(stats -> stats
.sampleCount(5.0)
.sum(150.0)
.minimum(20.0)
.maximum(40.0)
)
.unit(StandardUnit.COUNT)
)
);
// Query back the statistics
GetMetricStatisticsResponse response = cloudWatchClient.getMetricStatistics(request -> request
.namespace(namespace)
.metricName("AggregatedMetric")
.startTime(now.minus(1, ChronoUnit.HOURS))
.endTime(now.plus(1, ChronoUnit.MINUTES))
.period(3600)
.statistics(
Statistic.SUM,
Statistic.AVERAGE,
Statistic.MINIMUM,
Statistic.MAXIMUM,
Statistic.SAMPLE_COUNT
)
);
assertThat(response.datapoints()).isNotEmpty();
Datapoint dp = response.datapoints().get(0);
assertThat(dp.sum()).isEqualTo(150.0);
assertThat(dp.sampleCount()).isEqualTo(5.0);
assertThat(dp.minimum()).isEqualTo(20.0);
assertThat(dp.maximum()).isEqualTo(40.0);
assertThat(dp.average()).isEqualTo(30.0); // sum / sampleCount
}
@Test
@DisplayName("GetMetricStatistics honors the Unit filter")
void testGetMetricStatisticsUnitFilter() {
String namespace = "JavaTestUnitFilter";
Instant now = Instant.now();
cloudWatchClient.putMetricData(request -> request
.namespace(namespace)
.metricData(
datum -> datum
.metricName("UnitMetric")
.value(10.0)
.unit(StandardUnit.COUNT),
datum -> datum
.metricName("UnitMetric")
.value(20.0)
.unit(StandardUnit.BYTES)
)
);
GetMetricStatisticsResponse matching = cloudWatchClient.getMetricStatistics(request -> request
.namespace(namespace)
.metricName("UnitMetric")
.startTime(now.minus(1, ChronoUnit.HOURS))
.endTime(now.plus(1, ChronoUnit.MINUTES))
.period(3600)
.statistics(Statistic.SUM)
.unit(StandardUnit.COUNT)
);
assertThat(matching.datapoints()).hasSize(1);
assertThat(matching.datapoints().get(0).sum()).isEqualTo(10.0);
assertThat(matching.datapoints().get(0).unit()).isEqualTo(StandardUnit.COUNT);
GetMetricStatisticsResponse noMatch = cloudWatchClient.getMetricStatistics(request -> request
.namespace(namespace)
.metricName("UnitMetric")
.startTime(now.minus(1, ChronoUnit.HOURS))
.endTime(now.plus(1, ChronoUnit.MINUTES))
.period(3600)
.statistics(Statistic.SUM)
.unit(StandardUnit.MILLISECONDS)
);
assertThat(noMatch.datapoints()).isEmpty();
}
}
@@ -0,0 +1,350 @@
package com.floci.test;
import org.jboss.logging.Logger;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.codeartifact.CodeartifactClient;
import software.amazon.awssdk.services.codeartifact.model.*;
// Explicit import: this file's Tag usage is the CodeArtifact model type, not JUnit's @Tag.
import software.amazon.awssdk.services.codeartifact.model.Tag;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("CodeArtifact")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CodeArtifactTest {
private static final Logger LOG = Logger.getLogger(CodeArtifactTest.class);
private static CodeartifactClient codeArtifact;
private static final String DOMAIN = "compat-test-domain";
private static final String STORE_REPO = "compat-test-store";
private static final String REPO = "compat-test-repo";
private static String domainArn;
private static String repositoryArn;
@BeforeAll
static void setup() {
codeArtifact = TestFixtures.codeArtifactClient();
}
@AfterAll
static void cleanup() {
if (codeArtifact == null) return;
try {
codeArtifact.deleteRepository(r -> r.domain(DOMAIN).repository(REPO));
} catch (Exception e) {
LOG.warnf(e, "Failed to delete repository %s during test cleanup", REPO);
}
try {
codeArtifact.deleteRepository(r -> r.domain(DOMAIN).repository(STORE_REPO));
} catch (Exception e) {
LOG.warnf(e, "Failed to delete repository %s during test cleanup", STORE_REPO);
}
try {
codeArtifact.deleteDomain(r -> r.domain(DOMAIN));
} catch (Exception e) {
LOG.warnf(e, "Failed to delete domain %s during test cleanup", DOMAIN);
}
codeArtifact.close();
}
@Test
@Order(10)
@DisplayName("CreateDomain - creates a domain with tags")
void createDomain() {
CreateDomainResponse resp = codeArtifact.createDomain(r -> r
.domain(DOMAIN)
.tags(Tag.builder().key("owner").value("platform").build()));
domainArn = resp.domain().arn();
assertThat(resp.domain().name()).isEqualTo(DOMAIN);
assertThat(domainArn).contains("domain/" + DOMAIN);
assertThat(resp.domain().status()).isEqualTo(DomainStatus.ACTIVE);
assertThat(resp.domain().repositoryCount()).isZero();
}
@Test
@Order(11)
@DisplayName("CreateDomain - duplicate returns ConflictException")
void createDomainDuplicateFails() {
assertThatThrownBy(() -> codeArtifact.createDomain(r -> r.domain(DOMAIN)))
.isInstanceOfSatisfying(ConflictException.class, e -> {
assertThat(e.resourceId()).isEqualTo(DOMAIN);
assertThat(e.resourceTypeAsString()).isEqualTo("domain");
});
}
@Test
@Order(12)
@DisplayName("GetAuthorizationToken - returns a bearer token and a future expiration")
void getAuthorizationTokenReturnsATokenAndExpiration() {
GetAuthorizationTokenResponse resp = codeArtifact.getAuthorizationToken(r -> r.domain(DOMAIN));
assertThat(resp.authorizationToken()).isNotBlank();
assertThat(resp.expiration()).isAfter(Instant.now());
}
@Test
@Order(13)
@DisplayName("GetAuthorizationToken - fails against a domain that does not exist")
void getAuthorizationTokenMissingDomainFails() {
assertThatThrownBy(() -> codeArtifact.getAuthorizationToken(r -> r.domain("does-not-exist-domain")))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
@Order(14)
@DisplayName("GetAuthorizationToken - rejects a duration outside 0 or 900-43200")
void getAuthorizationTokenRejectsAnInvalidDuration() {
assertThatThrownBy(() -> codeArtifact.getAuthorizationToken(r -> r.domain(DOMAIN).durationSeconds(60L)))
.isInstanceOf(ValidationException.class);
}
@Test
@Order(20)
@DisplayName("CreateRepository - fails against a domain that does not exist")
void createRepositoryMissingDomainFails() {
assertThatThrownBy(() -> codeArtifact.createRepository(r -> r
.domain("does-not-exist-domain")
.repository(REPO)))
.isInstanceOfSatisfying(ResourceNotFoundException.class, e -> {
assertThat(e.resourceId()).isEqualTo("does-not-exist-domain");
assertThat(e.resourceTypeAsString()).isEqualTo("domain");
});
}
@Test
@Order(21)
@DisplayName("CreateRepository - creates the upstream store repository")
void createStoreRepository() {
CreateRepositoryResponse resp = codeArtifact.createRepository(r -> r
.domain(DOMAIN)
.repository(STORE_REPO));
assertThat(resp.repository().name()).isEqualTo(STORE_REPO);
assertThat(resp.repository().upstreams()).isEmpty();
}
@Test
@Order(22)
@DisplayName("CreateRepository - creates a repository with an upstream and tags")
void createRepository() {
CreateRepositoryResponse resp = codeArtifact.createRepository(r -> r
.domain(DOMAIN)
.repository(REPO)
.description("compat test repo")
.upstreams(UpstreamRepository.builder().repositoryName(STORE_REPO).build())
.tags(Tag.builder().key("team").value("data").build()));
repositoryArn = resp.repository().arn();
assertThat(resp.repository().domainName()).isEqualTo(DOMAIN);
assertThat(resp.repository().upstreams()).hasSize(1);
assertThat(resp.repository().upstreams().get(0).repositoryName()).isEqualTo(STORE_REPO);
}
@Test
@Order(23)
@DisplayName("DescribeDomain - reports the live repository count")
void describeDomainReportsRepositoryCount() {
DescribeDomainResponse resp = codeArtifact.describeDomain(r -> r.domain(DOMAIN));
assertThat(resp.domain().repositoryCount()).isEqualTo(2);
}
@Test
@Order(30)
@DisplayName("TagResource / ListTagsForResource / UntagResource round-trip on the repository")
void tagRoundTrip() {
codeArtifact.tagResource(r -> r
.resourceArn(repositoryArn)
.tags(Tag.builder().key("env").value("compat").build()));
List<Tag> tags = codeArtifact.listTagsForResource(r -> r.resourceArn(repositoryArn)).tags();
assertThat(tags).extracting(Tag::key).contains("team", "env");
codeArtifact.untagResource(r -> r.resourceArn(repositoryArn).tagKeys("env"));
tags = codeArtifact.listTagsForResource(r -> r.resourceArn(repositoryArn)).tags();
assertThat(tags).extracting(Tag::key).doesNotContain("env");
}
@Test
@Order(31)
@DisplayName("TagResource / ListTagsForResource round-trip on the domain")
void tagRoundTripOnDomain() {
codeArtifact.tagResource(r -> r
.resourceArn(domainArn)
.tags(Tag.builder().key("cost-center").value("platform-eng").build()));
List<Tag> tags = codeArtifact.listTagsForResource(r -> r.resourceArn(domainArn)).tags();
assertThat(tags).extracting(Tag::key).contains("owner", "cost-center");
}
@Test
@Order(40)
@DisplayName("GetRepositoryEndpoint - resolves for a real package format")
void getRepositoryEndpoint() {
GetRepositoryEndpointResponse resp = codeArtifact.getRepositoryEndpoint(r -> r
.domain(DOMAIN)
.repository(REPO)
.format(PackageFormat.NPM));
assertThat(resp.repositoryEndpoint()).contains("/codeartifact/npm/" + DOMAIN + "/" + REPO + "/");
}
@Test
@Order(50)
@DisplayName("Repository permissions policy - put/get/delete with optimistic locking")
void repositoryPermissionsPolicyRoundTrip() {
String policyDocument = "{\"Version\":\"2012-10-17\",\"Statement\":[]}";
PutRepositoryPermissionsPolicyResponse put = codeArtifact.putRepositoryPermissionsPolicy(r -> r
.domain(DOMAIN)
.repository(REPO)
.policyDocument(policyDocument));
assertThat(put.policy().document()).isEqualTo(policyDocument);
assertThatThrownBy(() -> codeArtifact.putRepositoryPermissionsPolicy(r -> r
.domain(DOMAIN)
.repository(REPO)
.policyDocument(policyDocument)
.policyRevision("not-the-current-revision")))
.isInstanceOf(ConflictException.class);
GetRepositoryPermissionsPolicyResponse got = codeArtifact.getRepositoryPermissionsPolicy(r -> r
.domain(DOMAIN)
.repository(REPO));
assertThat(got.policy().revision()).isEqualTo(put.policy().revision());
codeArtifact.deleteRepositoryPermissionsPolicy(r -> r.domain(DOMAIN).repository(REPO));
assertThatThrownBy(() -> codeArtifact.getRepositoryPermissionsPolicy(r -> r
.domain(DOMAIN)
.repository(REPO)))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
@Order(60)
@DisplayName("AssociateExternalConnection - accepts a real AWS-hosted public upstream")
void externalConnectionRoundTrip() {
AssociateExternalConnectionResponse assoc = codeArtifact.associateExternalConnection(r -> r
.domain(DOMAIN)
.repository(STORE_REPO)
.externalConnection("public:npmjs"));
assertThat(assoc.repository().externalConnections()).hasSize(1);
assertThat(assoc.repository().externalConnections().get(0).packageFormat()).isEqualTo(PackageFormat.NPM);
assertThatThrownBy(() -> codeArtifact.associateExternalConnection(r -> r
.domain(DOMAIN)
.repository(STORE_REPO)
.externalConnection("public:pypi")))
.isInstanceOf(ConflictException.class);
DisassociateExternalConnectionResponse disassoc = codeArtifact.disassociateExternalConnection(r -> r
.domain(DOMAIN)
.repository(STORE_REPO)
.externalConnection("public:npmjs"));
assertThat(disassoc.repository().externalConnections()).isEmpty();
}
@Test
@Order(70)
@DisplayName("PublishPackageVersion - publishes a generic asset and verifies its hashes")
void publishPackageVersion() {
byte[] content = "hello from the compat suite".getBytes(StandardCharsets.UTF_8);
String sha256 = sha256Hex(content);
PublishPackageVersionResponse resp = codeArtifact.publishPackageVersion(r -> r
.domain(DOMAIN)
.repository(REPO)
.format(PackageFormat.GENERIC)
.namespace("compat-ns")
.packageValue("compat-pkg")
.packageVersion("1.0.0")
.assetName("asset.txt")
.assetSHA256(sha256),
RequestBody.fromBytes(content));
assertThat(resp.status()).isEqualTo(PackageVersionStatus.PUBLISHED);
assertThat(resp.asset().name()).isEqualTo("asset.txt");
assertThat(resp.asset().size()).isEqualTo(content.length);
assertThat(resp.asset().hashes().get(HashAlgorithm.SHA_256)).isEqualTo(sha256);
}
// No SDK-level bad-assetSHA256 test: the Java SDK overwrites that header during SigV4 signing; see CodeArtifactServiceTest instead.
@Test
@Order(72)
@DisplayName("DescribePackageVersion / GetPackageVersionAsset - round-trip exact bytes")
void describeAndDownloadPackageVersionAsset() {
DescribePackageVersionResponse described = codeArtifact.describePackageVersion(r -> r
.domain(DOMAIN)
.repository(REPO)
.format(PackageFormat.GENERIC)
.namespace("compat-ns")
.packageValue("compat-pkg")
.packageVersion("1.0.0"));
assertThat(described.packageVersion().status()).isEqualTo(PackageVersionStatus.PUBLISHED);
assertThat(described.packageVersion().origin().originType()).isEqualTo(PackageVersionOriginType.INTERNAL);
ResponseBytes<GetPackageVersionAssetResponse> downloaded = codeArtifact.getPackageVersionAssetAsBytes(r -> r
.domain(DOMAIN)
.repository(REPO)
.format(PackageFormat.GENERIC)
.namespace("compat-ns")
.packageValue("compat-pkg")
.packageVersion("1.0.0")
.asset("asset.txt"));
assertThat(downloaded.response().assetName()).isEqualTo("asset.txt");
assertThat(downloaded.asByteArray()).isEqualTo("hello from the compat suite".getBytes(StandardCharsets.UTF_8));
}
@Test
@Order(73)
@DisplayName("PublishPackageVersion - a second publish to a Published version conflicts")
void republishingAPublishedVersionConflicts() {
byte[] content = "won't land".getBytes(StandardCharsets.UTF_8);
assertThatThrownBy(() -> codeArtifact.publishPackageVersion(r -> r
.domain(DOMAIN)
.repository(REPO)
.format(PackageFormat.GENERIC)
.namespace("compat-ns")
.packageValue("compat-pkg")
.packageVersion("1.0.0")
.assetName("another.txt")
.assetSHA256(sha256Hex(content)),
RequestBody.fromBytes(content)))
.isInstanceOf(ConflictException.class);
}
@Test
@Order(90)
@DisplayName("DeleteDomain - fails while repositories still exist")
void deleteDomainWithRepositoriesFails() {
assertThatThrownBy(() -> codeArtifact.deleteDomain(r -> r.domain(DOMAIN)))
.isInstanceOf(ConflictException.class);
}
private static String sha256Hex(byte[] content) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(content);
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,426 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.codebuild.CodeBuildClient;
import software.amazon.awssdk.services.codebuild.model.ArtifactsType;
import software.amazon.awssdk.services.codebuild.model.BatchGetProjectsResponse;
import software.amazon.awssdk.services.codebuild.model.ComputeType;
import software.amazon.awssdk.services.codebuild.model.CreateProjectResponse;
import software.amazon.awssdk.services.codebuild.model.CreateReportGroupResponse;
import software.amazon.awssdk.services.codebuild.model.DeleteReportGroupRequest;
import software.amazon.awssdk.services.codebuild.model.EnvironmentType;
import software.amazon.awssdk.services.codebuild.model.ImportSourceCredentialsResponse;
import software.amazon.awssdk.services.codebuild.model.ListCuratedEnvironmentImagesResponse;
import software.amazon.awssdk.services.codebuild.model.ListProjectsResponse;
import software.amazon.awssdk.services.codebuild.model.ListReportGroupsResponse;
import software.amazon.awssdk.services.codebuild.model.ListSourceCredentialsResponse;
import software.amazon.awssdk.services.codebuild.model.ProjectArtifacts;
import software.amazon.awssdk.services.codebuild.model.ProjectEnvironment;
import software.amazon.awssdk.services.codebuild.model.ProjectSource;
import software.amazon.awssdk.services.codebuild.model.ReportExportConfig;
import software.amazon.awssdk.services.codebuild.model.ReportExportConfigType;
import software.amazon.awssdk.services.codebuild.model.ReportType;
import software.amazon.awssdk.services.codebuild.model.AuthType;
import software.amazon.awssdk.services.codebuild.model.ServerType;
import software.amazon.awssdk.services.codebuild.model.SourceType;
import software.amazon.awssdk.services.codebuild.model.BatchGetBuildsResponse;
import software.amazon.awssdk.services.codebuild.model.Build;
import software.amazon.awssdk.services.codebuild.model.ListBuildsForProjectResponse;
import software.amazon.awssdk.services.codebuild.model.ListBuildsResponse;
import software.amazon.awssdk.services.codebuild.model.RetryBuildResponse;
import software.amazon.awssdk.services.codebuild.model.StartBuildResponse;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.codebuild.model.Tag;
import software.amazon.awssdk.services.codebuild.model.UpdateProjectResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CodeBuildTest {
private static final String CODEBUILD_TEST_IMAGE =
"public.ecr.aws/docker/library/busybox@sha256:fd8d9aa63ba2f0982b5304e1ee8d3b90a210bc1ffb5314d980eb6962f1a9715d";
static CodeBuildClient codebuild;
static String reportGroupArn;
static String sourceCredentialsArn;
static String buildId;
@BeforeAll
static void setup() {
codebuild = TestFixtures.codeBuildClient();
}
@AfterAll
static void teardown() {
codebuild.close();
}
@Test
@Order(1)
void createProject() {
CreateProjectResponse resp = codebuild.createProject(r -> r
.name("sdk-test-project")
.description("SDK test project")
.source(ProjectSource.builder()
.type(SourceType.S3)
.location("my-bucket/source.zip")
.build())
.artifacts(ProjectArtifacts.builder()
.type(ArtifactsType.NO_ARTIFACTS)
.build())
.environment(ProjectEnvironment.builder()
.type(EnvironmentType.LINUX_CONTAINER)
.image("aws/codebuild/standard:7.0")
.computeType(ComputeType.BUILD_GENERAL1_SMALL)
.build())
.serviceRole("arn:aws:iam::000000000000:role/codebuild-role")
.tags(Tag.builder().key("env").value("test").build()));
assertThat(resp.project().name()).isEqualTo("sdk-test-project");
assertThat(resp.project().arn()).contains(":project/sdk-test-project");
assertThat(resp.project().description()).isEqualTo("SDK test project");
assertThat(resp.project().timeoutInMinutes()).isEqualTo(60);
}
@Test
@Order(2)
void batchGetProjects() {
BatchGetProjectsResponse resp = codebuild.batchGetProjects(r -> r
.names("sdk-test-project", "nonexistent"));
assertThat(resp.projects()).hasSize(1);
assertThat(resp.projects().get(0).name()).isEqualTo("sdk-test-project");
assertThat(resp.projectsNotFound()).containsExactly("nonexistent");
}
@Test
@Order(3)
void listProjects() {
ListProjectsResponse resp = codebuild.listProjects(r -> r.build());
assertThat(resp.projects()).contains("sdk-test-project");
}
@Test
@Order(4)
void updateProject() {
UpdateProjectResponse resp = codebuild.updateProject(r -> r
.name("sdk-test-project")
.description("Updated by SDK test")
.timeoutInMinutes(120));
assertThat(resp.project().description()).isEqualTo("Updated by SDK test");
assertThat(resp.project().timeoutInMinutes()).isEqualTo(120);
}
@Test
@Order(5)
void createReportGroup() {
CreateReportGroupResponse resp = codebuild.createReportGroup(r -> r
.name("sdk-report-group")
.type(ReportType.TEST)
.exportConfig(ReportExportConfig.builder()
.exportConfigType(ReportExportConfigType.NO_EXPORT)
.build()));
assertThat(resp.reportGroup().name()).isEqualTo("sdk-report-group");
assertThat(resp.reportGroup().arn()).contains(":report-group/sdk-report-group");
assertThat(resp.reportGroup().status().toString()).isEqualTo("ACTIVE");
reportGroupArn = resp.reportGroup().arn();
}
@Test
@Order(6)
void listReportGroups() {
ListReportGroupsResponse resp = codebuild.listReportGroups(r -> r.build());
assertThat(resp.reportGroups()).contains(reportGroupArn);
}
@Test
@Order(7)
void importSourceCredentials() {
ImportSourceCredentialsResponse resp = codebuild.importSourceCredentials(r -> r
.token("ghp_test_token_sdk")
.serverType(ServerType.GITHUB)
.authType(AuthType.PERSONAL_ACCESS_TOKEN));
assertThat(resp.arn()).contains(":token/github-");
sourceCredentialsArn = resp.arn();
}
@Test
@Order(8)
void listSourceCredentials() {
ListSourceCredentialsResponse resp = codebuild.listSourceCredentials(r -> r.build());
assertThat(resp.sourceCredentialsInfos()).isNotEmpty();
assertThat(resp.sourceCredentialsInfos().get(0).serverType()).isEqualTo(ServerType.GITHUB);
assertThat(resp.sourceCredentialsInfos().get(0).authType()).isEqualTo(AuthType.PERSONAL_ACCESS_TOKEN);
}
@Test
@Order(9)
void listCuratedEnvironmentImages() {
ListCuratedEnvironmentImagesResponse resp = codebuild.listCuratedEnvironmentImages(r -> r.build());
assertThat(resp.platforms()).isNotEmpty();
assertThat(resp.platforms().get(0).platformAsString()).isNotBlank();
}
@Test
@Order(10)
void deleteSourceCredentials() {
codebuild.deleteSourceCredentials(r -> r.arn(sourceCredentialsArn));
ListSourceCredentialsResponse after = codebuild.listSourceCredentials(r -> r.build());
assertThat(after.sourceCredentialsInfos()).isEmpty();
}
@Test
@Order(11)
void deleteReportGroup() {
codebuild.deleteReportGroup(DeleteReportGroupRequest.builder()
.arn(reportGroupArn)
.build());
}
@Test
@Order(12)
void deleteProject() {
codebuild.deleteProject(r -> r.name("sdk-test-project"));
ListProjectsResponse after = codebuild.listProjects(r -> r.build());
assertThat(after.projects()).doesNotContain("sdk-test-project");
}
// ---- Phase 2: Real Build Execution ----
@Test
@Order(20)
void setupBuildProject() {
codebuild.createProject(r -> r
.name("build-exec-project")
.source(ProjectSource.builder()
.type(SourceType.NO_SOURCE)
.build())
.artifacts(ProjectArtifacts.builder()
.type(ArtifactsType.NO_ARTIFACTS)
.build())
.environment(ProjectEnvironment.builder()
.type(EnvironmentType.LINUX_CONTAINER)
.image(CODEBUILD_TEST_IMAGE)
.computeType(ComputeType.BUILD_GENERAL1_SMALL)
.build())
.serviceRole("arn:aws:iam::000000000000:role/codebuild-role"));
}
@Test
@Order(21)
void startBuild_noSource() {
String buildspec = "version: 0.2\nphases:\n build:\n commands:\n - echo hello-from-codebuild\n";
StartBuildResponse resp = codebuild.startBuild(r -> r
.projectName("build-exec-project")
.buildspecOverride(buildspec));
assertThat(resp.build().id()).contains("build-exec-project:");
assertThat(resp.build().arn()).contains(":build/build-exec-project:");
assertThat(resp.build().buildStatusAsString()).isEqualTo("IN_PROGRESS");
assertThat(resp.build().buildComplete()).isFalse();
buildId = resp.build().id();
}
@Test
@Order(22)
void batchGetBuilds_eventuallySucceeds() throws InterruptedException {
assertThat(buildId).isNotNull();
Build build = null;
for (int i = 0; i < 30; i++) {
BatchGetBuildsResponse resp = codebuild.batchGetBuilds(r -> r.ids(buildId));
assertThat(resp.builds()).hasSize(1);
build = resp.builds().get(0);
if (Boolean.TRUE.equals(build.buildComplete())) {
break;
}
Thread.sleep(2000);
}
assertThat(build).isNotNull();
assertThat(build.buildComplete()).isTrue();
assertThat(build.buildStatusAsString()).isEqualTo("SUCCEEDED");
assertThat(build.currentPhase()).isEqualTo("COMPLETED");
assertThat(build.phases()).isNotEmpty();
}
@Test
@Order(23)
void listBuilds_containsBuildId() {
assertThat(buildId).isNotNull();
ListBuildsResponse resp = codebuild.listBuilds(r -> r.build());
assertThat(resp.ids()).contains(buildId);
}
@Test
@Order(24)
void listBuildsForProject() {
ListBuildsForProjectResponse resp = codebuild.listBuildsForProject(r -> r
.projectName("build-exec-project"));
assertThat(resp.ids()).contains(buildId);
}
@Test
@Order(25)
void retryBuild() {
assertThat(buildId).isNotNull();
RetryBuildResponse resp = codebuild.retryBuild(r -> r.id(buildId));
assertThat(resp.build().id()).contains("build-exec-project:");
assertThat(resp.build().id()).isNotEqualTo(buildId);
assertThat(resp.build().buildStatusAsString()).isEqualTo("IN_PROGRESS");
}
@Test
@Order(26)
void stopBuild() throws InterruptedException {
// Start a new long-running build, then stop it
String longBuildspec = "version: 0.2\nphases:\n build:\n commands:\n - sleep 60\n";
StartBuildResponse startResp = codebuild.startBuild(r -> r
.projectName("build-exec-project")
.buildspecOverride(longBuildspec));
String longBuildId = startResp.build().id();
// Give it a moment to start the container
Thread.sleep(3000);
codebuild.stopBuild(r -> r.id(longBuildId));
// Poll until stopped
Build build = null;
for (int i = 0; i < 20; i++) {
BatchGetBuildsResponse resp = codebuild.batchGetBuilds(r -> r.ids(longBuildId));
build = resp.builds().get(0);
if (Boolean.TRUE.equals(build.buildComplete())) {
break;
}
Thread.sleep(1000);
}
assertThat(build).isNotNull();
assertThat(build.buildComplete()).isTrue();
assertThat(build.buildStatusAsString()).isEqualTo("STOPPED");
}
@Test
@Order(27)
void deleteBuildProject() {
codebuild.deleteProject(r -> r.name("build-exec-project"));
}
// ---- OS demo: list OS family + directory tree, upload to S3 ----
@Test
@Order(30)
void createOsBucket() {
S3Client s3 = TestFixtures.s3Client();
s3.createBucket(r -> r.bucket("os-bucket"));
s3.close();
}
@Test
@Order(31)
void setupOsDemoProject() {
codebuild.createProject(r -> r
.name("os-demo-project")
.source(ProjectSource.builder()
.type(SourceType.NO_SOURCE)
.build())
.artifacts(ProjectArtifacts.builder()
.type(ArtifactsType.S3)
.location("os-bucket")
.packaging("NONE")
.build())
.environment(ProjectEnvironment.builder()
.type(EnvironmentType.LINUX_CONTAINER)
.image(CODEBUILD_TEST_IMAGE)
.computeType(ComputeType.BUILD_GENERAL1_SMALL)
.build())
.serviceRole("arn:aws:iam::000000000000:role/codebuild-role"));
}
@Test
@Order(32)
void startOsDemoBuild() {
String buildspec = String.join("\n",
"version: 0.2",
"phases:",
" build:",
" commands:",
" - echo '=== OS Family ===' > command-output.txt",
" - uname -a >> command-output.txt",
" - echo '' >> command-output.txt",
" - echo '=== Directory Tree (depth 3) ===' >> command-output.txt",
" - find / -maxdepth 3 -type d 2>/dev/null >> command-output.txt",
"artifacts:",
" files:",
" - command-output.txt"
);
StartBuildResponse resp = codebuild.startBuild(r -> r
.projectName("os-demo-project")
.buildspecOverride(buildspec));
buildId = resp.build().id();
assertThat(buildId).contains("os-demo-project:");
}
@Test
@Order(33)
void osDemoBuild_eventuallySucceeds() throws InterruptedException {
assertThat(buildId).isNotNull();
Build build = null;
for (int i = 0; i < 30; i++) {
BatchGetBuildsResponse resp = codebuild.batchGetBuilds(r -> r.ids(buildId));
build = resp.builds().get(0);
if (Boolean.TRUE.equals(build.buildComplete())) {
break;
}
Thread.sleep(2000);
}
assertThat(build).isNotNull();
assertThat(build.buildComplete()).isTrue();
assertThat(build.buildStatusAsString()).isEqualTo("SUCCEEDED");
}
@Test
@Order(34)
void osDemoBuild_outputUploadedToS3() {
S3Client s3 = TestFixtures.s3Client();
byte[] data = s3.getObjectAsBytes(GetObjectRequest.builder()
.bucket("os-bucket")
.key("command-output.txt")
.build()).asByteArray();
String content = new String(data);
System.out.println("=== command-output.txt from os-bucket ===");
System.out.println(content);
assertThat(content).contains("Linux");
assertThat(content).contains("/usr");
s3.close();
}
@Test
@Order(35)
void cleanupOsDemo() {
codebuild.deleteProject(r -> r.name("os-demo-project"));
}
}
@@ -0,0 +1,445 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.codedeploy.CodeDeployClient;
import software.amazon.awssdk.services.codedeploy.model.AppSpecContent;
import software.amazon.awssdk.services.codedeploy.model.BatchGetDeploymentTargetsResponse;
import software.amazon.awssdk.services.codedeploy.model.ComputePlatform;
import software.amazon.awssdk.services.codedeploy.model.CreateDeploymentGroupResponse;
import software.amazon.awssdk.services.codedeploy.model.CreateDeploymentResponse;
import software.amazon.awssdk.services.codedeploy.model.DeploymentOption;
import software.amazon.awssdk.services.codedeploy.model.DeploymentStatus;
import software.amazon.awssdk.services.codedeploy.model.DeploymentStyle;
import software.amazon.awssdk.services.codedeploy.model.DeploymentType;
import software.amazon.awssdk.services.codedeploy.model.ECSService;
import software.amazon.awssdk.services.codedeploy.model.GetDeploymentResponse;
import software.amazon.awssdk.services.codedeploy.model.ListDeploymentTargetsResponse;
import software.amazon.awssdk.services.codedeploy.model.LoadBalancerInfo;
import software.amazon.awssdk.services.codedeploy.model.RevisionLocation;
import software.amazon.awssdk.services.codedeploy.model.RevisionLocationType;
import software.amazon.awssdk.services.codedeploy.model.TargetGroupInfo;
import software.amazon.awssdk.services.codedeploy.model.TargetGroupPairInfo;
import software.amazon.awssdk.services.codedeploy.model.TrafficRoute;
import software.amazon.awssdk.services.ecs.EcsClient;
import software.amazon.awssdk.services.ecs.model.CreateClusterResponse;
import software.amazon.awssdk.services.ecs.model.CreateServiceResponse;
import software.amazon.awssdk.services.ecs.model.DeploymentControllerType;
import software.amazon.awssdk.services.ecs.model.RegisterTaskDefinitionResponse;
import software.amazon.awssdk.services.elasticloadbalancingv2.ElasticLoadBalancingV2Client;
import software.amazon.awssdk.services.elasticloadbalancingv2.model.ActionTypeEnum;
import software.amazon.awssdk.services.elasticloadbalancingv2.model.CreateListenerResponse;
import software.amazon.awssdk.services.elasticloadbalancingv2.model.CreateLoadBalancerResponse;
import software.amazon.awssdk.services.elasticloadbalancingv2.model.CreateTargetGroupResponse;
import software.amazon.awssdk.services.elasticloadbalancingv2.model.DescribeRulesResponse;
import software.amazon.awssdk.services.elasticloadbalancingv2.model.IpAddressType;
import software.amazon.awssdk.services.elasticloadbalancingv2.model.LoadBalancerTypeEnum;
import software.amazon.awssdk.services.elasticloadbalancingv2.model.ProtocolEnum;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Compatibility tests for CodeDeploy ECS blue/green deployments.
*
* Verifies the full lifecycle: ALB + blue/green TG setup → ECS cluster/service setup →
* CodeDeploy ECS app/group → deployment → traffic shifts to green TG.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CodeDeployEcsTest {
static CodeDeployClient codedeploy;
static EcsClient ecs;
static ElasticLoadBalancingV2Client elb;
static final String CLUSTER = "cd-ecs-cluster";
static final String SERVICE = "cd-ecs-service";
static final String TASK_FAMILY = "cd-ecs-task";
static final String LB_NAME = "cd-ecs-alb";
static final String BLUE_TG = "cd-blue-tg";
static final String GREEN_TG = "cd-green-tg";
static final String APP_NAME = "cd-ecs-app";
static final String DG_NAME = "cd-ecs-dg";
static final String ROLE = "arn:aws:iam::000000000000:role/codedeploy-role";
static String lbArn;
static String blueTgArn;
static String greenTgArn;
static String listenerArn;
static String taskDefArn;
static String deploymentId;
@BeforeAll
static void setup() {
codedeploy = TestFixtures.codeDeployClient();
ecs = TestFixtures.ecsClient();
elb = TestFixtures.elbV2Client();
}
@AfterAll
static void teardown() {
try { codedeploy.deleteDeploymentGroup(r -> r.applicationName(APP_NAME).deploymentGroupName(DG_NAME)); } catch (Exception ignored) {}
try { codedeploy.deleteApplication(r -> r.applicationName(APP_NAME)); } catch (Exception ignored) {}
try { ecs.deleteService(r -> r.cluster(CLUSTER).service(SERVICE).force(true)); } catch (Exception ignored) {}
try { ecs.deleteCluster(r -> r.cluster(CLUSTER)); } catch (Exception ignored) {}
try {
if (listenerArn != null) { elb.deleteListener(r -> r.listenerArn(listenerArn)); }
if (blueTgArn != null) { elb.deleteTargetGroup(r -> r.targetGroupArn(blueTgArn)); }
if (greenTgArn != null) { elb.deleteTargetGroup(r -> r.targetGroupArn(greenTgArn)); }
if (lbArn != null) { elb.deleteLoadBalancer(r -> r.loadBalancerArn(lbArn)); }
} catch (Exception ignored) {}
codedeploy.close();
ecs.close();
elb.close();
}
// ── ELB v2 setup ─────────────────────────────────────────────────────────
@Test
@Order(1)
void createLoadBalancer() {
CreateLoadBalancerResponse resp = elb.createLoadBalancer(r -> r
.name(LB_NAME)
.type(LoadBalancerTypeEnum.APPLICATION)
.ipAddressType(IpAddressType.IPV4));
assertThat(resp.loadBalancers()).hasSize(1);
lbArn = resp.loadBalancers().get(0).loadBalancerArn();
assertThat(lbArn).isNotBlank();
}
@Test
@Order(2)
void createBlueTargetGroup() {
CreateTargetGroupResponse resp = elb.createTargetGroup(r -> r
.name(BLUE_TG)
.protocol(ProtocolEnum.HTTP)
.port(80)
.vpcId("vpc-00000001"));
assertThat(resp.targetGroups()).hasSize(1);
blueTgArn = resp.targetGroups().get(0).targetGroupArn();
assertThat(blueTgArn).isNotBlank();
}
@Test
@Order(3)
void createGreenTargetGroup() {
CreateTargetGroupResponse resp = elb.createTargetGroup(r -> r
.name(GREEN_TG)
.protocol(ProtocolEnum.HTTP)
.port(80)
.vpcId("vpc-00000001"));
assertThat(resp.targetGroups()).hasSize(1);
greenTgArn = resp.targetGroups().get(0).targetGroupArn();
assertThat(greenTgArn).isNotBlank().isNotEqualTo(blueTgArn);
}
@Test
@Order(4)
void createListenerPointingToBlue() {
assertThat(lbArn).isNotNull();
assertThat(blueTgArn).isNotNull();
CreateListenerResponse resp = elb.createListener(r -> r
.loadBalancerArn(lbArn)
.protocol(ProtocolEnum.HTTP)
.port(18090)
.defaultActions(software.amazon.awssdk.services.elasticloadbalancingv2.model.Action.builder()
.type(ActionTypeEnum.FORWARD)
.targetGroupArn(blueTgArn)
.build()));
assertThat(resp.listeners()).hasSize(1);
listenerArn = resp.listeners().get(0).listenerArn();
assertThat(listenerArn).isNotBlank();
}
@Test
@Order(5)
void verifyListenerInitiallyPointsToBlue() {
assertThat(listenerArn).isNotNull();
DescribeRulesResponse resp = elb.describeRules(r -> r.listenerArn(listenerArn));
var defaultRule = resp.rules().stream()
.filter(rule -> rule.isDefault())
.findFirst();
assertThat(defaultRule).isPresent();
var forwardAction = defaultRule.get().actions().stream()
.filter(a -> ActionTypeEnum.FORWARD.equals(a.type()))
.findFirst();
assertThat(forwardAction).isPresent();
assertThat(forwardAction.get().targetGroupArn()).isEqualTo(blueTgArn);
}
// ── ECS setup ────────────────────────────────────────────────────────────
@Test
@Order(6)
void createEcsCluster() {
CreateClusterResponse resp = ecs.createCluster(r -> r.clusterName(CLUSTER));
assertThat(resp.cluster().clusterName()).isEqualTo(CLUSTER);
assertThat(resp.cluster().status()).isEqualTo("ACTIVE");
}
@Test
@Order(7)
void registerTaskDefinition() {
RegisterTaskDefinitionResponse resp = ecs.registerTaskDefinition(r -> r
.family(TASK_FAMILY)
.containerDefinitions(cd -> cd
.name("app")
.image("nginx:latest")
.portMappings(pm -> pm.containerPort(80))));
assertThat(resp.taskDefinition().family()).isEqualTo(TASK_FAMILY);
taskDefArn = resp.taskDefinition().taskDefinitionArn();
assertThat(taskDefArn).isNotBlank();
}
@Test
@Order(8)
void createEcsService() {
assertThat(taskDefArn).isNotNull();
CreateServiceResponse resp = ecs.createService(r -> r
.cluster(CLUSTER)
.serviceName(SERVICE)
.taskDefinition(TASK_FAMILY + ":1")
.desiredCount(1)
.deploymentController(dc -> dc.type(DeploymentControllerType.EXTERNAL)));
assertThat(resp.service().serviceName()).isEqualTo(SERVICE);
}
// ── CodeDeploy ECS deployment ─────────────────────────────────────────────
@Test
@Order(9)
void createEcsApplication() {
var resp = codedeploy.createApplication(r -> r
.applicationName(APP_NAME)
.computePlatform(ComputePlatform.ECS));
assertThat(resp.applicationId()).isNotBlank();
}
@Test
@Order(10)
void createEcsDeploymentGroup() {
assertThat(listenerArn).isNotNull();
assertThat(blueTgArn).isNotNull();
assertThat(greenTgArn).isNotNull();
CreateDeploymentGroupResponse resp = codedeploy.createDeploymentGroup(r -> r
.applicationName(APP_NAME)
.deploymentGroupName(DG_NAME)
.deploymentConfigName("CodeDeployDefault.ECSAllAtOnce")
.serviceRoleArn(ROLE)
.deploymentStyle(DeploymentStyle.builder()
.deploymentType(DeploymentType.BLUE_GREEN)
.deploymentOption(DeploymentOption.WITH_TRAFFIC_CONTROL)
.build())
.ecsServices(ECSService.builder()
.clusterName(CLUSTER)
.serviceName(SERVICE)
.build())
.loadBalancerInfo(LoadBalancerInfo.builder()
.targetGroupPairInfoList(TargetGroupPairInfo.builder()
.targetGroups(
TargetGroupInfo.builder().name(BLUE_TG).build(),
TargetGroupInfo.builder().name(GREEN_TG).build())
.prodTrafficRoute(TrafficRoute.builder()
.listenerArns(listenerArn)
.build())
.build())
.build()));
assertThat(resp.deploymentGroupId()).isNotBlank();
}
@Test
@Order(11)
void createEcsDeployment_allAtOnce() {
assertThat(taskDefArn).isNotNull();
String appSpec = """
{
"version": 0.0,
"Resources": [{
"TargetService": {
"Type": "AWS::ECS::Service",
"Properties": {
"TaskDefinition": "%s",
"LoadBalancerInfo": {
"ContainerName": "app",
"ContainerPort": 80
}
}
}
}]
}
""".formatted(taskDefArn);
CreateDeploymentResponse resp = codedeploy.createDeployment(r -> r
.applicationName(APP_NAME)
.deploymentGroupName(DG_NAME)
.deploymentConfigName("CodeDeployDefault.ECSAllAtOnce")
.revision(RevisionLocation.builder()
.revisionType(RevisionLocationType.APP_SPEC_CONTENT)
.appSpecContent(AppSpecContent.builder()
.content(appSpec)
.build())
.build()));
assertThat(resp.deploymentId()).startsWith("d-");
deploymentId = resp.deploymentId();
}
@Test
@Order(12)
void getDeployment_returnsEcsComputePlatform() {
assertThat(deploymentId).isNotNull();
GetDeploymentResponse resp = codedeploy.getDeployment(r -> r.deploymentId(deploymentId));
assertThat(resp.deploymentInfo().deploymentId()).isEqualTo(deploymentId);
assertThat(resp.deploymentInfo().applicationName()).isEqualTo(APP_NAME);
assertThat(resp.deploymentInfo().computePlatformAsString()).isEqualTo("ECS");
}
@Test
@Order(13)
void deployment_eventuallySucceeds() throws InterruptedException {
assertThat(deploymentId).isNotNull();
DeploymentStatus status = DeploymentStatus.QUEUED;
for (int i = 0; i < 30 && !DeploymentStatus.SUCCEEDED.equals(status)
&& !DeploymentStatus.FAILED.equals(status); i++) {
Thread.sleep(500);
GetDeploymentResponse resp = codedeploy.getDeployment(r -> r.deploymentId(deploymentId));
status = resp.deploymentInfo().status();
}
assertThat(status).isEqualTo(DeploymentStatus.SUCCEEDED);
}
@Test
@Order(14)
void listDeploymentTargets_containsEcsTarget() {
assertThat(deploymentId).isNotNull();
ListDeploymentTargetsResponse resp = codedeploy.listDeploymentTargets(r -> r
.deploymentId(deploymentId));
assertThat(resp.targetIds()).hasSize(1);
assertThat(resp.targetIds().get(0)).contains(CLUSTER);
}
@Test
@Order(15)
void batchGetDeploymentTargets_returnsEcsTargetWithSucceededStatus() {
assertThat(deploymentId).isNotNull();
List<String> targetIds = codedeploy.listDeploymentTargets(r -> r
.deploymentId(deploymentId)).targetIds();
BatchGetDeploymentTargetsResponse resp = codedeploy.batchGetDeploymentTargets(r -> r
.deploymentId(deploymentId)
.targetIds(targetIds));
assertThat(resp.deploymentTargets()).hasSize(1);
var target = resp.deploymentTargets().get(0);
assertThat(target.deploymentTargetTypeAsString()).isEqualTo("ECSTarget");
assertThat(target.ecsTarget()).isNotNull();
assertThat(target.ecsTarget().statusAsString()).isEqualTo("Succeeded");
assertThat(target.ecsTarget().lifecycleEvents())
.anyMatch(e -> "Install".equals(e.lifecycleEventName()))
.anyMatch(e -> "AllowTraffic".equals(e.lifecycleEventName()));
}
@Test
@Order(16)
void listenerNowPointsToGreen_afterDeployment() {
assertThat(listenerArn).isNotNull();
assertThat(greenTgArn).isNotNull();
DescribeRulesResponse resp = elb.describeRules(r -> r.listenerArn(listenerArn));
var defaultRule = resp.rules().stream()
.filter(rule -> rule.isDefault())
.findFirst();
assertThat(defaultRule).isPresent();
var forwardAction = defaultRule.get().actions().stream()
.filter(a -> ActionTypeEnum.FORWARD.equals(a.type()))
.findFirst();
assertThat(forwardAction).isPresent();
assertThat(forwardAction.get().targetGroupArn()).isEqualTo(greenTgArn);
}
@Test
@Order(17)
void ecsTaskSetCreatedForGreenDeployment() {
// The green task set should exist and be PRIMARY after deployment
var taskSets = ecs.describeTaskSets(r -> r
.cluster(CLUSTER)
.service(SERVICE));
assertThat(taskSets.taskSets())
.anyMatch(ts -> "PRIMARY".equals(ts.status()));
}
// ── Canary deployment variant ─────────────────────────────────────────────
@Test
@Order(20)
void createCanaryDeployment() {
String appSpec = """
{
"version": 0.0,
"Resources": [{
"TargetService": {
"Type": "AWS::ECS::Service",
"Properties": {
"TaskDefinition": "%s",
"LoadBalancerInfo": {
"ContainerName": "app",
"ContainerPort": 80
}
}
}
}]
}
""".formatted(taskDefArn);
CreateDeploymentResponse resp = codedeploy.createDeployment(r -> r
.applicationName(APP_NAME)
.deploymentGroupName(DG_NAME)
.deploymentConfigName("CodeDeployDefault.ECSCanary10Percent5Minutes")
.revision(RevisionLocation.builder()
.revisionType(RevisionLocationType.APP_SPEC_CONTENT)
.appSpecContent(AppSpecContent.builder().content(appSpec).build())
.build()));
assertThat(resp.deploymentId()).startsWith("d-");
String canaryDeploymentId = resp.deploymentId();
// Poll until done (Floci caps wait times so this completes quickly)
DeploymentStatus status = DeploymentStatus.QUEUED;
try {
for (int i = 0; i < 40 && !DeploymentStatus.SUCCEEDED.equals(status)
&& !DeploymentStatus.FAILED.equals(status); i++) {
Thread.sleep(500);
GetDeploymentResponse gr = codedeploy.getDeployment(r -> r.deploymentId(canaryDeploymentId));
status = gr.deploymentInfo().status();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
assertThat(status).isEqualTo(DeploymentStatus.SUCCEEDED);
}
}
@@ -0,0 +1,473 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.codedeploy.CodeDeployClient;
import software.amazon.awssdk.services.codedeploy.model.AppSpecContent;
import software.amazon.awssdk.services.codedeploy.model.BatchGetApplicationsResponse;
import software.amazon.awssdk.services.codedeploy.model.BatchGetDeploymentGroupsResponse;
import software.amazon.awssdk.services.codedeploy.model.BatchGetDeploymentsResponse;
import software.amazon.awssdk.services.codedeploy.model.BatchGetDeploymentTargetsResponse;
import software.amazon.awssdk.services.codedeploy.model.ComputePlatform;
import software.amazon.awssdk.services.codedeploy.model.CreateApplicationResponse;
import software.amazon.awssdk.services.codedeploy.model.CreateDeploymentConfigResponse;
import software.amazon.awssdk.services.codedeploy.model.CreateDeploymentGroupResponse;
import software.amazon.awssdk.services.codedeploy.model.CreateDeploymentResponse;
import software.amazon.awssdk.services.codedeploy.model.DeploymentOption;
import software.amazon.awssdk.services.codedeploy.model.DeploymentReadyAction;
import software.amazon.awssdk.services.codedeploy.model.DeploymentStatus;
import software.amazon.awssdk.services.codedeploy.model.DeploymentStyle;
import software.amazon.awssdk.services.codedeploy.model.DeploymentType;
import software.amazon.awssdk.services.codedeploy.model.GetDeploymentConfigResponse;
import software.amazon.awssdk.services.codedeploy.model.GetDeploymentGroupResponse;
import software.amazon.awssdk.services.codedeploy.model.GetDeploymentResponse;
import software.amazon.awssdk.services.codedeploy.model.ListApplicationsResponse;
import software.amazon.awssdk.services.codedeploy.model.ListDeploymentConfigsResponse;
import software.amazon.awssdk.services.codedeploy.model.ListDeploymentGroupsResponse;
import software.amazon.awssdk.services.codedeploy.model.ListDeploymentTargetsResponse;
import software.amazon.awssdk.services.codedeploy.model.ListDeploymentsResponse;
import software.amazon.awssdk.services.codedeploy.model.ListTagsForResourceResponse;
import software.amazon.awssdk.services.codedeploy.model.MinimumHealthyHosts;
import software.amazon.awssdk.services.codedeploy.model.MinimumHealthyHostsType;
import software.amazon.awssdk.services.codedeploy.model.RevisionLocation;
import software.amazon.awssdk.services.codedeploy.model.RevisionLocationType;
import software.amazon.awssdk.services.codedeploy.model.Tag;
import software.amazon.awssdk.services.codedeploy.model.TrafficRoutingType;
import software.amazon.awssdk.services.lambda.LambdaClient;
import software.amazon.awssdk.services.lambda.model.CreateAliasResponse;
import software.amazon.awssdk.services.lambda.model.CreateFunctionRequest;
import software.amazon.awssdk.services.lambda.model.FunctionCode;
import software.amazon.awssdk.services.lambda.model.GetAliasResponse;
import software.amazon.awssdk.services.lambda.model.PublishVersionResponse;
import software.amazon.awssdk.services.lambda.model.Runtime;
import software.amazon.awssdk.services.lambda.model.UpdateAliasRequest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CodeDeployTest {
static CodeDeployClient codedeploy;
static LambdaClient lambda;
static String appId;
static String dgId;
static final String DEPLOY_FUNCTION = "cd-deploy-fn";
static final String DEPLOY_ALIAS = "live";
static final String ROLE = "arn:aws:iam::000000000000:role/lambda-role";
static String deploymentId;
static String v1;
static String v2;
@BeforeAll
static void setup() {
codedeploy = TestFixtures.codeDeployClient();
lambda = TestFixtures.lambdaClient();
}
@AfterAll
static void teardown() {
codedeploy.close();
lambda.close();
}
@Test
@Order(1)
void builtInDeploymentConfigsExist() {
ListDeploymentConfigsResponse resp = codedeploy.listDeploymentConfigs(r -> r.build());
assertThat(resp.deploymentConfigsList())
.contains("CodeDeployDefault.AllAtOnce",
"CodeDeployDefault.HalfAtATime",
"CodeDeployDefault.OneAtATime",
"CodeDeployDefault.LambdaAllAtOnce",
"CodeDeployDefault.ECSAllAtOnce");
}
@Test
@Order(2)
void getLambdaDeploymentConfig() {
GetDeploymentConfigResponse resp = codedeploy.getDeploymentConfig(r -> r
.deploymentConfigName("CodeDeployDefault.LambdaAllAtOnce"));
assertThat(resp.deploymentConfigInfo().deploymentConfigName())
.isEqualTo("CodeDeployDefault.LambdaAllAtOnce");
assertThat(resp.deploymentConfigInfo().computePlatform()).isEqualTo(ComputePlatform.LAMBDA);
assertThat(resp.deploymentConfigInfo().trafficRoutingConfig().type())
.isEqualTo(TrafficRoutingType.ALL_AT_ONCE);
}
@Test
@Order(3)
void getLambdaCanaryConfig() {
GetDeploymentConfigResponse resp = codedeploy.getDeploymentConfig(r -> r
.deploymentConfigName("CodeDeployDefault.LambdaCanary10Percent5Minutes"));
assertThat(resp.deploymentConfigInfo().computePlatform()).isEqualTo(ComputePlatform.LAMBDA);
assertThat(resp.deploymentConfigInfo().trafficRoutingConfig().type())
.isEqualTo(TrafficRoutingType.TIME_BASED_CANARY);
assertThat(resp.deploymentConfigInfo().trafficRoutingConfig().timeBasedCanary().canaryPercentage())
.isEqualTo(10);
}
@Test
@Order(4)
void createApplication() {
CreateApplicationResponse resp = codedeploy.createApplication(r -> r
.applicationName("sdk-lambda-app")
.computePlatform(ComputePlatform.LAMBDA)
.tags(Tag.builder().key("env").value("test").build()));
assertThat(resp.applicationId()).isNotBlank();
appId = resp.applicationId();
}
@Test
@Order(5)
void getApplication() {
var resp = codedeploy.getApplication(r -> r.applicationName("sdk-lambda-app"));
assertThat(resp.application().applicationName()).isEqualTo("sdk-lambda-app");
assertThat(resp.application().computePlatform()).isEqualTo(ComputePlatform.LAMBDA);
assertThat(resp.application().linkedToGitHub()).isFalse();
}
@Test
@Order(6)
void listApplications() {
ListApplicationsResponse resp = codedeploy.listApplications(r -> r.build());
assertThat(resp.applications()).contains("sdk-lambda-app");
}
@Test
@Order(7)
void batchGetApplications() {
BatchGetApplicationsResponse resp = codedeploy.batchGetApplications(r -> r
.applicationNames("sdk-lambda-app"));
assertThat(resp.applicationsInfo()).hasSize(1);
assertThat(resp.applicationsInfo().get(0).applicationName()).isEqualTo("sdk-lambda-app");
}
@Test
@Order(8)
void createDeploymentGroup() {
CreateDeploymentGroupResponse resp = codedeploy.createDeploymentGroup(r -> r
.applicationName("sdk-lambda-app")
.deploymentGroupName("sdk-lambda-dg")
.deploymentConfigName("CodeDeployDefault.LambdaAllAtOnce")
.serviceRoleArn("arn:aws:iam::000000000000:role/codedeploy-role")
.deploymentStyle(DeploymentStyle.builder()
.deploymentType(DeploymentType.BLUE_GREEN)
.deploymentOption(DeploymentOption.WITH_TRAFFIC_CONTROL)
.build()));
assertThat(resp.deploymentGroupId()).isNotBlank();
dgId = resp.deploymentGroupId();
}
@Test
@Order(9)
void getDeploymentGroup() {
GetDeploymentGroupResponse resp = codedeploy.getDeploymentGroup(r -> r
.applicationName("sdk-lambda-app")
.deploymentGroupName("sdk-lambda-dg"));
assertThat(resp.deploymentGroupInfo().deploymentGroupName()).isEqualTo("sdk-lambda-dg");
assertThat(resp.deploymentGroupInfo().deploymentConfigName())
.isEqualTo("CodeDeployDefault.LambdaAllAtOnce");
}
@Test
@Order(10)
void listDeploymentGroups() {
ListDeploymentGroupsResponse resp = codedeploy.listDeploymentGroups(r -> r
.applicationName("sdk-lambda-app"));
assertThat(resp.applicationName()).isEqualTo("sdk-lambda-app");
assertThat(resp.deploymentGroups()).contains("sdk-lambda-dg");
}
@Test
@Order(11)
void batchGetDeploymentGroups() {
BatchGetDeploymentGroupsResponse resp = codedeploy.batchGetDeploymentGroups(r -> r
.applicationName("sdk-lambda-app")
.deploymentGroupNames("sdk-lambda-dg"));
assertThat(resp.deploymentGroupsInfo()).hasSize(1);
assertThat(resp.deploymentGroupsInfo().get(0).deploymentGroupName()).isEqualTo("sdk-lambda-dg");
}
@Test
@Order(12)
void createCustomDeploymentConfig() {
CreateDeploymentConfigResponse resp = codedeploy.createDeploymentConfig(r -> r
.deploymentConfigName("SdkTestConfig")
.minimumHealthyHosts(MinimumHealthyHosts.builder()
.type(MinimumHealthyHostsType.FLEET_PERCENT)
.value(75)
.build())
.computePlatform(ComputePlatform.SERVER));
assertThat(resp.deploymentConfigId()).isNotBlank();
}
@Test
@Order(13)
void getCustomDeploymentConfig() {
GetDeploymentConfigResponse resp = codedeploy.getDeploymentConfig(r -> r
.deploymentConfigName("SdkTestConfig"));
assertThat(resp.deploymentConfigInfo().deploymentConfigName()).isEqualTo("SdkTestConfig");
assertThat(resp.deploymentConfigInfo().computePlatform()).isEqualTo(ComputePlatform.SERVER);
assertThat(resp.deploymentConfigInfo().minimumHealthyHosts().type())
.isEqualTo(MinimumHealthyHostsType.FLEET_PERCENT);
assertThat(resp.deploymentConfigInfo().minimumHealthyHosts().value()).isEqualTo(75);
}
@Test
@Order(14)
void tagAndListTags() {
String arn = "arn:aws:codedeploy:us-east-1:000000000000:application:sdk-lambda-app";
codedeploy.tagResource(r -> r
.resourceArn(arn)
.tags(Tag.builder().key("team").value("platform").build(),
Tag.builder().key("project").value("floci").build()));
ListTagsForResourceResponse resp = codedeploy.listTagsForResource(r -> r.resourceArn(arn));
// env=test was added during createApplication, plus team and project here
assertThat(resp.tags().stream().map(Tag::key)).contains("team", "project", "env");
}
@Test
@Order(15)
void untagResource() {
String arn = "arn:aws:codedeploy:us-east-1:000000000000:application:sdk-lambda-app";
codedeploy.untagResource(r -> r.resourceArn(arn).tagKeys("project"));
ListTagsForResourceResponse resp = codedeploy.listTagsForResource(r -> r.resourceArn(arn));
assertThat(resp.tags().stream().map(Tag::key)).doesNotContain("project");
assertThat(resp.tags().stream().map(Tag::key)).contains("team", "env");
}
@Test
@Order(16)
void cannotDeleteBuiltInConfig() {
assertThatThrownBy(() ->
codedeploy.deleteDeploymentConfig(r -> r.deploymentConfigName("CodeDeployDefault.AllAtOnce")))
.isInstanceOf(software.amazon.awssdk.services.codedeploy.model.InvalidDeploymentConfigNameException.class);
}
@Test
@Order(17)
void cleanup() {
codedeploy.deleteDeploymentGroup(r -> r
.applicationName("sdk-lambda-app")
.deploymentGroupName("sdk-lambda-dg"));
codedeploy.deleteDeploymentConfig(r -> r.deploymentConfigName("SdkTestConfig"));
codedeploy.deleteApplication(r -> r.applicationName("sdk-lambda-app"));
ListApplicationsResponse after = codedeploy.listApplications(r -> r.build());
assertThat(after.applications()).doesNotContain("sdk-lambda-app");
}
// ---- Phase 2: Lambda deployment via CodeDeploy ----
@Test
@Order(20)
void setupLambdaFunctionForDeployment() {
// Create function and publish two versions, alias pointing to v1
lambda.createFunction(CreateFunctionRequest.builder()
.functionName(DEPLOY_FUNCTION)
.runtime(Runtime.NODEJS20_X)
.role(ROLE)
.handler("index.handler")
.code(FunctionCode.builder()
.zipFile(SdkBytes.fromByteArray(LambdaUtils.minimalZip()))
.build())
.build());
PublishVersionResponse pv1 = lambda.publishVersion(r -> r.functionName(DEPLOY_FUNCTION));
v1 = pv1.version();
assertThat(v1).isNotBlank();
// A second version needs a real change behind it. AWS does not publish a version when the
// code and configuration have not moved since the last one, so two publishes in a row
// return the same version. A blue/green deployment shifts between two different builds
// anyway, so deploy one here rather than publishing the same bytes twice.
lambda.updateFunctionCode(r -> r
.functionName(DEPLOY_FUNCTION)
.zipFile(SdkBytes.fromByteArray(LambdaUtils.handlerZip())));
PublishVersionResponse pv2 = lambda.publishVersion(r -> r.functionName(DEPLOY_FUNCTION));
v2 = pv2.version();
assertThat(v2).isNotBlank().isNotEqualTo(v1);
CreateAliasResponse alias = lambda.createAlias(r -> r
.functionName(DEPLOY_FUNCTION)
.name(DEPLOY_ALIAS)
.functionVersion(v1));
assertThat(alias.aliasArn()).contains(DEPLOY_FUNCTION);
}
@Test
@Order(21)
void setupCodeDeployAppAndGroupForDeployment() {
codedeploy.createApplication(r -> r
.applicationName("cd-lambda-app")
.computePlatform(ComputePlatform.LAMBDA));
codedeploy.createDeploymentGroup(r -> r
.applicationName("cd-lambda-app")
.deploymentGroupName("cd-lambda-dg")
.deploymentConfigName("CodeDeployDefault.LambdaAllAtOnce")
.serviceRoleArn("arn:aws:iam::000000000000:role/codedeploy-role"));
}
@Test
@Order(22)
void createDeployment_allAtOnce() {
String appSpec = """
{
"version": "0.0",
"Resources": [{
"myFunction": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Name": "%s",
"Alias": "%s",
"CurrentVersion": "%s",
"TargetVersion": "%s"
}
}
}]
}
""".formatted(DEPLOY_FUNCTION, DEPLOY_ALIAS, v1, v2);
CreateDeploymentResponse resp = codedeploy.createDeployment(r -> r
.applicationName("cd-lambda-app")
.deploymentGroupName("cd-lambda-dg")
.deploymentConfigName("CodeDeployDefault.LambdaAllAtOnce")
.revision(RevisionLocation.builder()
.revisionType(RevisionLocationType.APP_SPEC_CONTENT)
.appSpecContent(AppSpecContent.builder()
.content(appSpec)
.build())
.build()));
assertThat(resp.deploymentId()).startsWith("d-");
deploymentId = resp.deploymentId();
}
@Test
@Order(23)
void getDeployment_returnsInfo() {
assertThat(deploymentId).isNotNull();
GetDeploymentResponse resp = codedeploy.getDeployment(r -> r.deploymentId(deploymentId));
assertThat(resp.deploymentInfo().deploymentId()).isEqualTo(deploymentId);
assertThat(resp.deploymentInfo().applicationName()).isEqualTo("cd-lambda-app");
assertThat(resp.deploymentInfo().deploymentGroupName()).isEqualTo("cd-lambda-dg");
assertThat(resp.deploymentInfo().status()).isIn(
DeploymentStatus.QUEUED, DeploymentStatus.IN_PROGRESS, DeploymentStatus.SUCCEEDED);
}
@Test
@Order(24)
void getDeployment_eventuallySucceeds() throws InterruptedException {
assertThat(deploymentId).isNotNull();
DeploymentStatus status = DeploymentStatus.QUEUED;
for (int i = 0; i < 20 && !DeploymentStatus.SUCCEEDED.equals(status); i++) {
Thread.sleep(500);
GetDeploymentResponse resp = codedeploy.getDeployment(r -> r.deploymentId(deploymentId));
status = resp.deploymentInfo().status();
}
assertThat(status).isEqualTo(DeploymentStatus.SUCCEEDED);
}
@Test
@Order(25)
void aliasPointsToTargetVersionAfterDeployment() {
GetAliasResponse alias = lambda.getAlias(r -> r
.functionName(DEPLOY_FUNCTION)
.name(DEPLOY_ALIAS));
assertThat(alias.functionVersion()).isEqualTo(v2);
assertThat(alias.routingConfig()).isNull();
}
@Test
@Order(26)
void listDeployments_containsDeploymentId() {
assertThat(deploymentId).isNotNull();
ListDeploymentsResponse resp = codedeploy.listDeployments(r -> r
.applicationName("cd-lambda-app")
.deploymentGroupName("cd-lambda-dg"));
assertThat(resp.deployments()).contains(deploymentId);
}
@Test
@Order(27)
void batchGetDeployments_returnsDeploymentInfo() {
assertThat(deploymentId).isNotNull();
BatchGetDeploymentsResponse resp = codedeploy.batchGetDeployments(r -> r
.deploymentIds(deploymentId));
assertThat(resp.deploymentsInfo()).hasSize(1);
assertThat(resp.deploymentsInfo().get(0).deploymentId()).isEqualTo(deploymentId);
assertThat(resp.deploymentsInfo().get(0).status()).isEqualTo(DeploymentStatus.SUCCEEDED);
}
@Test
@Order(28)
void listDeploymentTargets_returnsSingleTarget() {
assertThat(deploymentId).isNotNull();
ListDeploymentTargetsResponse resp = codedeploy.listDeploymentTargets(r -> r
.deploymentId(deploymentId));
assertThat(resp.targetIds()).hasSize(1);
assertThat(resp.targetIds().get(0)).contains(DEPLOY_FUNCTION);
}
@Test
@Order(29)
void batchGetDeploymentTargets_returnsLambdaTarget() {
assertThat(deploymentId).isNotNull();
ListDeploymentTargetsResponse listResp = codedeploy.listDeploymentTargets(r -> r
.deploymentId(deploymentId));
List<String> targetIds = listResp.targetIds();
BatchGetDeploymentTargetsResponse resp = codedeploy.batchGetDeploymentTargets(r -> r
.deploymentId(deploymentId)
.targetIds(targetIds));
assertThat(resp.deploymentTargets()).hasSize(1);
assertThat(resp.deploymentTargets().get(0).deploymentTargetTypeAsString())
.isEqualTo("LambdaFunction");
assertThat(resp.deploymentTargets().get(0).lambdaTarget()).isNotNull();
assertThat(resp.deploymentTargets().get(0).lambdaTarget().statusAsString())
.isEqualTo("Succeeded");
assertThat(resp.deploymentTargets().get(0).lambdaTarget().lifecycleEvents())
.anyMatch(e -> "AllowTraffic".equals(e.lifecycleEventName()));
}
@Test
@Order(30)
void cleanupDeployment() {
lambda.deleteFunction(r -> r.functionName(DEPLOY_FUNCTION));
codedeploy.deleteDeploymentGroup(r -> r
.applicationName("cd-lambda-app")
.deploymentGroupName("cd-lambda-dg"));
codedeploy.deleteApplication(r -> r.applicationName("cd-lambda-app"));
}
}
@@ -0,0 +1,147 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.codepipeline.CodePipelineClient;
import software.amazon.awssdk.services.codepipeline.model.ActionCategory;
import software.amazon.awssdk.services.codepipeline.model.ActionDeclaration;
import software.amazon.awssdk.services.codepipeline.model.ActionOwner;
import software.amazon.awssdk.services.codepipeline.model.ActionTypeId;
import software.amazon.awssdk.services.codepipeline.model.ArtifactStore;
import software.amazon.awssdk.services.codepipeline.model.ArtifactStoreType;
import software.amazon.awssdk.services.codepipeline.model.CreatePipelineResponse;
import software.amazon.awssdk.services.codepipeline.model.DeleteWebhookResponse;
import software.amazon.awssdk.services.codepipeline.model.GetPipelineResponse;
import software.amazon.awssdk.services.codepipeline.model.ListPipelinesResponse;
import software.amazon.awssdk.services.codepipeline.model.ListWebhooksResponse;
import software.amazon.awssdk.services.codepipeline.model.OutputArtifact;
import software.amazon.awssdk.services.codepipeline.model.PipelineDeclaration;
import software.amazon.awssdk.services.codepipeline.model.PutWebhookResponse;
import software.amazon.awssdk.services.codepipeline.model.StageDeclaration;
import software.amazon.awssdk.services.codepipeline.model.UpdatePipelineResponse;
import software.amazon.awssdk.services.codepipeline.model.WebhookAuthenticationType;
import software.amazon.awssdk.services.codepipeline.model.WebhookDefinition;
import software.amazon.awssdk.services.codepipeline.model.WebhookFilterRule;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CodePipelineTest {
private static final String PIPELINE = "sdk-codepipeline";
private static CodePipelineClient codepipeline;
@BeforeAll
static void setup() {
codepipeline = TestFixtures.codePipelineClient();
}
@AfterAll
static void teardown() {
codepipeline.close();
}
@Test
@Order(1)
void createAndGetPipeline() {
CreatePipelineResponse created = codepipeline.createPipeline(r -> r.pipeline(declaration("Deploy")));
assertThat(created.pipeline().name()).isEqualTo(PIPELINE);
assertThat(created.pipeline().version()).isEqualTo(1);
GetPipelineResponse fetched = codepipeline.getPipeline(r -> r.name(PIPELINE));
assertThat(fetched.pipeline().stages()).hasSize(2);
assertThat(fetched.metadata().pipelineArn()).endsWith(":" + PIPELINE);
}
@Test
@Order(2)
void updateAndListPipeline() {
UpdatePipelineResponse updated = codepipeline.updatePipeline(r -> r.pipeline(declaration("Release")));
assertThat(updated.pipeline().version()).isEqualTo(2);
assertThat(updated.pipeline().stages().get(1).name()).isEqualTo("Release");
ListPipelinesResponse listed = codepipeline.listPipelines();
assertThat(listed.pipelines()).extracting(p -> p.name()).contains(PIPELINE);
}
@Test
@Order(3)
void webhookResponsesUseSdkModelShape() {
WebhookDefinition definition = WebhookDefinition.builder()
.name("sdk-webhook")
.targetPipeline(PIPELINE)
.targetAction("Source")
.authentication(WebhookAuthenticationType.UNAUTHENTICATED)
.filters(WebhookFilterRule.builder()
.jsonPath("$.ref")
.matchEquals("refs/heads/main")
.build())
.build();
PutWebhookResponse created = codepipeline.putWebhook(r -> r.webhook(definition));
assertThat(created.webhook().definition()).isEqualTo(definition);
assertThat(created.webhook().arn()).endsWith(":webhook/sdk-webhook");
ListWebhooksResponse listed = codepipeline.listWebhooks();
assertThat(listed.webhooks()).singleElement()
.extracting(webhook -> webhook.definition().name())
.isEqualTo("sdk-webhook");
DeleteWebhookResponse deleted = codepipeline.deleteWebhook(r -> r.name("sdk-webhook"));
assertThat(deleted.sdkFields()).isEmpty();
}
@Test
@Order(4)
void deletePipeline() {
codepipeline.deletePipeline(r -> r.name(PIPELINE));
assertThat(codepipeline.listPipelines().pipelines())
.extracting(p -> p.name())
.doesNotContain(PIPELINE);
}
private static PipelineDeclaration declaration(String secondStage) {
return PipelineDeclaration.builder()
.name(PIPELINE)
.roleArn("arn:aws:iam::000000000000:role/codepipeline-role")
.artifactStore(ArtifactStore.builder()
.type(ArtifactStoreType.S3)
.location("codepipeline-artifacts")
.build())
.stages(
StageDeclaration.builder()
.name("Source")
.actions(ActionDeclaration.builder()
.name("Source")
.actionTypeId(ActionTypeId.builder()
.category(ActionCategory.SOURCE)
.owner(ActionOwner.AWS)
.provider("S3")
.version("1")
.build())
.configuration(Map.of(
"S3Bucket", "codepipeline-source",
"S3ObjectKey", "source.zip"))
.outputArtifacts(OutputArtifact.builder().name("SourceOutput").build())
.build())
.build(),
StageDeclaration.builder()
.name(secondStage)
.actions(ActionDeclaration.builder()
.name("Approval")
.actionTypeId(ActionTypeId.builder()
.category(ActionCategory.APPROVAL)
.owner(ActionOwner.AWS)
.provider("Manual")
.version("1")
.build())
.build())
.build())
.build();
}
}
@@ -0,0 +1,250 @@
package com.floci.test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.cognitoidentityprovider.CognitoIdentityProviderClient;
import software.amazon.awssdk.services.cognitoidentityprovider.model.AdminGetUserResponse;
import software.amazon.awssdk.services.cognitoidentityprovider.model.AttributeType;
import software.amazon.awssdk.services.cognitoidentityprovider.model.CreateUserPoolResponse;
import software.amazon.awssdk.services.cognitoidentityprovider.model.VerifiedAttributeType;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Compatibility tests for Cognito ConfirmSignUp verified attribute updates (Issue #1654).
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CognitoConfirmSignUpVerificationTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private static final Pattern SIX_DIGIT_CODE_PATTERN = Pattern.compile("\\b(\\d{6})\\b");
private static final String PASSWORD = "Passw0rd!";
private static CognitoIdentityProviderClient cognitoClient;
private String poolId;
@BeforeAll
static void setup() {
cognitoClient = TestFixtures.cognitoClient();
}
@AfterAll
static void cleanup() {
if (cognitoClient != null) {
cognitoClient.close();
}
}
@AfterEach
void tearDown() {
if (poolId != null) {
try {
cognitoClient.deleteUserPool(b -> b.userPoolId(poolId));
} catch (Exception ignored) {
}
poolId = null;
}
}
@Test
@Order(1)
@DisplayName("ConfirmSignUp marks email_verified=true when email is the auto verified attribute")
void confirmSignUpMarksEmailVerified() throws Exception {
clearInspectionEndpoint("/_aws/ses");
String poolName = "email-verify-" + UUID.randomUUID();
CreateUserPoolResponse poolResp = cognitoClient.createUserPool(b -> b
.poolName(poolName)
.autoVerifiedAttributes(VerifiedAttributeType.EMAIL));
poolId = poolResp.userPool().id();
String clientId = cognitoClient.createUserPoolClient(b -> b
.userPoolId(poolId)
.clientName("email-verify-client"))
.userPoolClient().clientId();
String email = "alice+" + UUID.randomUUID() + "@example.com";
cognitoClient.signUp(b -> b
.clientId(clientId)
.username(email)
.password(PASSWORD)
.userAttributes(AttributeType.builder()
.name("email")
.value(email)
.build()));
String confirmationCode = fetchVerificationCodeFromSes(email);
cognitoClient.confirmSignUp(b -> b
.clientId(clientId)
.username(email)
.confirmationCode(confirmationCode));
AdminGetUserResponse user = cognitoClient.adminGetUser(b -> b
.userPoolId(poolId)
.username(email));
assertThat(user.userStatusAsString())
.as("ConfirmSignUp should confirm the user")
.isEqualTo("CONFIRMED");
String emailVerified = makeAttributeValue(user.userAttributes(), "email_verified");
assertThat(emailVerified)
.as("ConfirmSignUp should set email_verified=true for the email auto verified attribute")
.isEqualTo("true");
}
@Test
@Order(2)
@DisplayName("ConfirmSignUp marks phone_number_verified=true when phone_number is the auto verified attribute")
void confirmSignUpMarksPhoneNumberVerified() throws Exception {
clearInspectionEndpoint("/_aws/sns");
String poolName = "phone-verify-" + UUID.randomUUID();
CreateUserPoolResponse poolResp = cognitoClient.createUserPool(b -> b
.poolName(poolName)
.autoVerifiedAttributes(VerifiedAttributeType.PHONE_NUMBER));
poolId = poolResp.userPool().id();
String clientId = cognitoClient.createUserPoolClient(b -> b
.userPoolId(poolId)
.clientName("phone-verify-client"))
.userPoolClient().clientId();
String username = "phone-user-" + UUID.randomUUID();
String phoneNumber = "+49170" + ThreadLocalRandom.current().nextLong(1_000_000, 10_000_000);
cognitoClient.signUp(b -> b
.clientId(clientId)
.username(username)
.password(PASSWORD)
.userAttributes(AttributeType.builder()
.name("phone_number")
.value(phoneNumber)
.build()));
String confirmationCode = fetchVerificationCodeFromSns(phoneNumber);
cognitoClient.confirmSignUp(b -> b
.clientId(clientId)
.username(username)
.confirmationCode(confirmationCode));
AdminGetUserResponse user = cognitoClient.adminGetUser(b -> b
.userPoolId(poolId)
.username(username));
assertThat(user.userStatusAsString())
.as("ConfirmSignUp should confirm the user")
.isEqualTo("CONFIRMED");
String phoneVerified = makeAttributeValue(user.userAttributes(), "phone_number_verified");
assertThat(phoneVerified)
.as("ConfirmSignUp should set phone_number_verified=true for the phone_number auto-verified attribute (Issue #1654)")
.isEqualTo("true");
}
private static String makeAttributeValue(List<AttributeType> attributes, String name) {
return attributes.stream()
.filter(a -> name.equals(a.name()))
.map(AttributeType::value)
.findFirst()
.orElse(null);
}
private static void clearInspectionEndpoint(String path) throws Exception {
HttpResponse<String> resp = HTTP.send(
HttpRequest.newBuilder()
.uri(TestFixtures.endpoint().resolve(path))
.DELETE()
.timeout(Duration.ofSeconds(10))
.build(),
HttpResponse.BodyHandlers.ofString()
);
assertThat(resp.statusCode())
.as("clearing inspection endpoint %s should succeed", path)
.isEqualTo(200);
}
private static String fetchVerificationCodeFromSes(String recipient) throws Exception {
URI uri = TestFixtures.endpoint()
.resolve("/_aws/ses?email=" + URLEncoder.encode(recipient, StandardCharsets.UTF_8));
HttpResponse<String> resp = HTTP.send(
HttpRequest.newBuilder()
.uri(uri)
.GET()
.timeout(Duration.ofSeconds(10))
.build(),
HttpResponse.BodyHandlers.ofString()
);
JsonNode messages = JSON.readTree(resp.body()).path("messages");
assertThat(messages.isArray() && !messages.isEmpty())
.as("SES should have received a verification email for %s", recipient)
.isTrue();
String body = messages.get(0).path("Body").path("text_part").asText();
return extractSixDigitCode(body);
}
private static String fetchVerificationCodeFromSns(String phoneNumber) throws Exception {
URI uri = TestFixtures.endpoint()
.resolve("/_aws/sns?phone=" + URLEncoder.encode(phoneNumber, StandardCharsets.UTF_8));
HttpResponse<String> resp = HTTP.send(
HttpRequest.newBuilder()
.uri(uri)
.GET()
.timeout(Duration.ofSeconds(10))
.build(),
HttpResponse.BodyHandlers.ofString()
);
JsonNode messages = JSON.readTree(resp.body()).path("messages");
assertThat(messages.isArray() && !messages.isEmpty())
.as("SNS should have received a verification SMS for %s", phoneNumber)
.isTrue();
String body = messages.get(0).path("Message").asText();
return extractSixDigitCode(body);
}
private static String extractSixDigitCode(String body) {
Matcher matcher = SIX_DIGIT_CODE_PATTERN.matcher(body);
assertThat(matcher.find())
.as("Verification message body should contain a 6-digit code")
.isTrue();
return matcher.group(1);
}
}
@@ -0,0 +1,536 @@
package com.floci.test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.cognitoidentityprovider.CognitoIdentityProviderClient;
import software.amazon.awssdk.services.cognitoidentityprovider.model.*;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.Signature;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
/**
* Compatibility tests for Cognito fixes (Java SDK-based):
* #218 — RS256 JWT signing + JWKS signature verification
* #220 — AdminGetUser accepts sub UUID and email alias as Username
* #228 — AccessToken contains client_id claim
* #229 — InitiateAuth rejects auth when no password hash is set
* #233 — ListUsers respects Filter parameter
* #235 — AdminSetUserPassword(Permanent=false) changes the password
* #1505 — CreateUserPoolClient must not return empty {} for unset optional blocks
*
* Note: Issue #234 (GetTokensFromRefreshToken) is tested in sdk-test-node/tests/cognito-features.test.ts
* because GetTokensFromRefreshTokenCommand is not present in Java SDK 2.31.8.
*/
@DisplayName("Cognito IDP — bug fixes #218 #220 #228 #229 #233 #235 #1505")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CognitoFeaturesTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private static CognitoIdentityProviderClient cognito;
private static String poolId;
private static String userPoolClientId;
private static String poolArn;
private static String clientId;
private static final String USERNAME = "compat-user-" + UUID.randomUUID() + "@example.com";
private static final String PASSWORD = "CompatPass1!";
private static String userSub;
@BeforeAll
static void setup() {
cognito = TestFixtures.cognitoClient();
}
@AfterAll
static void cleanup() {
if (cognito == null) return;
try {
if (userPoolClientId != null) {
cognito.deleteUserPoolClient(b -> b
.userPoolId(poolId)
.clientId(userPoolClientId)
);
}
if (poolId != null) {
cognito.deleteUserPool(b -> b.userPoolId(poolId));
}
} catch (Exception ignored) {}
cognito.close();
}
// ── Setup ─────────────────────────────────────────────────────────────────
@Test
@Order(1)
void createPool() {
CreateUserPoolResponse resp = cognito.createUserPool(b -> b.poolName("compat-test-pool"));
poolId = resp.userPool().id();
poolArn = resp.userPool().arn();
assertThat(poolId).isNotBlank();
assertThat(poolArn).isNotBlank();
}
@Test
@Order(2)
void tagListAndUntagResourceRoundTrip() {
cognito.tagResource(b -> b.resourceArn(poolArn).tags(Map.of("env", "test", "team", "platform")));
ListTagsForResourceResponse tagged = cognito.listTagsForResource(b -> b.resourceArn(poolArn));
assertThat(tagged.tags()).containsEntry("env", "test").containsEntry("team", "platform");
cognito.untagResource(b -> b.resourceArn(poolArn).tagKeys("team"));
ListTagsForResourceResponse untagged = cognito.listTagsForResource(b -> b.resourceArn(poolArn));
assertThat(untagged.tags()).containsEntry("env", "test").doesNotContainKey("team");
}
@Test
@Order(3)
void tagResourceRejectsReservedKey() {
assertThatThrownBy(() -> cognito.tagResource(b -> b
.resourceArn(poolArn)
.tags(Map.of("floci:override-id", "late-id"))))
.isInstanceOf(CognitoIdentityProviderException.class)
.hasMessageContaining("Reserved tag keys with prefix floci:");
}
@Test
@Order(4)
void createClient() {
CreateUserPoolClientResponse resp = cognito.createUserPoolClient(b -> b
.userPoolId(poolId)
.clientName("compat-test-client")
.explicitAuthFlows(
ExplicitAuthFlowsType.ALLOW_USER_PASSWORD_AUTH,
ExplicitAuthFlowsType.ALLOW_ADMIN_USER_PASSWORD_AUTH,
ExplicitAuthFlowsType.ALLOW_REFRESH_TOKEN_AUTH));
clientId = resp.userPoolClient().clientId();
assertThat(clientId).isNotBlank();
}
@Test
@Order(5)
void createUserWithPermPassword() {
cognito.adminCreateUser(b -> b
.userPoolId(poolId)
.username(USERNAME)
.userAttributes(
AttributeType.builder().name("email").value(USERNAME).build(),
AttributeType.builder().name("email_verified").value("true").build())
.messageAction(MessageActionType.SUPPRESS));
cognito.adminSetUserPassword(b -> b
.userPoolId(poolId)
.username(USERNAME)
.password(PASSWORD)
.permanent(true));
AdminGetUserResponse user = cognito.adminGetUser(b -> b.userPoolId(poolId).username(USERNAME));
userSub = user.userAttributes().stream()
.filter(a -> "sub".equals(a.name()))
.map(AttributeType::value)
.findFirst()
.orElse(null);
assertThat(userSub).isNotBlank();
}
@Test
@Order(6)
void createUserPoolClientDoesNotReturnEmptyOptionalBlocks() {
CreateUserPoolClientResponse resp = cognito.createUserPoolClient(b -> b
.userPoolId(poolId)
.clientName("user-pool-client"));
UserPoolClientType client = resp.userPoolClient();
userPoolClientId = client.clientId();
assertThat(client.analyticsConfiguration())
.as("analyticsConfiguration must be null when not set")
.isNull();
assertThat(client.tokenValidityUnits())
.as("tokenValidityUnits must be null when not set")
.isNull();
assertThat(client.refreshTokenRotation())
.as("refreshTokenRotation must be null when not set")
.isNull();
assertThat(client.explicitAuthFlows())
.as("ExplicitAuthFlows must be empty when not set, matching AWS; the default"
+ " (refresh, SRP and custom auth) is enforced at auth time, not stored")
.isEmpty();
}
// ── Issue #229 — InitiateAuth rejects when no password hash is set ────────
@Test
@Order(10)
void initiateAuthRejectsAnyPasswordForUserWithNoHashSet() {
String noHashUser = "no-hash-" + UUID.randomUUID() + "@example.com";
cognito.adminCreateUser(b -> b
.userPoolId(poolId)
.username(noHashUser)
.userAttributes(AttributeType.builder().name("email").value(noHashUser).build())
.messageAction(MessageActionType.SUPPRESS));
assertThatThrownBy(() -> cognito.initiateAuth(b -> b
.clientId(clientId)
.authFlow(AuthFlowType.USER_PASSWORD_AUTH)
.authParameters(Map.of("USERNAME", noHashUser, "PASSWORD", "anything"))))
.isInstanceOf(CognitoIdentityProviderException.class)
.matches(e -> e.getClass().getSimpleName().equals("NotAuthorizedException")
|| e.getMessage().contains("NotAuthorizedException"));
}
@Test
@Order(11)
void initiateAuthRejectsWrongPassword() {
assertThatThrownBy(() -> cognito.initiateAuth(b -> b
.clientId(clientId)
.authFlow(AuthFlowType.USER_PASSWORD_AUTH)
.authParameters(Map.of("USERNAME", USERNAME, "PASSWORD", "WrongPass1!"))))
.isInstanceOf(CognitoIdentityProviderException.class)
.matches(e -> e.getClass().getSimpleName().equals("NotAuthorizedException")
|| e.getMessage().contains("NotAuthorizedException"));
}
// ── Issue #235 — AdminSetUserPassword(Permanent=false) changes the password ─
@Test
@Order(20)
void adminSetUserPasswordPermanentFalseChangesPassword() {
String tempPass = "TempPass1!";
cognito.adminSetUserPassword(b -> b
.userPoolId(poolId)
.username(USERNAME)
.password(tempPass)
.permanent(false));
// Old password now rejected
assertThatThrownBy(() -> cognito.initiateAuth(b -> b
.clientId(clientId)
.authFlow(AuthFlowType.USER_PASSWORD_AUTH)
.authParameters(Map.of("USERNAME", USERNAME, "PASSWORD", PASSWORD))))
.isInstanceOf(CognitoIdentityProviderException.class)
.matches(e -> e.getClass().getSimpleName().equals("NotAuthorizedException")
|| e.getMessage().contains("NotAuthorizedException"));
// New temp password triggers NEW_PASSWORD_REQUIRED challenge, not tokens
InitiateAuthResponse challengeResp = cognito.initiateAuth(b -> b
.clientId(clientId)
.authFlow(AuthFlowType.USER_PASSWORD_AUTH)
.authParameters(Map.of("USERNAME", USERNAME, "PASSWORD", tempPass)));
assertThat(challengeResp.challengeName()).isEqualTo(ChallengeNameType.NEW_PASSWORD_REQUIRED);
// Restore permanent password for subsequent tests
cognito.adminSetUserPassword(b -> b
.userPoolId(poolId)
.username(USERNAME)
.password(PASSWORD)
.permanent(true));
}
// ── Issue #228 — AccessToken contains client_id claim ─────────────────────
@Test
@Order(30)
void accessTokenContainsClientIdClaim() throws Exception {
InitiateAuthResponse resp = cognito.initiateAuth(b -> b
.clientId(clientId)
.authFlow(AuthFlowType.USER_PASSWORD_AUTH)
.authParameters(Map.of("USERNAME", USERNAME, "PASSWORD", PASSWORD)));
JsonNode payload = decodeJwtPayload(resp.authenticationResult().accessToken());
assertThat(payload.path("client_id").asText())
.as("AccessToken must contain client_id matching the requesting ClientId")
.isEqualTo(clientId);
}
@Test
@Order(31)
void idTokenDoesNotContainClientIdClaim() throws Exception {
InitiateAuthResponse resp = cognito.initiateAuth(b -> b
.clientId(clientId)
.authFlow(AuthFlowType.USER_PASSWORD_AUTH)
.authParameters(Map.of("USERNAME", USERNAME, "PASSWORD", PASSWORD)));
JsonNode payload = decodeJwtPayload(resp.authenticationResult().idToken());
assertThat(payload.has("client_id"))
.as("IdToken should not contain client_id claim")
.isFalse();
}
// ── Issue #218 — RS256 JWT signing and JWKS signature verification ─────────
@Test
@Order(32)
void accessTokenIsSignedWithRs256() throws Exception {
InitiateAuthResponse resp = cognito.initiateAuth(b -> b
.clientId(clientId)
.authFlow(AuthFlowType.USER_PASSWORD_AUTH)
.authParameters(Map.of("USERNAME", USERNAME, "PASSWORD", PASSWORD)));
JsonNode header = decodeJwtHeader(resp.authenticationResult().accessToken());
assertThat(header.path("alg").asText()).isEqualTo("RS256");
assertThat(header.path("kid").asText()).isNotBlank();
}
@Test
@Order(33)
void accessTokenSignatureVerifiesAgainstJwks() throws Exception {
InitiateAuthResponse resp = cognito.initiateAuth(b -> b
.clientId(clientId)
.authFlow(AuthFlowType.USER_PASSWORD_AUTH)
.authParameters(Map.of("USERNAME", USERNAME, "PASSWORD", PASSWORD)));
String accessToken = resp.authenticationResult().accessToken();
String kid = decodeJwtHeader(accessToken).path("kid").asText();
URI jwksUri = TestFixtures.endpoint().resolve("/" + poolId + "/.well-known/jwks.json");
HttpResponse<String> jwksResp = HTTP.send(
HttpRequest.newBuilder().uri(jwksUri).GET().timeout(Duration.ofSeconds(10)).build(),
HttpResponse.BodyHandlers.ofString());
assertThat(jwksResp.statusCode()).isEqualTo(200);
JsonNode jwk = null;
for (JsonNode key : JSON.readTree(jwksResp.body()).path("keys")) {
if (kid.equals(key.path("kid").asText())) {
jwk = key;
break;
}
}
assertThat(jwk).as("JWK with kid=%s must be present in JWKS", kid).isNotNull();
assertThat(verifyRs256Signature(accessToken, jwk))
.as("AccessToken RS256 signature must verify against published JWKS public key")
.isTrue();
}
// ── Issue #220 — AdminGetUser accepts sub UUID and email as Username ───────
@Test
@Order(40)
void adminGetUserBySubUuid() {
assertThat(userSub).isNotBlank();
AdminGetUserResponse resp = cognito.adminGetUser(b -> b
.userPoolId(poolId)
.username(userSub));
assertThat(resp.username())
.as("AdminGetUser with sub UUID should resolve to the correct user")
.isEqualTo(USERNAME);
}
@Test
@Order(41)
void adminGetUserByEmailAlias() {
AdminGetUserResponse resp = cognito.adminGetUser(b -> b
.userPoolId(poolId)
.username(USERNAME));
assertThat(resp.username()).isEqualTo(USERNAME);
}
@Test
@Order(42)
void adminSetUserPasswordBySubUuid() {
cognito.adminSetUserPassword(b -> b
.userPoolId(poolId)
.username(userSub)
.password(PASSWORD)
.permanent(true));
InitiateAuthResponse resp = cognito.initiateAuth(b -> b
.clientId(clientId)
.authFlow(AuthFlowType.USER_PASSWORD_AUTH)
.authParameters(Map.of("USERNAME", USERNAME, "PASSWORD", PASSWORD)));
assertThat(resp.authenticationResult().accessToken()).isNotBlank();
}
// ── Issue #233 — ListUsers respects Filter parameter ─────────────────────
@Test
@Order(50)
void listUsersNoFilterReturnsUser() {
ListUsersResponse resp = cognito.listUsers(b -> b.userPoolId(poolId));
assertThat(resp.users()).extracting(UserType::username).contains(USERNAME);
}
@Test
@Order(51)
void listUsersFilterByEmailExactMatch() {
ListUsersResponse resp = cognito.listUsers(b -> b
.userPoolId(poolId)
.filter("email = \"" + USERNAME + "\""));
System.out.println("Filter: email = \"" + USERNAME + "\"");
System.out.println("Users found: " + resp.users().size());
for (UserType u : resp.users()) {
String email = u.attributes().stream().filter(a -> "email".equals(a.name())).map(AttributeType::value).findFirst().orElse("null");
System.out.println(" - User: " + u.username() + ", email: " + email);
}
assertThat(resp.users()).hasSize(1);
assertThat(resp.users().get(0).username()).isEqualTo(USERNAME);
}
@Test
@Order(52)
void listUsersFilterByEmailPrefixStartsWith() {
ListUsersResponse resp = cognito.listUsers(b -> b
.userPoolId(poolId)
.filter("email ^= \"compat-user-\""));
assertThat(resp.users()).extracting(UserType::username).contains(USERNAME);
}
@Test
@Order(53)
void listUsersFilterBySubExactMatch() {
assertThat(userSub).isNotBlank();
ListUsersResponse resp = cognito.listUsers(b -> b
.userPoolId(poolId)
.filter("sub = \"" + userSub + "\""));
assertThat(resp.users()).hasSize(1);
assertThat(resp.users().get(0).username()).isEqualTo(USERNAME);
}
@Test
@Order(54)
void listUsersFilterNoMatchReturnsEmpty() {
ListUsersResponse resp = cognito.listUsers(b -> b
.userPoolId(poolId)
.filter("email = \"nobody@nowhere.invalid\""));
assertThat(resp.users()).isEmpty();
}
@Test
@Order(55)
void describeUserPoolReturnsAllTwentyStandardAttributes() {
DescribeUserPoolResponse resp = cognito.describeUserPool(b -> b.userPoolId(poolId));
List<SchemaAttributeType> schema = resp.userPool().schemaAttributes();
assertThat(schema).hasSize(20);
List<String> names = schema.stream().map(SchemaAttributeType::name).toList();
assertThat(names).contains(
"sub", "name", "given_name", "family_name", "middle_name", "nickname",
"preferred_username", "profile", "picture", "website", "email",
"email_verified", "gender", "birthdate", "zoneinfo", "locale",
"phone_number", "phone_number_verified", "address", "updated_at");
SchemaAttributeType sub = schema.stream().filter(a -> "sub".equals(a.name())).findFirst().orElseThrow();
assertThat(sub.required()).isTrue();
assertThat(sub.mutable()).isFalse();
}
// ── AdminRespondToAuthChallenge ─────────────────────────────────────────
@Test
@Order(60)
void adminRespondToAuthChallengeNewPasswordRequired() {
String tempUser = "admin-challenge-user-" + java.util.UUID.randomUUID();
String tempPassword = "TempPass1!";
String newPassword = "Permanent99!";
cognito.adminCreateUser(b -> b
.userPoolId(poolId)
.username(tempUser)
.temporaryPassword(tempPassword)
.userAttributes(AttributeType.builder().name("email").value(tempUser + "@example.com").build())
.messageAction(MessageActionType.SUPPRESS));
AdminInitiateAuthResponse initResp = cognito.adminInitiateAuth(b -> b
.userPoolId(poolId)
.clientId(clientId)
.authFlow(AuthFlowType.ADMIN_USER_PASSWORD_AUTH)
.authParameters(Map.of("USERNAME", tempUser, "PASSWORD", tempPassword)));
assertThat(initResp.challengeNameAsString()).isEqualTo("NEW_PASSWORD_REQUIRED");
assertThat(initResp.session()).isNotBlank();
AdminRespondToAuthChallengeResponse challengeResp = cognito.adminRespondToAuthChallenge(b -> b
.userPoolId(poolId)
.clientId(clientId)
.challengeName(ChallengeNameType.NEW_PASSWORD_REQUIRED)
.session(initResp.session())
.challengeResponses(Map.of("USERNAME", tempUser, "NEW_PASSWORD", newPassword)));
assertThat(challengeResp.authenticationResult()).isNotNull();
assertThat(challengeResp.authenticationResult().accessToken()).isNotBlank();
assertThat(challengeResp.authenticationResult().idToken()).isNotBlank();
assertThat(challengeResp.authenticationResult().refreshToken()).isNotBlank();
cognito.adminDeleteUser(b -> b.userPoolId(poolId).username(tempUser));
}
@Test
@Order(61)
void deletionProtectionRefusesDeleteUntilDeactivated() {
String protectedPoolId = cognito.createUserPool(b -> b
.poolName("compat-protected-pool")
.deletionProtection(DeletionProtectionType.ACTIVE))
.userPool().id();
try {
assertThatThrownBy(() -> cognito.deleteUserPool(b -> b.userPoolId(protectedPoolId)))
.isInstanceOf(InvalidParameterException.class)
.hasMessageContaining("deletion protection is activated");
assertThat(cognito.describeUserPool(b -> b.userPoolId(protectedPoolId)).userPool().deletionProtection())
.isEqualTo(DeletionProtectionType.ACTIVE);
} finally {
cognito.updateUserPool(b -> b.userPoolId(protectedPoolId).deletionProtection(DeletionProtectionType.INACTIVE));
cognito.deleteUserPool(b -> b.userPoolId(protectedPoolId));
}
assertThatThrownBy(() -> cognito.describeUserPool(b -> b.userPoolId(protectedPoolId)))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── Issue #234 note ───────────────────────────────────────────────────────
// GetTokensFromRefreshToken is tested in sdk-test-node/tests/cognito-features.test.ts
// because GetTokensFromRefreshTokenCommand is not available in Java SDK 2.31.8.
// ── JWT helpers ───────────────────────────────────────────────────────────
private static JsonNode decodeJwtPayload(String jwt) throws Exception {
return decodeJwtPart(jwt, 1);
}
private static JsonNode decodeJwtHeader(String jwt) throws Exception {
return decodeJwtPart(jwt, 0);
}
private static JsonNode decodeJwtPart(String jwt, int index) throws Exception {
String[] parts = jwt.split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("JWT must have 3 parts, got " + parts.length);
}
byte[] decoded = Base64.getUrlDecoder().decode(padBase64(parts[index]));
return JSON.readTree(new String(decoded, StandardCharsets.UTF_8));
}
private static boolean verifyRs256Signature(String jwt, JsonNode jwk) throws Exception {
String[] parts = jwt.split("\\.");
BigInteger modulus = new BigInteger(1, Base64.getUrlDecoder().decode(padBase64(jwk.path("n").asText())));
BigInteger exponent = new BigInteger(1, Base64.getUrlDecoder().decode(padBase64(jwk.path("e").asText())));
RSAPublicKey publicKey = (RSAPublicKey) KeyFactory.getInstance("RSA")
.generatePublic(new RSAPublicKeySpec(modulus, exponent));
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(publicKey);
verifier.update((parts[0] + "." + parts[1]).getBytes(StandardCharsets.UTF_8));
return verifier.verify(Base64.getUrlDecoder().decode(padBase64(parts[2])));
}
private static String padBase64(String value) {
int remainder = value.length() % 4;
return remainder == 0 ? value : value + "=".repeat(4 - remainder);
}
}
@@ -0,0 +1,258 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.cognitoidentityprovider.CognitoIdentityProviderClient;
import software.amazon.awssdk.services.cognitoidentityprovider.model.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Cognito SRP Authentication (Issue #310)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CognitoSrpTest {
private static final BigInteger G = BigInteger.valueOf(2);
private static final BigInteger N = new BigInteger(
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" +
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" +
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" +
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" +
"C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" +
"83655D23DCA3AD961C62F356208552BB9ED529077096966D" +
"670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" +
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" +
"DE2BCBF6955817183995497CEA956AE515D2261898FA0510" +
"15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" +
"ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" +
"ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" +
"F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" +
"BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" +
"43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF", 16);
private static CognitoIdentityProviderClient cognito;
private static String poolId;
private static String clientId;
private static final String USERNAME = "srp-user-" + UUID.randomUUID();
private static final String PASSWORD = "SrpPass1!";
@BeforeAll
static void setup() {
cognito = TestFixtures.cognitoClient();
}
@AfterAll
static void cleanup() {
if (cognito == null) return;
try {
if (poolId != null) {
cognito.deleteUserPool(b -> b.userPoolId(poolId));
}
} catch (Exception ignored) {}
cognito.close();
}
@Test
@Order(1)
void createPoolAndClient() {
CreateUserPoolResponse poolResp = cognito.createUserPool(b -> b.poolName("srp-test-pool"));
poolId = poolResp.userPool().id();
CreateUserPoolClientResponse clientResp = cognito.createUserPoolClient(b -> b
.userPoolId(poolId)
.clientName("srp-test-client")
.explicitAuthFlows(ExplicitAuthFlowsType.ALLOW_USER_SRP_AUTH));
clientId = clientResp.userPoolClient().clientId();
assertThat(poolId).isNotBlank();
assertThat(clientId).isNotBlank();
}
@Test
@Order(2)
void createUser() {
cognito.adminCreateUser(b -> b
.userPoolId(poolId)
.username(USERNAME)
.messageAction(MessageActionType.SUPPRESS));
cognito.adminSetUserPassword(b -> b
.userPoolId(poolId)
.username(USERNAME)
.password(PASSWORD)
.permanent(true));
}
@Test
@Order(3)
@DisplayName("Verify USER_SRP_AUTH returns PASSWORD_VERIFIER challenge")
void srpAuthReturnsChallenge() {
BigInteger a = new BigInteger(256, new SecureRandom());
BigInteger A = G.modPow(a, N);
InitiateAuthResponse authResp = cognito.initiateAuth(b -> b
.authFlow(AuthFlowType.USER_SRP_AUTH)
.clientId(clientId)
.authParameters(Map.of(
"USERNAME", USERNAME,
"SRP_A", A.toString(16)
)));
assertThat(authResp.challengeName()).isEqualTo(ChallengeNameType.PASSWORD_VERIFIER);
Map<String, String> params = authResp.challengeParameters();
assertThat(params).containsKey("SRP_B");
assertThat(params).containsKey("SALT");
assertThat(params).containsKey("SECRET_BLOCK");
}
@Test
@Order(4)
@DisplayName("Full USER_SRP_AUTH round-trip yields valid tokens")
void srpAuthCompletesAndReturnsTokens() throws Exception {
// 1. Generate client ephemeral a, public A = g^a mod N.
BigInteger a = new BigInteger(256, new SecureRandom());
BigInteger A = G.modPow(a, N);
// 2. Kick off SRP — server returns SRP_B, SALT, SECRET_BLOCK, USER_ID_FOR_SRP.
InitiateAuthResponse init = cognito.initiateAuth(b -> b
.authFlow(AuthFlowType.USER_SRP_AUTH)
.clientId(clientId)
.authParameters(Map.of(
"USERNAME", USERNAME,
"SRP_A", A.toString(16)
)));
assertThat(init.challengeName()).isEqualTo(ChallengeNameType.PASSWORD_VERIFIER);
Map<String, String> challenge = init.challengeParameters();
BigInteger B = new BigInteger(challenge.get("SRP_B"), 16);
BigInteger salt = new BigInteger(challenge.get("SALT"), 16);
String secretBlockB64 = challenge.get("SECRET_BLOCK");
String userIdForSrp = challenge.get("USER_ID_FOR_SRP");
String poolName = poolId.split("_", 2)[1];
// 3. Compute the PASSWORD_CLAIM_SIGNATURE per the AWS "Caldera" SRP variant.
BigInteger x = computeX(poolName, userIdForSrp, PASSWORD, salt);
BigInteger u = computeU(A, B);
BigInteger k = computeK();
// S = (B - k * g^x)^(a + u*x) mod N
BigInteger S = B.subtract(k.multiply(G.modPow(x, N))).mod(N)
.modPow(a.add(u.multiply(x)), N);
byte[] hkdfKey = calderaHkdf(S, u);
String timestamp = formatCognitoTimestamp(new Date());
byte[] secretBlock = Base64.getDecoder().decode(secretBlockB64);
byte[] signature = hmacSha256(hkdfKey,
concat(poolName.getBytes(StandardCharsets.UTF_8),
userIdForSrp.getBytes(StandardCharsets.UTF_8),
secretBlock,
timestamp.getBytes(StandardCharsets.UTF_8)));
// 4. Respond to the challenge — Floci must return real tokens if its
// server-side k, U, S, HKDF and HMAC layouts all match the spec.
Map<String, String> responses = new HashMap<>();
responses.put("USERNAME", userIdForSrp);
responses.put("PASSWORD_CLAIM_SECRET_BLOCK", secretBlockB64);
responses.put("TIMESTAMP", timestamp);
responses.put("PASSWORD_CLAIM_SIGNATURE", Base64.getEncoder().encodeToString(signature));
RespondToAuthChallengeResponse resp = cognito.respondToAuthChallenge(b -> b
.clientId(clientId)
.challengeName(ChallengeNameType.PASSWORD_VERIFIER)
.session(init.session())
.challengeResponses(responses));
AuthenticationResultType auth = resp.authenticationResult();
assertThat(auth).as("AuthenticationResult populated").isNotNull();
// JWTs have three base64 segments separated by dots.
assertThat(auth.idToken()).matches("[^.]+\\.[^.]+\\.[^.]+");
assertThat(auth.accessToken()).matches("[^.]+\\.[^.]+\\.[^.]+");
assertThat(auth.refreshToken()).isNotBlank();
}
// ────────────────────────── Caldera SRP helpers ──────────────────────────
// The AWS Java SDK v2 doesn't expose a public SRP helper — clients writing
// server-side SRP normally pull amazon-cognito-identity-provider's mobile
// SDK or AWSCognitoAuthHelper from aws-android-sdk-cognitoidentityprovider.
// For test isolation we inline the math here, matching the spec floci's
// CognitoSrpHelper implements (the canonical AWS "Caldera" variant).
private static BigInteger computeK() throws Exception {
// k = SHA-256(N || g) over 2's-complement byte representations
// (minimal length with sign byte where needed).
MessageDigest sha = MessageDigest.getInstance("SHA-256");
sha.update(N.toByteArray());
sha.update(G.toByteArray());
return new BigInteger(1, sha.digest());
}
private static BigInteger computeU(BigInteger A, BigInteger B) throws Exception {
MessageDigest sha = MessageDigest.getInstance("SHA-256");
sha.update(A.toByteArray());
sha.update(B.toByteArray());
return new BigInteger(1, sha.digest());
}
private static BigInteger computeX(String poolName, String username, String password, BigInteger salt) throws Exception {
// inner = SHA-256(poolName + username + ":" + password)
MessageDigest sha = MessageDigest.getInstance("SHA-256");
sha.update(poolName.getBytes(StandardCharsets.UTF_8));
sha.update(username.getBytes(StandardCharsets.UTF_8));
sha.update(":".getBytes(StandardCharsets.UTF_8));
sha.update(password.getBytes(StandardCharsets.UTF_8));
byte[] inner = sha.digest();
// x = SHA-256(saltBytes || inner) — salt as 2's-complement bytes.
sha.reset();
sha.update(salt.toByteArray());
sha.update(inner);
return new BigInteger(1, sha.digest());
}
private static byte[] calderaHkdf(BigInteger S, BigInteger U) throws Exception {
// HKDF-Expand over (S, U as salt, "Caldera Derived Key" || 0x01) → 16 bytes
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(U.toByteArray(), "HmacSHA256"));
byte[] prk = mac.doFinal(S.toByteArray());
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
mac.update("Caldera Derived Key".getBytes(StandardCharsets.UTF_8));
mac.update((byte) 1);
return Arrays.copyOf(mac.doFinal(), 16);
}
private static byte[] hmacSha256(byte[] key, byte[] data) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data);
}
private static byte[] concat(byte[]... parts) {
int total = 0;
for (byte[] p : parts) total += p.length;
byte[] out = new byte[total];
int off = 0;
for (byte[] p : parts) { System.arraycopy(p, 0, out, off, p.length); off += p.length; }
return out;
}
private static String formatCognitoTimestamp(Date date) {
// Cognito expects "EEE MMM d HH:mm:ss z yyyy" in US locale, UTC, day NOT
// zero-padded — see amazon-cognito-identity-js/getNowString.ts.
SimpleDateFormat fmt = new SimpleDateFormat("EEE MMM d HH:mm:ss z yyyy", Locale.US);
fmt.setTimeZone(TimeZone.getTimeZone("UTC"));
return fmt.format(date);
}
}
@@ -0,0 +1,365 @@
package com.floci.test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.cognitoidentityprovider.CognitoIdentityProviderClient;
import software.amazon.awssdk.services.cognitoidentityprovider.model.AliasAttributeType;
import software.amazon.awssdk.services.cognitoidentityprovider.model.AttributeType;
import software.amazon.awssdk.services.cognitoidentityprovider.model.AuthFlowType;
import software.amazon.awssdk.services.cognitoidentityprovider.model.CodeDeliveryDetailsType;
import software.amazon.awssdk.services.cognitoidentityprovider.model.CodeMismatchException;
import software.amazon.awssdk.services.cognitoidentityprovider.model.ExpiredCodeException;
import software.amazon.awssdk.services.cognitoidentityprovider.model.ExplicitAuthFlowsType;
import software.amazon.awssdk.services.cognitoidentityprovider.model.MessageActionType;
import software.amazon.awssdk.services.cognitoidentityprovider.model.UpdateUserAttributesResponse;
import software.amazon.awssdk.services.cognitoidentityprovider.model.UserNotFoundException;
import software.amazon.awssdk.services.cognitoidentityprovider.model.VerifiedAttributeType;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CognitoVerifyUserAttributeTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private static final Pattern SIX_DIGIT_CODE = Pattern.compile("\\b(\\d{6})\\b");
private static final String PASSWORD = "Verify1234!";
private static CognitoIdentityProviderClient cognito;
private String poolId;
@BeforeAll
static void setUpClient() {
cognito = TestFixtures.cognitoClient();
}
@AfterAll
static void closeClient() {
if (cognito != null) {
cognito.close();
}
}
@AfterEach
void deletePool() {
if (poolId != null) {
cognito.deleteUserPool(b -> b.userPoolId(poolId));
poolId = null;
}
}
@Test
void verifiesEmailAndPhoneNumberWithAwsSdk() throws Exception {
poolId = cognito.createUserPool(b -> b.poolName("verify-attribute-" + UUID.randomUUID()))
.userPool()
.id();
String clientId = createClient();
String username = "verify-" + UUID.randomUUID();
String email = username + "@example.com";
String phoneNumber = "+1555" + ThreadLocalRandom.current().nextLong(1_000_000, 10_000_000);
createConfirmedUser(username,
AttributeType.builder().name("email").value(email).build(),
AttributeType.builder().name("phone_number").value(phoneNumber).build());
String accessToken = authenticate(clientId, username);
clearInspectionEndpoint("/_aws/ses");
clearInspectionEndpoint("/_aws/sns");
CodeDeliveryDetailsType emailDelivery = cognito.getUserAttributeVerificationCode(b -> b
.accessToken(accessToken)
.attributeName("email"))
.codeDeliveryDetails();
assertThat(emailDelivery.attributeName()).isEqualTo("email");
assertThat(emailDelivery.deliveryMediumAsString()).isEqualTo("EMAIL");
assertThat(emailDelivery.destination()).isEqualTo("v***@e***");
String emailCode = fetchLatestSesVerificationCode(email);
CodeDeliveryDetailsType phoneDelivery = cognito.getUserAttributeVerificationCode(b -> b
.accessToken(accessToken)
.attributeName("phone_number"))
.codeDeliveryDetails();
assertThat(phoneDelivery.attributeName()).isEqualTo("phone_number");
assertThat(phoneDelivery.deliveryMediumAsString()).isEqualTo("SMS");
assertThat(phoneDelivery.destination()).isEqualTo(maskedPhoneNumber(phoneNumber));
String phoneCode = fetchLatestSnsVerificationCode(phoneNumber);
assertThatThrownBy(() -> cognito.verifyUserAttribute(b -> b
.accessToken(accessToken)
.attributeName("email")
.code(differentCode(emailCode))))
.isInstanceOf(CodeMismatchException.class);
assertThat(userAttribute(username, "email_verified")).isNull();
cognito.verifyUserAttribute(b -> b
.accessToken(accessToken)
.attributeName("email")
.code(emailCode));
cognito.verifyUserAttribute(b -> b
.accessToken(accessToken)
.attributeName("phone_number")
.code(phoneCode));
assertThat(userAttribute(username, "email_verified")).isEqualTo("true");
assertThat(userAttribute(username, "phone_number_verified")).isEqualTo("true");
assertThatThrownBy(() -> cognito.verifyUserAttribute(b -> b
.accessToken(accessToken)
.attributeName("email")
.code(emailCode)))
.isInstanceOfSatisfying(ExpiredCodeException.class,
exception -> assertThat(exception.awsErrorDetails().errorMessage())
.isEqualTo("Invalid code provided, please request a code again."));
clearInspectionEndpoint("/_aws/ses");
clearInspectionEndpoint("/_aws/sns");
cognito.getUserAttributeVerificationCode(b -> b
.accessToken(accessToken)
.attributeName("email"));
String repeatedEmailCode = fetchLatestSesVerificationCode(email);
cognito.getUserAttributeVerificationCode(b -> b
.accessToken(accessToken)
.attributeName("phone_number"));
String repeatedPhoneCode = fetchLatestSnsVerificationCode(phoneNumber);
cognito.verifyUserAttribute(b -> b
.accessToken(accessToken)
.attributeName("email")
.code(repeatedEmailCode));
cognito.verifyUserAttribute(b -> b
.accessToken(accessToken)
.attributeName("phone_number")
.code(repeatedPhoneCode));
assertThat(userAttribute(username, "email_verified")).isEqualTo("true");
assertThat(userAttribute(username, "phone_number_verified")).isEqualTo("true");
}
@Test
void requiredEmailUpdateStaysPendingUntilVerification() throws Exception {
poolId = cognito.createUserPool(b -> b
.poolName("pending-email-update-" + UUID.randomUUID())
.aliasAttributes(AliasAttributeType.EMAIL)
.autoVerifiedAttributes(VerifiedAttributeType.EMAIL)
.userAttributeUpdateSettings(s -> s
.attributesRequireVerificationBeforeUpdate(VerifiedAttributeType.EMAIL)))
.userPool()
.id();
String clientId = createClient();
String username = "pending-email-" + UUID.randomUUID();
String oldEmail = "old+" + UUID.randomUUID() + "@example.com";
String newEmail = "new+" + UUID.randomUUID() + "@example.com";
createConfirmedUser(username,
AttributeType.builder().name("email").value(oldEmail).build(),
AttributeType.builder().name("email_verified").value("true").build());
String accessToken = authenticate(clientId, oldEmail);
clearInspectionEndpoint("/_aws/ses");
UpdateUserAttributesResponse update = cognito.updateUserAttributes(b -> b
.accessToken(accessToken)
.userAttributes(AttributeType.builder().name("email").value(newEmail).build()));
assertThat(update.codeDeliveryDetailsList()).singleElement().satisfies(delivery -> {
assertThat(delivery.attributeName()).isEqualTo("email");
assertThat(delivery.deliveryMediumAsString()).isEqualTo("EMAIL");
assertThat(delivery.destination()).isEqualTo(maskedEmail(newEmail));
});
String code = fetchLatestSesVerificationCode(newEmail);
assertThat(userAttribute(username, "email")).isEqualTo(oldEmail);
assertThat(userAttribute(username, "email_verified")).isEqualTo("true");
assertThat(authenticate(clientId, oldEmail)).isNotBlank();
assertAliasRejected(clientId, newEmail);
assertThatThrownBy(() -> cognito.verifyUserAttribute(b -> b
.accessToken(accessToken)
.attributeName("email")
.code(differentCode(code))))
.isInstanceOf(CodeMismatchException.class);
assertThat(userAttribute(username, "email")).isEqualTo(oldEmail);
assertThat(userAttribute(username, "email_verified")).isEqualTo("true");
assertThat(authenticate(clientId, oldEmail)).isNotBlank();
assertAliasRejected(clientId, newEmail);
cognito.verifyUserAttribute(b -> b
.accessToken(accessToken)
.attributeName("email")
.code(code));
assertThat(userAttribute(username, "email")).isEqualTo(newEmail);
assertThat(userAttribute(username, "email_verified")).isEqualTo("true");
assertAliasRejected(clientId, oldEmail);
assertThat(authenticate(clientId, newEmail)).isNotBlank();
}
@Test
void nonRequiredPhoneUpdateIsImmediateAndUnverifiedUntilVerification() throws Exception {
poolId = cognito.createUserPool(b -> b
.poolName("immediate-phone-update-" + UUID.randomUUID())
.aliasAttributes(AliasAttributeType.PHONE_NUMBER)
.autoVerifiedAttributes(VerifiedAttributeType.PHONE_NUMBER))
.userPool()
.id();
String clientId = createClient();
String username = "immediate-phone-" + UUID.randomUUID();
String oldPhone = "+15550001111";
String newPhone = "+15550002222";
createConfirmedUser(username,
AttributeType.builder().name("phone_number").value(oldPhone).build(),
AttributeType.builder().name("phone_number_verified").value("true").build());
String accessToken = authenticate(clientId, oldPhone);
clearInspectionEndpoint("/_aws/sns");
UpdateUserAttributesResponse update = cognito.updateUserAttributes(b -> b
.accessToken(accessToken)
.userAttributes(AttributeType.builder().name("phone_number").value(newPhone).build()));
assertThat(update.codeDeliveryDetailsList()).singleElement().satisfies(delivery -> {
assertThat(delivery.attributeName()).isEqualTo("phone_number");
assertThat(delivery.deliveryMediumAsString()).isEqualTo("SMS");
assertThat(delivery.destination()).isEqualTo(maskedPhoneNumber(newPhone));
});
String code = fetchLatestSnsVerificationCode(newPhone);
assertThat(userAttribute(username, "phone_number")).isEqualTo(newPhone);
assertThat(userAttribute(username, "phone_number_verified")).isEqualTo("false");
assertAliasRejected(clientId, oldPhone);
assertAliasRejected(clientId, newPhone);
cognito.verifyUserAttribute(b -> b
.accessToken(accessToken)
.attributeName("phone_number")
.code(code));
assertThat(userAttribute(username, "phone_number_verified")).isEqualTo("true");
assertAliasRejected(clientId, oldPhone);
assertThat(authenticate(clientId, newPhone)).isNotBlank();
}
private String createClient() {
return cognito.createUserPoolClient(b -> b
.userPoolId(poolId)
.clientName("verify-attribute-client")
.explicitAuthFlows(ExplicitAuthFlowsType.ALLOW_USER_PASSWORD_AUTH))
.userPoolClient()
.clientId();
}
private void createConfirmedUser(String username, AttributeType... attributes) {
cognito.adminCreateUser(b -> b
.userPoolId(poolId)
.username(username)
.messageAction(MessageActionType.SUPPRESS)
.userAttributes(attributes));
cognito.adminSetUserPassword(b -> b
.userPoolId(poolId)
.username(username)
.password(PASSWORD)
.permanent(true));
}
private String authenticate(String clientId, String username) {
return cognito.initiateAuth(b -> b
.clientId(clientId)
.authFlow(AuthFlowType.USER_PASSWORD_AUTH)
.authParameters(Map.of("USERNAME", username, "PASSWORD", PASSWORD)))
.authenticationResult()
.accessToken();
}
private void assertAliasRejected(String clientId, String alias) {
assertThatThrownBy(() -> authenticate(clientId, alias))
.isInstanceOf(UserNotFoundException.class);
}
private String userAttribute(String username, String attributeName) {
return cognito.adminGetUser(b -> b.userPoolId(poolId).username(username))
.userAttributes()
.stream()
.filter(attribute -> attributeName.equals(attribute.name()))
.map(AttributeType::value)
.findFirst()
.orElse(null);
}
private static void clearInspectionEndpoint(String path) throws Exception {
HttpResponse<String> response = HTTP.send(
HttpRequest.newBuilder()
.uri(TestFixtures.endpoint().resolve(path))
.DELETE()
.timeout(Duration.ofSeconds(10))
.build(),
HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
}
private static String fetchLatestSesVerificationCode(String recipient) throws Exception {
URI uri = TestFixtures.endpoint()
.resolve("/_aws/ses?email=" + URLEncoder.encode(recipient, StandardCharsets.UTF_8));
JsonNode messages = getInspectionMessages(uri);
return extractVerificationCode(messages.get(0).path("Body").path("text_part").asText());
}
private static String fetchLatestSnsVerificationCode(String phoneNumber) throws Exception {
URI uri = TestFixtures.endpoint()
.resolve("/_aws/sns?phone=" + URLEncoder.encode(phoneNumber, StandardCharsets.UTF_8));
JsonNode messages = getInspectionMessages(uri);
return extractVerificationCode(messages.get(0).path("Message").asText());
}
private static JsonNode getInspectionMessages(URI uri) throws Exception {
HttpResponse<String> response = HTTP.send(
HttpRequest.newBuilder()
.uri(uri)
.GET()
.timeout(Duration.ofSeconds(10))
.build(),
HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
JsonNode messages = JSON.readTree(response.body()).path("messages");
assertThat(messages.isArray() && !messages.isEmpty()).isTrue();
return messages;
}
private static String extractVerificationCode(String message) {
Matcher matcher = SIX_DIGIT_CODE.matcher(message);
assertThat(matcher.find()).isTrue();
return matcher.group(1);
}
private static String differentCode(String code) {
return "000000".equals(code) ? "000001" : "000000";
}
private static String maskedEmail(String email) {
int at = email.indexOf('@');
return email.charAt(0) + "***@" + email.charAt(at + 1) + "***";
}
private static String maskedPhoneNumber(String phoneNumber) {
return "+" + "*".repeat(phoneNumber.length() - 5)
+ phoneNumber.substring(phoneNumber.length() - 4);
}
}
@@ -0,0 +1,69 @@
package com.floci.test;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.controlcatalog.ControlCatalogClient;
import software.amazon.awssdk.services.controlcatalog.model.ControlFilter;
import software.amazon.awssdk.services.controlcatalog.model.ImplementationFilter;
import software.amazon.awssdk.services.controlcatalog.model.ResourceNotFoundException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.HashSet;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ControlCatalogTest {
private static final String RCP = "AWS::Organizations::Policy::RESOURCE_CONTROL_POLICY";
private static final String CONTROL_ARN = "arn:aws:controlcatalog:::control/7mo7a2h2ebsq71l8k6uzr96ou";
@Test
void controlLookupAndFilteringUseAwsSdkContracts() {
try (ControlCatalogClient client = TestFixtures.controlCatalogClient()) {
var control = client.getControl(request -> request.controlArn(CONTROL_ARN));
assertEquals(CONTROL_ARN, control.arn());
assertEquals(RCP, control.implementation().type());
assertEquals("CT.S3.PV.5", control.implementation().identifier());
var page = client.listControls(request -> request
.maxResults(2)
.filter(ControlFilter.builder()
.implementations(ImplementationFilter.builder().types(RCP).build())
.governedProviders("AWS")
.build()));
assertEquals(2, page.controls().size());
assertTrue(page.controls().stream().allMatch(item -> RCP.equals(item.implementation().type())));
assertTrue(page.controls().stream().allMatch(item -> item.governedProviders().contains("AWS")));
assertFalse(page.nextToken().isBlank());
var secondPage = client.listControls(request -> request
.maxResults(2)
.nextToken(page.nextToken())
.filter(ControlFilter.builder()
.implementations(ImplementationFilter.builder().types(RCP).build())
.governedProviders("AWS")
.build()));
assertEquals(2, secondPage.controls().size());
assertFalse(secondPage.nextToken().isBlank());
var finalPage = client.listControls(request -> request
.maxResults(2)
.nextToken(secondPage.nextToken())
.filter(ControlFilter.builder()
.implementations(ImplementationFilter.builder().types(RCP).build())
.governedProviders("AWS")
.build()));
assertEquals(1, finalPage.controls().size());
assertNull(finalPage.nextToken());
var arns = new HashSet<String>();
page.controls().forEach(item -> arns.add(item.arn()));
secondPage.controls().forEach(item -> arns.add(item.arn()));
finalPage.controls().forEach(item -> arns.add(item.arn()));
assertEquals(5, arns.size());
assertThrows(ResourceNotFoundException.class, () -> client.getControl(request -> request
.controlArn("arn:aws:controlcatalog:::control/aaaaaaaaaaaaaaaaaaaaaaaaa")));
}
}
}
@@ -0,0 +1,184 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.acm.AcmClient;
import software.amazon.awssdk.services.acm.model.GetCertificateResponse;
import software.amazon.awssdk.services.apigateway.ApiGatewayClient;
import software.amazon.awssdk.services.apigateway.model.EndpointConfiguration;
import software.amazon.awssdk.services.apigateway.model.EndpointType;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManagerFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import static org.assertj.core.api.Assertions.assertThat;
/**
* What a deployment tool does with a custom domain: request an ACM certificate for it, create
* the API Gateway domain name with that certificate, then reach https://&lt;domain&gt; with a
* client that trusts only the chain ACM returned. DNS is not involved: the socket goes to the
* Floci host and carries the domain as SNI, which is what a hosts-file or local DNS setup does.
*
* <p>Needs Floci with FLOCI_TLS_ENABLED=true; skipped otherwise. Floci-only: on AWS the
* certificate would wait for DNS validation.
*/
class CustomDomainTlsTest {
private static AcmClient acm;
private static ApiGatewayClient apigw;
private static String certificateArn;
private static String domainName;
@BeforeAll
static void setup() throws Exception {
Assumptions.assumeFalse(TestFixtures.isRealAws(), "Floci-only: real AWS needs DNS validation");
Assumptions.assumeTrue(tlsIsServed(), "needs Floci with FLOCI_TLS_ENABLED=true");
acm = TestFixtures.acmClient();
apigw = TestFixtures.apiGatewayClient();
domainName = TestFixtures.uniqueName("api") + ".dev.localhost.floci.io";
}
@AfterAll
static void cleanup() {
if (apigw != null) {
try {
apigw.deleteDomainName(b -> b.domainName(domainName));
} catch (Exception e) {
System.err.println("CustomDomainTlsTest cleanup: deleteDomainName failed: " + e.getMessage());
}
apigw.close();
}
if (acm != null) {
if (certificateArn != null) {
try {
acm.deleteCertificate(b -> b.certificateArn(certificateArn));
} catch (Exception e) {
System.err.println("CustomDomainTlsTest cleanup: deleteCertificate failed: " + e.getMessage());
}
}
acm.close();
}
}
@Test
void clientTrustingOnlyTheAcmChainReachesTheCustomDomain() throws Exception {
certificateArn = acm.requestCertificate(b -> b.domainName(domainName).validationMethod("DNS")).certificateArn();
GetCertificateResponse issued = acm.getCertificate(b -> b.certificateArn(certificateArn));
assertThat(issued.certificate()).contains("BEGIN CERTIFICATE");
assertThat(issued.certificateChain()).contains("BEGIN CERTIFICATE");
apigw.createDomainName(b -> b
.domainName(domainName)
.regionalCertificateArn(certificateArn)
.endpointConfiguration(EndpointConfiguration.builder().types(EndpointType.REGIONAL).build()));
X509Certificate served = handshake(issued.certificateChain(), domainName);
assertThat(sans(served)).contains(domainName);
X509Certificate acmLeaf = parse(issued.certificate()).iterator().next();
assertThat(served.getIssuerX500Principal()).isEqualTo(acmLeaf.getIssuerX500Principal());
}
/**
* The compatibility workflow runs Floci with FLOCI_TLS_ENABLED=true but hands the tests
* FLOCI_ENDPOINT=http://floci:4566 (one port serves both), so the URL scheme says nothing
* about TLS. Probe instead: fetch the CA over plain HTTP and try one handshake against the
* same host and port.
*/
private static boolean tlsIsServed() throws Exception {
String caPem = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create("http://" + host() + ":" + port() + "/_floci/ca.pem")).build(),
HttpResponse.BodyHandlers.ofString()).body();
if (!caPem.contains("BEGIN CERTIFICATE")) {
System.err.println("CustomDomainTlsTest: no CA served on " + host() + ":" + port() + ", skipping");
return false;
}
try {
handshake(caPem, null);
return true;
} catch (IOException e) {
System.err.println("CustomDomainTlsTest: no TLS on " + host() + ":" + port() + " (" + e.getMessage() + "), skipping");
return false;
}
}
private static String host() {
return TestFixtures.endpoint().getHost();
}
private static int port() {
URI endpoint = TestFixtures.endpoint();
if (endpoint.getPort() != -1) {
return endpoint.getPort();
}
return "https".equalsIgnoreCase(endpoint.getScheme()) ? 443 : 80;
}
/**
* TLS to the Floci host trusting only {@code chainPem}. With {@code sniHost} set, sends it as
* SNI and asks for hostname verification, the way an SDK would; {@code null} is a bare probe.
* The JDK falls back to the peer host when the SNI name does not match, and the Floci host
* is always in the certificate, so the SAN assertion in the test is the check that the
* domain is served, not the handshake alone.
*/
private static X509Certificate handshake(String chainPem, String sniHost) throws Exception {
KeyStore trust = KeyStore.getInstance(KeyStore.getDefaultType());
trust.load(null, null);
int i = 0;
for (X509Certificate ca : parse(chainPem)) {
trust.setCertificateEntry("chain-" + i++, ca);
}
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(trust);
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, tmf.getTrustManagers(), null);
try (SSLSocket socket = (SSLSocket) ctx.getSocketFactory().createSocket()) {
socket.connect(new InetSocketAddress(host(), port()), 5000);
socket.setSoTimeout(10000);
if (sniHost != null) {
SSLParameters params = socket.getSSLParameters();
params.setServerNames(List.of(new SNIHostName(sniHost)));
params.setEndpointIdentificationAlgorithm("HTTPS");
socket.setSSLParameters(params);
}
socket.startHandshake();
return (X509Certificate) socket.getSession().getPeerCertificates()[0];
}
}
private static Collection<X509Certificate> parse(String pem) throws Exception {
Collection<? extends Certificate> certs = CertificateFactory.getInstance("X.509")
.generateCertificates(new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8)));
return certs.stream().map(X509Certificate.class::cast).toList();
}
private static Set<String> sans(X509Certificate cert) throws Exception {
Set<String> out = new TreeSet<>();
for (List<?> entry : cert.getSubjectAlternativeNames()) {
out.add(String.valueOf(entry.get(1)));
}
return out;
}
}
@@ -0,0 +1,222 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.athena.AthenaClient;
import software.amazon.awssdk.services.athena.model.*;
import software.amazon.awssdk.services.firehose.FirehoseClient;
import software.amazon.awssdk.services.firehose.model.PutRecordRequest;
import software.amazon.awssdk.services.firehose.model.Record;
import software.amazon.awssdk.services.glue.GlueClient;
import software.amazon.awssdk.services.glue.model.*;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
@DisplayName("Data Lake (Athena + Glue + Firehose)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DataLakeTest {
private static AthenaClient athena;
private static GlueClient glue;
private static FirehoseClient firehose;
private static S3Client s3;
private static final String DB_NAME = TestFixtures.uniqueName("test_db");
private static final String TABLE_NAME = "orders";
private static final String STREAM_NAME = TestFixtures.uniqueName("orders_stream");
@BeforeAll
static void setup() {
athena = TestFixtures.athenaClient();
glue = TestFixtures.glueClient();
firehose = TestFixtures.firehoseClient();
s3 = TestFixtures.s3Client();
}
@Test
@org.junit.jupiter.api.Order(1)
void setupInfrastructure() {
// 1. Glue Database
glue.createDatabase(CreateDatabaseRequest.builder()
.databaseInput(DatabaseInput.builder().name(DB_NAME).build())
.build());
// 2. Glue Table — standard AWS JSON table config: TextInputFormat + JsonSerDe
glue.createTable(CreateTableRequest.builder()
.databaseName(DB_NAME)
.tableInput(TableInput.builder()
.name(TABLE_NAME)
.storageDescriptor(StorageDescriptor.builder()
.location("s3://floci-firehose-results/" + STREAM_NAME + "/")
.inputFormat("org.apache.hadoop.mapred.TextInputFormat")
.outputFormat("org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat")
.serdeInfo(SerDeInfo.builder()
.serializationLibrary("org.openx.data.jsonserde.JsonSerDe")
.parameters(Map.of("serialization.format", "1"))
.build())
.columns(
software.amazon.awssdk.services.glue.model.Column.builder().name("id").type("int").build(),
software.amazon.awssdk.services.glue.model.Column.builder().name("amount").type("double").build()
)
.build())
.build())
.build());
// 3. Firehose Stream delivering under the Glue table location. The static
// prefix gets yyyy/MM/dd/HH/ appended like real Firehose, which stays
// inside the table location since Athena reads it recursively.
firehose.createDeliveryStream(software.amazon.awssdk.services.firehose.model.CreateDeliveryStreamRequest.builder()
.deliveryStreamName(STREAM_NAME)
.extendedS3DestinationConfiguration(software.amazon.awssdk.services.firehose.model.ExtendedS3DestinationConfiguration.builder()
.bucketARN("arn:aws:s3:::floci-firehose-results")
.roleARN("arn:aws:iam::000000000000:role/datalake-firehose-role")
.prefix(STREAM_NAME + "/")
// Floci does not enforce AWS's 60s minimum interval, which keeps
// the delivery wait short (would need 60+ against real AWS).
.bufferingHints(software.amazon.awssdk.services.firehose.model.BufferingHints.builder()
.sizeInMBs(1)
.intervalInSeconds(5)
.build())
.build())
.build());
}
@Test
@org.junit.jupiter.api.Order(2)
void ingestAndQuery() throws Exception {
// Ingest data
for (int i = 1; i <= 5; i++) {
String json = String.format("{\"id\": %d, \"amount\": %.2f}", i, i * 10.0);
firehose.putRecord(PutRecordRequest.builder()
.deliveryStreamName(STREAM_NAME)
.record(Record.builder().data(SdkBytes.fromString(json, StandardCharsets.UTF_8)).build())
.build());
}
// Small records stay buffered until the stream's IntervalInSeconds
// elapses; wait for Firehose to deliver before querying, the same way a
// real AWS client would.
long deadline = System.currentTimeMillis() + 30_000;
boolean delivered = false;
while (!delivered && System.currentTimeMillis() < deadline) {
try {
delivered = !s3.listObjectsV2(ListObjectsV2Request.builder()
.bucket("floci-firehose-results")
.prefix(STREAM_NAME + "/")
.build()).contents().isEmpty();
} catch (NoSuchBucketException e) {
// The bucket itself is only created on the first delivery.
}
if (!delivered) {
Thread.sleep(2_000);
}
}
assertThat(delivered)
.as("Firehose should deliver the buffered records within IntervalInSeconds")
.isTrue();
// Athena Query
StartQueryExecutionResponse startResp = athena.startQueryExecution(StartQueryExecutionRequest.builder()
.queryString("SELECT sum(amount) as total FROM " + TABLE_NAME)
.queryExecutionContext(QueryExecutionContext.builder().database(DB_NAME).build())
.build());
String queryId = startResp.queryExecutionId();
QueryExecutionStatus status = TestFixtures.awaitAthenaQueryTerminal(
athena, queryId, Duration.ofSeconds(60));
assertThat(status.state())
.as("Athena query did not succeed: %s", status.stateChangeReason())
.isEqualTo(QueryExecutionState.SUCCEEDED);
GetQueryResultsResponse results = athena.getQueryResults(GetQueryResultsRequest.builder()
.queryExecutionId(queryId)
.build());
assertThat(results.resultSet()).isNotNull();
// Athena GetQueryResults includes a header row + data rows
assertThat(results.resultSet().rows()).hasSizeGreaterThanOrEqualTo(2);
// Header row must contain the column name
List<String> header = results.resultSet().rows().get(0).data().stream()
.map(d -> d.varCharValue())
.collect(Collectors.toList());
assertThat(header).containsExactly("total");
// Data row: sum(amount) = 10+20+30+40+50 = 150
String total = results.resultSet().rows().get(1).data().get(0).varCharValue();
assertThat(Double.parseDouble(total)).isEqualTo(150.0);
}
@Test
@org.junit.jupiter.api.Order(3)
void glueUserDefinedFunctionsLifecycle() {
String functionName = "sdk_udf__integer";
glue.createUserDefinedFunction(CreateUserDefinedFunctionRequest.builder()
.databaseName(DB_NAME)
.functionInput(UserDefinedFunctionInput.builder()
.functionName(functionName)
.className("ExampleFunction")
.ownerName("owner")
.ownerType(PrincipalType.USER)
.resourceUris(ResourceUri.builder()
.resourceType(ResourceType.FILE)
.uri("s3://floci-firehose-results/function.json")
.build())
.build())
.build());
UserDefinedFunction created = glue.getUserDefinedFunction(GetUserDefinedFunctionRequest.builder()
.databaseName(DB_NAME)
.functionName(functionName)
.build()).userDefinedFunction();
assertThat(created.functionName()).isEqualTo(functionName);
assertThat(created.databaseName()).isEqualTo(DB_NAME);
assertThat(created.ownerName()).isEqualTo("owner");
assertThat(created.createTime()).isNotNull();
assertThat(created.resourceUris()).hasSize(1);
assertThat(glue.getUserDefinedFunctions(GetUserDefinedFunctionsRequest.builder()
.databaseName(DB_NAME)
.pattern("sdk_udf__.*")
.build()).userDefinedFunctions())
.extracting(UserDefinedFunction::functionName)
.containsExactly(functionName);
glue.updateUserDefinedFunction(UpdateUserDefinedFunctionRequest.builder()
.databaseName(DB_NAME)
.functionName(functionName)
.functionInput(UserDefinedFunctionInput.builder()
.functionName(functionName)
.className("ExampleFunction")
.ownerName("new-owner")
.ownerType(PrincipalType.USER)
.build())
.build());
UserDefinedFunction updated = glue.getUserDefinedFunction(GetUserDefinedFunctionRequest.builder()
.databaseName(DB_NAME)
.functionName(functionName)
.build()).userDefinedFunction();
assertThat(updated.ownerName()).isEqualTo("new-owner");
glue.deleteUserDefinedFunction(DeleteUserDefinedFunctionRequest.builder()
.databaseName(DB_NAME)
.functionName(functionName)
.build());
assertThrows(EntityNotFoundException.class, () -> glue.getUserDefinedFunction(
GetUserDefinedFunctionRequest.builder()
.databaseName(DB_NAME)
.functionName(functionName)
.build()));
}
}
@@ -0,0 +1,232 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import software.amazon.awssdk.services.datasync.DataSyncClient;
import software.amazon.awssdk.services.datasync.model.AgentStatus;
import software.amazon.awssdk.services.datasync.model.CreateTaskResponse;
import software.amazon.awssdk.services.datasync.model.DescribeAgentResponse;
import software.amazon.awssdk.services.datasync.model.DescribeLocationNfsResponse;
import software.amazon.awssdk.services.datasync.model.DescribeLocationS3Response;
import software.amazon.awssdk.services.datasync.model.DescribeTaskResponse;
import software.amazon.awssdk.services.datasync.model.InvalidRequestException;
import software.amazon.awssdk.services.datasync.model.S3StorageClass;
import software.amazon.awssdk.services.datasync.model.TagListEntry;
import software.amazon.awssdk.services.datasync.model.TaskMode;
import software.amazon.awssdk.services.datasync.model.TaskStatus;
import software.amazon.awssdk.services.datasync.model.VerifyMode;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("DataSync")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DataSyncTest {
private static DataSyncClient datasync;
private static String suffix;
private static String agentArn;
private static String s3LocationArn;
private static String nfsLocationArn;
private static String taskArn;
private static String enhancedTaskArn;
@BeforeAll
static void setup() {
datasync = TestFixtures.dataSyncClient();
suffix = String.valueOf(System.currentTimeMillis());
}
@AfterAll
static void cleanup() {
if (datasync == null) {
return;
}
deleteQuietly("task", enhancedTaskArn, arn -> datasync.deleteTask(r -> r.taskArn(arn)));
deleteQuietly("task", taskArn, arn -> datasync.deleteTask(r -> r.taskArn(arn)));
deleteQuietly("location", nfsLocationArn, arn -> datasync.deleteLocation(r -> r.locationArn(arn)));
deleteQuietly("location", s3LocationArn, arn -> datasync.deleteLocation(r -> r.locationArn(arn)));
deleteQuietly("agent", agentArn, arn -> datasync.deleteAgent(r -> r.agentArn(arn)));
datasync.close();
}
private static void deleteQuietly(String kind, String arn, java.util.function.Consumer<String> delete) {
if (arn == null) {
return;
}
try {
delete.accept(arn);
} catch (RuntimeException alreadyGone) {
System.out.println("DataSync cleanup left a " + kind + " behind: " + arn
+ " (" + alreadyGone.getMessage() + ")");
}
}
@Test
@Order(1)
void createAgentIsOnlineOnTheFirstDescribe() {
agentArn = datasync.createAgent(r -> r
.activationKey("AAAAA-1AAAA-BB1CC-DDDDD-EEEEE")
.agentName("sdk-test-agent-" + suffix)
.tags(TagListEntry.builder().key("team").value("platform").build()))
.agentArn();
assertThat(agentArn).contains(":datasync:").contains(":agent/agent-");
DescribeAgentResponse described = datasync.describeAgent(r -> r.agentArn(agentArn));
assertThat(described.agentArn()).isEqualTo(agentArn);
assertThat(described.name()).isEqualTo("sdk-test-agent-" + suffix);
assertThat(described.status()).isEqualTo(AgentStatus.ONLINE);
assertThat(described.endpointType()).isNotNull();
assertThat(described.creationTime()).isNotNull();
}
@Test
@Order(2)
void createS3LocationAppliesTheDocumentedStorageClassDefault() {
s3LocationArn = datasync.createLocationS3(r -> r
.s3BucketArn("arn:aws:s3:::sdk-test-datasync-" + suffix)
.subdirectory("/backups")
.s3Config(c -> c.bucketAccessRoleArn("arn:aws:iam::000000000000:role/datasync")))
.locationArn();
assertThat(s3LocationArn).contains(":location/loc-");
DescribeLocationS3Response described = datasync.describeLocationS3(r -> r.locationArn(s3LocationArn));
assertThat(described.locationArn()).isEqualTo(s3LocationArn);
assertThat(described.locationUri()).startsWith("s3://sdk-test-datasync-" + suffix);
assertThat(described.s3StorageClass()).isEqualTo(S3StorageClass.STANDARD);
assertThat(described.s3Config().bucketAccessRoleArn())
.isEqualTo("arn:aws:iam::000000000000:role/datasync");
assertThat(described.creationTime()).isNotNull();
}
@Test
@Order(3)
void createNfsLocationReadsBackItsAgentAndMountOptions() {
nfsLocationArn = datasync.createLocationNfs(r -> r
.serverHostname("nfs-" + suffix + ".example.com")
.subdirectory("/export/home")
.onPremConfig(c -> c.agentArns(agentArn)))
.locationArn();
DescribeLocationNfsResponse described = datasync.describeLocationNfs(r -> r.locationArn(nfsLocationArn));
assertThat(described.locationUri()).startsWith("nfs://nfs-" + suffix + ".example.com");
assertThat(described.onPremConfig().agentArns()).containsExactly(agentArn);
assertThat(described.mountOptions().versionAsString()).isEqualTo("AUTOMATIC");
}
@Test
@Order(4)
void basicTaskIsAvailableAndVerifiesPointInTime() {
CreateTaskResponse created = datasync.createTask(r -> r
.sourceLocationArn(nfsLocationArn)
.destinationLocationArn(s3LocationArn)
.name("sdk-test-task-" + suffix)
.tags(TagListEntry.builder().key("env").value("test").build()));
taskArn = created.taskArn();
assertThat(taskArn).contains(":task/task-");
DescribeTaskResponse described = datasync.describeTask(r -> r.taskArn(taskArn));
assertThat(described.status()).isEqualTo(TaskStatus.AVAILABLE);
assertThat(described.taskMode()).isEqualTo(TaskMode.BASIC);
assertThat(described.sourceLocationArn()).isEqualTo(nfsLocationArn);
assertThat(described.destinationLocationArn()).isEqualTo(s3LocationArn);
assertThat(described.options().verifyMode()).isEqualTo(VerifyMode.POINT_IN_TIME_CONSISTENT);
}
@Test
@Order(5)
void enhancedTaskVerifiesOnlyFilesTransferred() {
enhancedTaskArn = datasync.createTask(r -> r
.sourceLocationArn(nfsLocationArn)
.destinationLocationArn(s3LocationArn)
.name("sdk-test-task-enhanced-" + suffix)
.taskMode(TaskMode.ENHANCED))
.taskArn();
DescribeTaskResponse described = datasync.describeTask(r -> r.taskArn(enhancedTaskArn));
assertThat(described.taskMode()).isEqualTo(TaskMode.ENHANCED);
assertThat(described.options().verifyMode()).isEqualTo(VerifyMode.ONLY_FILES_TRANSFERRED);
}
@Test
@Order(6)
void enhancedTaskRejectsPointInTimeConsistentVerification() {
assertThatThrownBy(() -> datasync.createTask(r -> r
.sourceLocationArn(nfsLocationArn)
.destinationLocationArn(s3LocationArn)
.name("sdk-test-task-rejected-" + suffix)
.taskMode(TaskMode.ENHANCED)
.options(o -> o.verifyMode(VerifyMode.POINT_IN_TIME_CONSISTENT))))
.isInstanceOf(InvalidRequestException.class);
assertThatThrownBy(() -> datasync.updateTask(r -> r
.taskArn(enhancedTaskArn)
.options(o -> o.verifyMode(VerifyMode.POINT_IN_TIME_CONSISTENT))))
.isInstanceOf(InvalidRequestException.class);
}
@Test
@Order(7)
void updateTaskKeepsTheModeItWasCreatedWith() {
datasync.updateTask(r -> r
.taskArn(enhancedTaskArn)
.name("sdk-test-task-enhanced-renamed-" + suffix));
DescribeTaskResponse described = datasync.describeTask(r -> r.taskArn(enhancedTaskArn));
assertThat(described.name()).isEqualTo("sdk-test-task-enhanced-renamed-" + suffix);
assertThat(described.taskMode()).isEqualTo(TaskMode.ENHANCED);
assertThat(described.options().verifyMode()).isEqualTo(VerifyMode.ONLY_FILES_TRANSFERRED);
}
@Test
@Order(8)
void listTasksIncludesBothTasks() {
assertThat(datasync.listTasks(r -> r.maxResults(100)).tasks())
.extracting(t -> t.taskArn())
.contains(taskArn, enhancedTaskArn);
}
@Test
@Order(9)
void tagRoundTripOnTheTask() {
datasync.tagResource(r -> r
.resourceArn(taskArn)
.tags(TagListEntry.builder().key("owner").value("platform").build()));
assertThat(datasync.listTagsForResource(r -> r.resourceArn(taskArn)).tags())
.contains(TagListEntry.builder().key("env").value("test").build())
.contains(TagListEntry.builder().key("owner").value("platform").build());
datasync.untagResource(r -> r.resourceArn(taskArn).keys("env"));
assertThat(datasync.listTagsForResource(r -> r.resourceArn(taskArn)).tags())
.extracting(TagListEntry::key)
.contains("owner")
.doesNotContain("env");
}
@Test
@Order(10)
void deleteTaskThenDescribeIsAnInvalidRequest() {
datasync.deleteTask(r -> r.taskArn(taskArn));
String deleted = taskArn;
taskArn = null;
assertThatThrownBy(() -> datasync.describeTask(r -> r.taskArn(deleted)))
.isInstanceOf(InvalidRequestException.class);
}
}
@@ -0,0 +1,71 @@
package com.floci.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.detective.DetectiveClient;
import software.amazon.awssdk.services.detective.model.Account;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
@DisplayName("Detective organization administration")
class DetectiveOrganizationAdministrationTest {
private static final String MANAGEMENT_ACCOUNT = "222222222222";
private static final String ADMIN_ACCOUNT = MANAGEMENT_ACCOUNT;
private static final String MEMBER_ACCOUNT = "333333333333";
@Test
@DisplayName("uses AWS SDK models for organization graph and member operations")
void organizationAdministrationUsesAwsSdk() {
assumeFalse(TestFixtures.isRealAws(), "Avoids changing Detective organization settings in real AWS");
try (var organizations = TestFixtures.organizationsClient(MANAGEMENT_ACCOUNT);
DetectiveClient management = TestFixtures.detectiveClient(MANAGEMENT_ACCOUNT);
DetectiveClient administrator = TestFixtures.detectiveClient(ADMIN_ACCOUNT)) {
try {
organizations.describeOrganization();
} catch (software.amazon.awssdk.services.organizations.model.AwsOrganizationsNotInUseException e) {
organizations.createOrganization();
}
assertThat(management.listOrganizationAdminAccounts(request -> request.maxResults(200)).administrators())
.isEmpty();
management.enableOrganizationAdminAccount(request -> request.accountId(ADMIN_ACCOUNT));
assertThat(management.listOrganizationAdminAccounts(request -> request.maxResults(200)).administrators())
.extracting(administratorAccount -> administratorAccount.accountId())
.containsExactly(ADMIN_ACCOUNT);
String graphArn = administrator.listGraphs(request -> request.maxResults(200)).graphList().get(0).arn();
administrator.updateOrganizationConfiguration(request -> request.graphArn(graphArn).autoEnable(true));
assertThat(administrator.describeOrganizationConfiguration(request -> request.graphArn(graphArn)).autoEnable())
.isTrue();
var created = administrator.createMembers(request -> request
.graphArn(graphArn)
.accounts(List.of(Account.builder().accountId(MEMBER_ACCOUNT).build())));
assertThat(created.members()).extracting(member -> member.accountId()).containsExactly(MEMBER_ACCOUNT);
assertThat(created.unprocessedAccounts()).isEmpty();
var duplicate = administrator.createMembers(request -> request
.graphArn(graphArn)
.accounts(List.of(Account.builder().accountId(MEMBER_ACCOUNT).build())));
assertThat(duplicate.members()).isEmpty();
assertThat(duplicate.unprocessedAccounts())
.extracting(account -> account.accountId())
.containsExactly(MEMBER_ACCOUNT);
assertThat(administrator.listMembers(request -> request.graphArn(graphArn).maxResults(200)).memberDetails())
.extracting(member -> member.accountId())
.containsExactly(MEMBER_ACCOUNT);
administrator.startMonitoringMember(request -> request.graphArn(graphArn).accountId(MEMBER_ACCOUNT));
assertThat(administrator.listMembers(request -> request.graphArn(graphArn)).memberDetails())
.singleElement()
.satisfies(member -> assertThat(member.statusAsString()).isEqualTo("ENABLED"));
}
}
}
@@ -0,0 +1,198 @@
package com.floci.test;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.databasemigration.DatabaseMigrationClient;
import software.amazon.awssdk.services.databasemigration.model.DatabaseMigrationException;
import software.amazon.awssdk.services.databasemigration.model.DescribeReplicationSubnetGroupsResponse;
import software.amazon.awssdk.services.databasemigration.model.ReplicationSubnetGroup;
import software.amazon.awssdk.services.databasemigration.model.Tag;
import software.amazon.awssdk.services.ec2.Ec2Client;
import software.amazon.awssdk.services.ec2.model.Subnet;
import software.amazon.awssdk.services.sts.StsClient;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DmsTest {
private static final String GROUP_ID = "sdk-compat-subnet-group";
private static final String PAGED_GROUP_PREFIX = "sdk-compat-page-";
private static final String CONTROL_CHAR_GROUP_ID = "sdk-compat-control-char";
@Test
void replicationSubnetGroupLifecycleUsesAwsSdkWireContract() {
List<Subnet> subnets = twoSubnetsInOneVpcAcrossZones();
List<String> subnetIds = subnets.stream().map(Subnet::subnetId).toList();
try (DatabaseMigrationClient dms = TestFixtures.databaseMigrationClient()) {
dms.createReplicationSubnetGroup(request -> request
.replicationSubnetGroupIdentifier(GROUP_ID)
.replicationSubnetGroupDescription("sdk compatibility suite")
.subnetIds(subnetIds)
.tags(tag -> tag.key("env").value("test")));
try {
List<ReplicationSubnetGroup> described = dms.describeReplicationSubnetGroups(request -> request
.filters(filter -> filter.name("replication-subnet-group-id").values(GROUP_ID)))
.replicationSubnetGroups();
assertEquals(1, described.size());
ReplicationSubnetGroup group = described.get(0);
assertEquals(GROUP_ID, group.replicationSubnetGroupIdentifier());
assertEquals("Complete", group.subnetGroupStatus());
assertEquals(subnets.get(0).vpcId(), group.vpcId());
assertFalse(group.subnets().isEmpty());
assertTrue(group.subnets().stream()
.allMatch(subnet -> "Active".equals(subnet.subnetStatus())));
String arn = "arn:aws:dms:us-east-1:" + callerAccountId() + ":subgrp:" + GROUP_ID;
assertEquals(Map.of("env", "test"), tagsOf(dms, arn));
dms.addTagsToResource(request -> request.resourceArn(arn)
.tags(tag -> tag.key("owner").value("data")));
assertEquals(Map.of("env", "test", "owner", "data"), tagsOf(dms, arn));
dms.removeTagsFromResource(request -> request.resourceArn(arn).tagKeys("env"));
assertEquals(Map.of("owner", "data"), tagsOf(dms, arn));
} finally {
dms.deleteReplicationSubnetGroup(request -> request.replicationSubnetGroupIdentifier(GROUP_ID));
}
DatabaseMigrationException deleted = assertThrows(DatabaseMigrationException.class,
() -> dms.describeReplicationSubnetGroups(request -> request
.filters(filter -> filter.name("replication-subnet-group-id").values(GROUP_ID))));
assertEquals("ResourceNotFoundFault", deleted.awsErrorDetails().errorCode());
}
}
/**
* DMS pages this describe at a minimum MaxRecords of 20, so a second page needs more than 20
* groups to exist. Walk the pages by Marker and check the walk is a partition of what was
* created: every identifier once, none missing, non-final pages full, and no Marker handed back
* on the last page (an SDK paginator treats any Marker as "ask again" and would loop).
*/
@Test
void describeReplicationSubnetGroupsPagesByMarker() {
List<String> subnetIds = twoSubnetsInOneVpcAcrossZones().stream().map(Subnet::subnetId).toList();
List<String> created = new ArrayList<>();
for (int i = 0; i < 25; i++) {
created.add(PAGED_GROUP_PREFIX + String.format("%02d", i));
}
try (DatabaseMigrationClient dms = TestFixtures.databaseMigrationClient()) {
for (String identifier : created) {
dms.createReplicationSubnetGroup(request -> request
.replicationSubnetGroupIdentifier(identifier)
.replicationSubnetGroupDescription("sdk pagination suite")
.subnetIds(subnetIds));
}
try {
List<String> walked = new ArrayList<>();
String marker = null;
int pages = 0;
do {
String resume = marker;
DescribeReplicationSubnetGroupsResponse page = dms.describeReplicationSubnetGroups(
request -> request.maxRecords(20).marker(resume));
pages++;
marker = page.marker();
if (marker != null) {
assertEquals(20, page.replicationSubnetGroups().size(),
"a non-final page must be full");
}
page.replicationSubnetGroups()
.forEach(g -> walked.add(g.replicationSubnetGroupIdentifier()));
} while (marker != null);
assertTrue(pages > 1, "25 groups at MaxRecords=20 must span more than one page");
assertEquals(walked.size(), walked.stream().distinct().count(), "a group was returned twice");
assertEquals(created, walked.stream()
.filter(id -> id.startsWith(PAGED_GROUP_PREFIX))
.sorted()
.toList());
} finally {
for (String identifier : created) {
dms.deleteReplicationSubnetGroup(request -> request
.replicationSubnetGroupIdentifier(identifier));
}
}
}
}
@Test
void describeReplicationSubnetGroupsRejectsMaxRecordsOutsideTheDocumentedRange() {
try (DatabaseMigrationClient dms = TestFixtures.databaseMigrationClient()) {
for (int outOfRange : new int[] {19, 101}) {
DatabaseMigrationException rejected = assertThrows(DatabaseMigrationException.class,
() -> dms.describeReplicationSubnetGroups(request -> request.maxRecords(outOfRange)));
assertEquals("InvalidParameterValueException", rejected.awsErrorDetails().errorCode());
}
}
}
/**
* The DMS request model allows only printable characters in the description, so a control
* character is rejected rather than persisted.
*/
@Test
void createReplicationSubnetGroupRejectsANonPrintableDescription() {
List<String> subnetIds = twoSubnetsInOneVpcAcrossZones().stream().map(Subnet::subnetId).toList();
try (DatabaseMigrationClient dms = TestFixtures.databaseMigrationClient()) {
DatabaseMigrationException rejected = assertThrows(DatabaseMigrationException.class,
() -> dms.createReplicationSubnetGroup(request -> request
.replicationSubnetGroupIdentifier(CONTROL_CHAR_GROUP_ID)
.replicationSubnetGroupDescription("sdk\u0001suite")
.subnetIds(subnetIds)));
assertEquals("InvalidParameterValueException", rejected.awsErrorDetails().errorCode());
DatabaseMigrationException absent = assertThrows(DatabaseMigrationException.class,
() -> dms.describeReplicationSubnetGroups(request -> request
.filters(filter -> filter.name("replication-subnet-group-id")
.values(CONTROL_CHAR_GROUP_ID))));
assertEquals("ResourceNotFoundFault", absent.awsErrorDetails().errorCode());
}
}
/**
* DMS does not return the subnet group's ARN, so the tagging calls need it built the way the
* Terraform provider builds it. Read the account from STS rather than hardcoding the default.
*/
private static String callerAccountId() {
try (StsClient sts = TestFixtures.stsClient()) {
return sts.getCallerIdentity().account();
}
}
private static Map<String, String> tagsOf(DatabaseMigrationClient dms, String arn) {
return dms.listTagsForResource(request -> request.resourceArn(arn)).tagList().stream()
.collect(Collectors.toMap(Tag::key, Tag::value));
}
/**
* DMS requires the group's subnets to share a VPC and to cover at least two Availability
* Zones, so pick a VPC that satisfies both rather than the first two subnets returned.
*/
private static List<Subnet> twoSubnetsInOneVpcAcrossZones() {
try (Ec2Client ec2 = TestFixtures.ec2Client()) {
Map<String, Map<String, Subnet>> byVpcAndZone = new LinkedHashMap<>();
for (Subnet subnet : ec2.describeSubnets().subnets()) {
byVpcAndZone
.computeIfAbsent(subnet.vpcId(), vpc -> new LinkedHashMap<>())
.putIfAbsent(subnet.availabilityZone(), subnet);
}
return byVpcAndZone.values().stream()
.filter(byZone -> byZone.size() >= 2)
.map(byZone -> byZone.values().stream().limit(2).toList())
.findFirst()
.orElseThrow(() -> new IllegalStateException(
"no VPC has subnets in two Availability Zones"));
}
}
}
@@ -0,0 +1,290 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.BillingMode;
import software.amazon.awssdk.services.dynamodb.model.ComparisonOperator;
import software.amazon.awssdk.services.dynamodb.model.Condition;
import software.amazon.awssdk.services.dynamodb.model.DynamoDbException;
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
import software.amazon.awssdk.services.dynamodb.model.KeyType;
import software.amazon.awssdk.services.dynamodb.model.LocalSecondaryIndex;
import software.amazon.awssdk.services.dynamodb.model.Projection;
import software.amazon.awssdk.services.dynamodb.model.ProjectionType;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
import software.amazon.awssdk.services.dynamodb.model.Select;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class DynamoDbAccessPathValidationTest {
private static final String TABLE = "access-path-" + UUID.randomUUID().toString().substring(0, 8);
private static DynamoDbClient ddb;
@BeforeAll
static void createTable() {
ddb = TestFixtures.dynamoDbClient();
ddb.createTable(request -> request
.tableName(TABLE)
.keySchema(key("pk", KeyType.HASH), key("sk", KeyType.RANGE))
.attributeDefinitions(
attribute("pk"), attribute("sk"), attribute("status"),
attribute("createdAt"), attribute("alternate"))
.globalSecondaryIndexes(GlobalSecondaryIndex.builder()
.indexName("status-index")
.keySchema(key("status", KeyType.HASH), key("createdAt", KeyType.RANGE),
key("alternate", KeyType.RANGE))
.projection(Projection.builder()
.projectionType(ProjectionType.INCLUDE)
.nonKeyAttributes("summary")
.build())
.build())
.localSecondaryIndexes(LocalSecondaryIndex.builder()
.indexName("alternate-index")
.keySchema(key("pk", KeyType.HASH), key("alternate", KeyType.RANGE))
.projection(Projection.builder().projectionType(ProjectionType.KEYS_ONLY).build())
.build())
.billingMode(BillingMode.PAY_PER_REQUEST));
}
@AfterAll
static void deleteTable() {
ddb.deleteTable(request -> request.tableName(TABLE));
ddb.close();
}
@Test
void queryAndScanRejectUnknownIndexOnEmptyTable() {
assertValidationException(() -> ddb.scan(request -> request
.tableName(TABLE)
.indexName("missing-index")));
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.indexName("missing-index")
.keyConditionExpression("pk = :pk")
.expressionAttributeValues(Map.of(":pk", value("p1")))));
}
@Test
void queryRejectsNonKeyConditionOnEmptyTable() {
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.keyConditionExpression("pk = :pk AND status = :status")
.expressionAttributeValues(Map.of(
":pk", value("p1"),
":status", value("open")))));
}
@Test
void queryRejectsKeyConditionValuesWithWrongSchemaTypes() {
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.keyConditionExpression("pk = :pk AND sk > :sk")
.expressionAttributeValues(Map.of(
":pk", value("p1"),
":sk", numberValue("1")))));
}
@Test
void acceptsTableKeyConditionsInEitherOrder() {
var response = ddb.query(request -> request
.tableName(TABLE)
.keyConditionExpression("sk >= :sk AND pk = :pk")
.expressionAttributeValues(Map.of(
":pk", value("p1"),
":sk", value("a"))));
assertThat(response.count()).isZero();
}
@Test
void lsiRejectsBaseTableSortKeyCondition() {
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.indexName("alternate-index")
.keyConditionExpression("pk = :pk AND sk = :sk")
.expressionAttributeValues(Map.of(
":pk", value("p1"),
":sk", value("s1")))));
}
@Test
@SuppressWarnings("deprecation")
void legacyKeyConditionsUseSelectedGsiSchema() {
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.indexName("status-index")
.keyConditions(Map.of(
"status", condition(ComparisonOperator.EQ, "open"),
"pk", condition(ComparisonOperator.EQ, "p1")))));
}
@Test
void queryRejectsSelectedKeyInFilterExpression() {
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.indexName("status-index")
.keyConditionExpression("#status = :status")
.filterExpression("createdAt > :cutoff")
.expressionAttributeNames(Map.of("#status", "status"))
.expressionAttributeValues(Map.of(
":status", value("open"),
":cutoff", value("2026-01-01")))));
}
@Test
void queryAndScanRejectNonProjectedGsiAttribute() {
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.indexName("status-index")
.keyConditionExpression("#status = :status")
.expressionAttributeNames(Map.of("#status", "status"))
.expressionAttributeValues(Map.of(":status", value("open")))
.projectionExpression("details")));
assertValidationException(() -> ddb.scan(request -> request
.tableName(TABLE)
.indexName("status-index")
.projectionExpression("details")));
}
@Test
void acceptsProjectedGsiAttributesAndLsiTableFetch() {
var gsiResponse = ddb.query(request -> request
.tableName(TABLE)
.indexName("status-index")
.keyConditionExpression("#status = :status")
.expressionAttributeNames(Map.of("#status", "status"))
.expressionAttributeValues(Map.of(":status", value("open")))
.projectionExpression("pk, #status, summary"));
assertThat(gsiResponse.count()).isZero();
var lsiResponse = ddb.query(request -> request
.tableName(TABLE)
.indexName("alternate-index")
.keyConditionExpression("pk = :pk")
.expressionAttributeValues(Map.of(":pk", value("p1")))
.select(Select.ALL_ATTRIBUTES));
assertThat(lsiResponse.count()).isZero();
var lsiScanResponse = ddb.scan(request -> request
.tableName(TABLE)
.indexName("alternate-index")
.projectionExpression("details"));
assertThat(lsiScanResponse.count()).isZero();
}
@Test
void gsiStillRejectsConsistentReads() {
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.indexName("status-index")
.consistentRead(true)
.keyConditionExpression("#status = :status")
.expressionAttributeNames(Map.of("#status", "status"))
.expressionAttributeValues(Map.of(":status", value("open")))));
assertValidationException(() -> ddb.scan(request -> request
.tableName(TABLE)
.indexName("status-index")
.consistentRead(true)));
}
@Test
void queryAndScanRejectWrongExclusiveStartKeyTypes() {
Map<String, AttributeValue> invalidStartKey = Map.of(
"pk", AttributeValue.builder().n("1").build(),
"sk", value("s1"));
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.keyConditionExpression("pk = :pk")
.expressionAttributeValues(Map.of(":pk", value("p1")))
.exclusiveStartKey(invalidStartKey)));
assertValidationException(() -> ddb.scan(request -> request
.tableName(TABLE)
.exclusiveStartKey(invalidStartKey)));
}
@Test
@SuppressWarnings("deprecation")
void queryRejectsInvalidCompositeSortKeyConditions() {
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.indexName("status-index")
.keyConditionExpression("#status = :status AND alternate = :alternate")
.expressionAttributeNames(Map.of("#status", "status"))
.expressionAttributeValues(Map.of(
":status", value("open"),
":alternate", value("a1")))));
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.indexName("status-index")
.keyConditionExpression("#status = :status AND createdAt > :createdAt "
+ "AND alternate = :alternate")
.expressionAttributeNames(Map.of("#status", "status"))
.expressionAttributeValues(Map.of(
":status", value("open"),
":createdAt", value("2026-01-01"),
":alternate", value("a1")))));
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.indexName("status-index")
.keyConditions(Map.of(
"status", condition(ComparisonOperator.EQ, "open"),
"alternate", condition(ComparisonOperator.EQ, "a1")))));
assertValidationException(() -> ddb.query(request -> request
.tableName(TABLE)
.indexName("status-index")
.keyConditions(Map.of(
"status", condition(ComparisonOperator.EQ, "open"),
"createdAt", condition(ComparisonOperator.GT, "2026-01-01"),
"alternate", condition(ComparisonOperator.EQ, "a1")))));
}
private static void assertValidationException(org.assertj.core.api.ThrowableAssert.ThrowingCallable call) {
assertThatThrownBy(call)
.isInstanceOf(DynamoDbException.class)
.satisfies(error -> assertThat(((DynamoDbException) error)
.awsErrorDetails().errorCode()).isEqualTo("ValidationException"));
}
private static KeySchemaElement key(String name, KeyType type) {
return KeySchemaElement.builder().attributeName(name).keyType(type).build();
}
private static AttributeDefinition attribute(String name) {
return AttributeDefinition.builder()
.attributeName(name)
.attributeType(ScalarAttributeType.S)
.build();
}
private static AttributeValue value(String value) {
return AttributeValue.builder().s(value).build();
}
private static AttributeValue numberValue(String value) {
return AttributeValue.builder().n(value).build();
}
private static Condition condition(ComparisonOperator operator, String attributeValue) {
return Condition.builder()
.comparisonOperator(operator)
.attributeValueList(value(attributeValue))
.build();
}
}
@@ -0,0 +1,288 @@
package com.floci.test;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
import software.amazon.awssdk.services.dynamodb.model.KeyType;
import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput;
import software.amazon.awssdk.services.dynamodb.model.Put;
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest;
import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException;
import software.amazon.awssdk.services.dynamodb.model.Update;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end concurrency tests against a running Floci instance.
*
* <p>Covers the four scenarios that must match real DynamoDB's per-item
* linearisability and transaction atomicity — see issue #571.
*
* <p>Each scenario uses a shared {@link CountDownLatch} starting gate so
* client threads dispatch simultaneously, maximising contention at the server.
*/
@DisplayName("DynamoDB - Concurrency")
class DynamoDbConcurrencyTest {
private static final String TABLE_NAME = "sdk-test-concurrency-table";
private static final int THREADS = 20;
private static DynamoDbClient ddb;
@BeforeAll
static void setup() {
ddb = TestFixtures.dynamoDbClient();
ddb.createTable(CreateTableRequest.builder()
.tableName(TABLE_NAME)
.keySchema(KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build())
.attributeDefinitions(AttributeDefinition.builder()
.attributeName("pk").attributeType(ScalarAttributeType.S).build())
.provisionedThroughput(ProvisionedThroughput.builder()
.readCapacityUnits(5L).writeCapacityUnits(5L).build())
.build());
}
@AfterAll
static void cleanup() {
if (ddb != null) {
try {
ddb.deleteTable(DeleteTableRequest.builder().tableName(TABLE_NAME).build());
} catch (Exception ignored) {}
ddb.close();
}
}
@Test
@DisplayName("UpdateItem arithmetic — 20 concurrent increments yield 20 distinct results")
void concurrentUpdateItemArithmetic() throws InterruptedException {
String pk = "arith-" + System.nanoTime();
Set<Integer> observed = Collections.synchronizedSet(new HashSet<>());
runConcurrently(THREADS, () -> {
UpdateItemResponse response = ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of("pk", AttributeValue.builder().s(pk).build()))
.updateExpression("SET cnt = if_not_exists(cnt, :start) + :inc")
.expressionAttributeValues(Map.of(
":start", AttributeValue.builder().n("0").build(),
":inc", AttributeValue.builder().n("1").build()))
.returnValues(ReturnValue.ALL_NEW)
.build());
observed.add(Integer.parseInt(response.attributes().get("cnt").n()));
});
assertThat(observed)
.as("each UpdateItem should return a distinct cnt under contention")
.hasSize(THREADS);
int finalCnt = Integer.parseInt(ddb.getItem(GetItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of("pk", AttributeValue.builder().s(pk).build()))
.consistentRead(true)
.build())
.item().get("cnt").n());
assertThat(finalCnt).isEqualTo(THREADS);
}
@Test
@DisplayName("PutItem attribute_not_exists — exactly one of N concurrent attempts succeeds")
void concurrentPutItemAttributeNotExists() throws InterruptedException {
String pk = "unique-" + System.nanoTime();
AtomicInteger successes = new AtomicInteger();
AtomicInteger conditionalFailures = new AtomicInteger();
runConcurrently(THREADS, () -> {
try {
ddb.putItem(PutItemRequest.builder()
.tableName(TABLE_NAME)
.item(Map.of("pk", AttributeValue.builder().s(pk).build()))
.conditionExpression("attribute_not_exists(pk)")
.build());
successes.incrementAndGet();
} catch (ConditionalCheckFailedException e) {
conditionalFailures.incrementAndGet();
}
});
assertThat(successes.get())
.as("exactly one concurrent PutItem(attribute_not_exists) must succeed")
.isEqualTo(1);
assertThat(conditionalFailures.get()).isEqualTo(THREADS - 1);
}
@Test
@DisplayName("TransactWriteItems overlapping — winners see consistent state across items")
void concurrentTransactWriteItemsOverlapping() throws InterruptedException {
String pkA = "txA-" + System.nanoTime();
String pkB = "txB-" + System.nanoTime();
// Seed both keys at version=0.
for (String pk : List.of(pkA, pkB)) {
ddb.putItem(PutItemRequest.builder()
.tableName(TABLE_NAME)
.item(Map.of(
"pk", AttributeValue.builder().s(pk).build(),
"version", AttributeValue.builder().n("0").build()))
.build());
}
AtomicInteger committed = new AtomicInteger();
AtomicInteger cancelled = new AtomicInteger();
runConcurrently(THREADS, () -> {
int currentVersion = Integer.parseInt(ddb.getItem(GetItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of("pk", AttributeValue.builder().s(pkA).build()))
.consistentRead(true)
.build())
.item().get("version").n());
int nextVersion = currentVersion + 1;
Map<String, AttributeValue> exprValues = Map.of(
":v0", AttributeValue.builder().n(String.valueOf(currentVersion)).build(),
":v1", AttributeValue.builder().n(String.valueOf(nextVersion)).build());
try {
ddb.transactWriteItems(TransactWriteItemsRequest.builder()
.transactItems(
buildVersionUpdate(pkA, exprValues),
buildVersionUpdate(pkB, exprValues))
.build());
committed.incrementAndGet();
} catch (TransactionCanceledException e) {
cancelled.incrementAndGet();
}
});
int versionA = Integer.parseInt(ddb.getItem(GetItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of("pk", AttributeValue.builder().s(pkA).build()))
.consistentRead(true)
.build())
.item().get("version").n());
int versionB = Integer.parseInt(ddb.getItem(GetItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of("pk", AttributeValue.builder().s(pkB).build()))
.consistentRead(true)
.build())
.item().get("version").n());
assertThat(versionA)
.as("pkA and pkB must end on the same version — transaction atomicity")
.isEqualTo(versionB);
assertThat(committed.get())
.as("commit count must match observed version progress")
.isEqualTo(versionA);
assertThat(committed.get() + cancelled.get()).isEqualTo(THREADS);
}
private static TransactWriteItem buildVersionUpdate(String pk, Map<String, AttributeValue> exprValues) {
return TransactWriteItem.builder()
.update(Update.builder()
.tableName(TABLE_NAME)
.key(Map.of("pk", AttributeValue.builder().s(pk).build()))
.updateExpression("SET version = :v1")
.conditionExpression("version = :v0")
.expressionAttributeValues(exprValues)
.build())
.build();
}
@Test
@DisplayName("UpdateItem + PutItem on same key — linearisable, no half-updated state")
void concurrentMixedUpdateAndPut() throws InterruptedException {
String pk = "mixed-" + System.nanoTime();
AtomicInteger idSource = new AtomicInteger();
runConcurrently(THREADS, () -> {
int id = idSource.getAndIncrement();
if (id % 2 == 0) {
ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of("pk", AttributeValue.builder().s(pk).build()))
.updateExpression("SET cnt = if_not_exists(cnt, :start) + :inc")
.expressionAttributeValues(Map.of(
":start", AttributeValue.builder().n("0").build(),
":inc", AttributeValue.builder().n("1").build()))
.build());
} else {
ddb.putItem(PutItemRequest.builder()
.tableName(TABLE_NAME)
.item(Map.of(
"pk", AttributeValue.builder().s(pk).build(),
"writer", AttributeValue.builder().s("put-" + id).build()))
.build());
}
});
Map<String, AttributeValue> finalItem = ddb.getItem(GetItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of("pk", AttributeValue.builder().s(pk).build()))
.consistentRead(true)
.build())
.item();
assertThat(finalItem).isNotNull();
assertThat(finalItem.get("pk").s()).isEqualTo(pk);
finalItem.forEach((name, value) -> assertThat(value)
.as("attribute %s must not be null in final item", name)
.isNotNull());
}
private static void runConcurrently(int threadCount, Runnable work) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(threadCount);
CountDownLatch startGate = new CountDownLatch(1);
CountDownLatch doneGate = new CountDownLatch(threadCount);
List<Throwable> errors = Collections.synchronizedList(new ArrayList<>());
try {
for (int i = 0; i < threadCount; i++) {
pool.submit(() -> {
try {
startGate.await();
work.run();
} catch (Throwable t) {
errors.add(t);
} finally {
doneGate.countDown();
}
});
}
startGate.countDown();
assertThat(doneGate.await(60, TimeUnit.SECONDS))
.as("concurrent work did not complete within 60s")
.isTrue();
} finally {
pool.shutdownNow();
pool.awaitTermination(5, TimeUnit.SECONDS);
}
assertThat(errors)
.as("no unexpected errors should be thrown")
.isEmpty();
}
}
@@ -0,0 +1,866 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeAction;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.AttributeValueUpdate;
import software.amazon.awssdk.services.dynamodb.model.BatchGetItemResponse;
import software.amazon.awssdk.services.dynamodb.model.BillingMode;
import software.amazon.awssdk.services.dynamodb.model.ComparisonOperator;
import software.amazon.awssdk.services.dynamodb.model.Condition;
import software.amazon.awssdk.services.dynamodb.model.ConditionCheck;
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
import software.amazon.awssdk.services.dynamodb.model.DynamoDbException;
import software.amazon.awssdk.services.dynamodb.model.ExpectedAttributeValue;
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
import software.amazon.awssdk.services.dynamodb.model.KeyType;
import software.amazon.awssdk.services.dynamodb.model.KeysAndAttributes;
import software.amazon.awssdk.services.dynamodb.model.ListTablesResponse;
import software.amazon.awssdk.services.dynamodb.model.LocalSecondaryIndex;
import software.amazon.awssdk.services.dynamodb.model.Projection;
import software.amazon.awssdk.services.dynamodb.model.ProjectionType;
import software.amazon.awssdk.services.dynamodb.model.Put;
import software.amazon.awssdk.services.dynamodb.model.PutItemResponse;
import software.amazon.awssdk.services.dynamodb.model.PutRequest;
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
import software.amazon.awssdk.services.dynamodb.model.QueryResponse;
import software.amazon.awssdk.services.dynamodb.model.ReturnItemCollectionMetrics;
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
import software.amazon.awssdk.services.dynamodb.model.ScanResponse;
import software.amazon.awssdk.services.dynamodb.model.Select;
import software.amazon.awssdk.services.dynamodb.model.Tag;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest;
import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
import software.amazon.awssdk.services.dynamodb.model.WriteRequest;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
/**
* Compatibility tests for the DynamoDB conformance improvements.
*
* Covers:
* - Phase 1: ListTables (pagination, alphabetical order, Limit validation) + ValidationException rename
* - Phase 2: ProjectionExpression on GetItem, Query, Scan, BatchGetItem
* - Phase 3: Batch limits, transaction validations, empty-key rejection,
* ReturnItemCollectionMetrics, idempotency token
* - Phase 4: Select=COUNT, parallel scan, attribute_type() function, parenthesized key condition
* - Phase 7: TagResource/ListTagsOfResource error cases
* - Phase 8: ReturnValuesOnConditionCheckFailure
* - Phase 10: Legacy API (AttributesToGet, AttributeUpdates, QueryFilter, ScanFilter)
* - Phase 11: Enum validation before table lookup
*/
@DisplayName("DynamoDB Conformance Changes")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DynamoDbConformanceChangesTest {
private static DynamoDbClient ddb;
private static final String TABLE = "conformance-test-" + UUID.randomUUID().toString().substring(0, 8);
private static final String TABLE_LSI = "conformance-lsi-" + UUID.randomUUID().toString().substring(0, 8);
@BeforeAll
static void setup() {
ddb = TestFixtures.dynamoDbClient();
ddb.createTable(r -> r
.tableName(TABLE)
.keySchema(
KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build(),
KeySchemaElement.builder().attributeName("sk").keyType(KeyType.RANGE).build())
.attributeDefinitions(
AttributeDefinition.builder().attributeName("pk").attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder().attributeName("sk").attributeType(ScalarAttributeType.S).build())
.billingMode(BillingMode.PAY_PER_REQUEST));
ddb.createTable(r -> r
.tableName(TABLE_LSI)
.keySchema(
KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build(),
KeySchemaElement.builder().attributeName("sk").keyType(KeyType.RANGE).build())
.attributeDefinitions(
AttributeDefinition.builder().attributeName("pk").attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder().attributeName("sk").attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder().attributeName("gsi_sk").attributeType(ScalarAttributeType.S).build())
.localSecondaryIndexes(LocalSecondaryIndex.builder()
.indexName("lsi-by-gsi-sk")
.keySchema(
KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build(),
KeySchemaElement.builder().attributeName("gsi_sk").keyType(KeyType.RANGE).build())
.projection(Projection.builder().projectionType(ProjectionType.ALL).build())
.build())
.billingMode(BillingMode.PAY_PER_REQUEST));
// Seed items
for (int i = 0; i < 5; i++) {
final int idx = i;
ddb.putItem(r -> r.tableName(TABLE).item(Map.of(
"pk", av("p1"),
"sk", av("s" + idx),
"name", av("Item-" + idx),
"count", AttributeValue.builder().n(String.valueOf(idx * 10)).build(),
"status", av("active"))));
}
}
@AfterAll
static void cleanup() {
try { ddb.deleteTable(r -> r.tableName(TABLE)); } catch (Exception ignored) {}
try { ddb.deleteTable(r -> r.tableName(TABLE_LSI)); } catch (Exception ignored) {}
}
// -----------------------------------------------------------------------
// Phase 1 — ListTables: alphabetical order, Limit, ExclusiveStartTableName
// -----------------------------------------------------------------------
@Test @Order(10)
void listTablesReturnsAlphabeticalOrder() {
ListTablesResponse resp = ddb.listTables();
List<String> names = resp.tableNames();
List<String> sorted = new ArrayList<>(names);
Collections.sort(sorted);
assertThat(names).isEqualTo(sorted);
}
@Test @Order(11)
void listTablesWithLimit() {
ListTablesResponse first = ddb.listTables(r -> r.limit(1));
assertThat(first.tableNames()).hasSize(1);
assertThat(first.lastEvaluatedTableName()).isNotNull();
ListTablesResponse second = ddb.listTables(r -> r
.limit(1)
.exclusiveStartTableName(first.lastEvaluatedTableName()));
assertThat(second.tableNames()).isNotEmpty();
assertThat(second.tableNames()).doesNotContain(first.tableNames().get(0));
}
@Test @Order(12)
void listTablesLimitZeroThrowsValidationException() {
assertThatThrownBy(() -> ddb.listTables(r -> r.limit(0)))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("ValidationException"));
}
@Test @Order(13)
void listTablesLimitOver100ThrowsValidationException() {
assertThatThrownBy(() -> ddb.listTables(r -> r.limit(101)))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("ValidationException"));
}
// -----------------------------------------------------------------------
// Phase 1 — ValidationException for invalid table names
// -----------------------------------------------------------------------
@Test @Order(20)
void putItemInvalidTableNameThrowsValidationException() {
assertThatThrownBy(() -> ddb.putItem(r -> r.tableName("ab").item(Map.of("pk", av("x")))))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("ValidationException"));
}
// -----------------------------------------------------------------------
// Phase 2 — ProjectionExpression
// -----------------------------------------------------------------------
@Test @Order(30)
void getItemWithProjectionReturnsOnlyProjectedAttributes() {
GetItemResponse resp = ddb.getItem(r -> r
.tableName(TABLE)
.key(Map.of("pk", av("p1"), "sk", av("s0")))
.projectionExpression("#n")
.expressionAttributeNames(Map.of("#n", "name")));
assertThat(resp.item()).containsKey("name");
assertThat(resp.item()).doesNotContainKey("count");
assertThat(resp.item()).doesNotContainKey("status");
}
@Test @Order(31)
void queryWithProjectionReturnsOnlyProjectedAttributes() {
QueryResponse resp = ddb.query(r -> r
.tableName(TABLE)
.keyConditionExpression("pk = :pk")
.expressionAttributeValues(Map.of(":pk", av("p1")))
.projectionExpression("sk, #n")
.expressionAttributeNames(Map.of("#n", "name")));
assertThat(resp.items()).isNotEmpty();
for (Map<String, AttributeValue> item : resp.items()) {
assertThat(item).containsKeys("sk", "name");
assertThat(item).doesNotContainKey("count");
}
}
@Test @Order(32)
void scanWithProjectionReturnsOnlyProjectedAttributes() {
ScanResponse resp = ddb.scan(r -> r
.tableName(TABLE)
.filterExpression("pk = :pk")
.expressionAttributeValues(Map.of(":pk", av("p1")))
.projectionExpression("pk, sk"));
assertThat(resp.items()).isNotEmpty();
for (Map<String, AttributeValue> item : resp.items()) {
assertThat(item).containsKeys("pk", "sk");
assertThat(item).doesNotContainKey("name");
}
}
@Test @Order(33)
void batchGetItemWithProjectionReturnsOnlyProjectedAttributes() {
BatchGetItemResponse resp = ddb.batchGetItem(r -> r
.requestItems(Map.of(TABLE, KeysAndAttributes.builder()
.keys(Map.of("pk", av("p1"), "sk", av("s0")))
.projectionExpression("#n")
.expressionAttributeNames(Map.of("#n", "name"))
.build())));
List<Map<String, AttributeValue>> items = resp.responses().get(TABLE);
assertThat(items).isNotEmpty();
assertThat(items.get(0)).containsKey("name");
assertThat(items.get(0)).doesNotContainKey("count");
}
// -----------------------------------------------------------------------
// Phase 3 — Batch limits and empty-key rejection
// -----------------------------------------------------------------------
@Test @Order(40)
void batchWriteItemRejectsMoreThan25Items() {
List<WriteRequest> writes = new ArrayList<>();
for (int i = 0; i < 26; i++) {
writes.add(WriteRequest.builder()
.putRequest(PutRequest.builder()
.item(Map.of("pk", av("excess-" + i), "sk", av("s")))
.build())
.build());
}
assertThatThrownBy(() -> ddb.batchWriteItem(r -> r.requestItems(Map.of(TABLE, writes))))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("ValidationException"));
}
@Test @Order(41)
void batchGetItemRejectsMoreThan100Keys() {
List<Map<String, AttributeValue>> keys = new ArrayList<>();
for (int i = 0; i < 101; i++) {
keys.add(Map.of("pk", av("k-" + i), "sk", av("s")));
}
assertThatThrownBy(() -> ddb.batchGetItem(r -> r
.requestItems(Map.of(TABLE, KeysAndAttributes.builder().keys(keys).build()))))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("ValidationException"));
}
@Test @Order(42)
void putItemRejectsEmptyStringPrimaryKey() {
assertThatThrownBy(() -> ddb.putItem(r -> r
.tableName(TABLE)
.item(Map.of("pk", av(""), "sk", av("s1")))))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("ValidationException"));
}
@Test @Order(43)
void transactWriteItemsRejectsDuplicateKeys() {
assertThatThrownBy(() -> ddb.transactWriteItems(r -> r
.transactItems(
TransactWriteItem.builder().put(Put.builder()
.tableName(TABLE)
.item(Map.of("pk", av("dup"), "sk", av("s1")))
.build()).build(),
TransactWriteItem.builder().put(Put.builder()
.tableName(TABLE)
.item(Map.of("pk", av("dup"), "sk", av("s1")))
.build()).build())))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("ValidationException"));
}
@Test @Order(44)
void transactWriteItemsIdempotencyTokenRejectsConflict() {
String token = UUID.randomUUID().toString();
// First request succeeds
ddb.transactWriteItems(r -> r
.clientRequestToken(token)
.transactItems(TransactWriteItem.builder()
.put(Put.builder()
.tableName(TABLE)
.item(Map.of("pk", av("idem-test"), "sk", av("s")))
.build())
.build()));
// Same token, different payload → IdempotentParameterMismatchException
assertThatThrownBy(() -> ddb.transactWriteItems(r -> r
.clientRequestToken(token)
.transactItems(TransactWriteItem.builder()
.put(Put.builder()
.tableName(TABLE)
.item(Map.of("pk", av("idem-different"), "sk", av("s")))
.build())
.build())))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("IdempotentParameterMismatchException"));
}
@Test @Order(45)
void returnItemCollectionMetricsOnLsiTable() {
PutItemResponse resp = ddb.putItem(r -> r
.tableName(TABLE_LSI)
.item(Map.of(
"pk", av("m1"),
"sk", av("sk-a"),
"gsi_sk", av("g1")))
.returnItemCollectionMetrics(ReturnItemCollectionMetrics.SIZE));
assertThat(resp.itemCollectionMetrics()).isNotNull();
assertThat(resp.itemCollectionMetrics().sizeEstimateRangeGB()).hasSize(2);
}
// -----------------------------------------------------------------------
// Phase 4 — Select=COUNT, parallel scan, attribute_type(), parenthesized key condition
// -----------------------------------------------------------------------
@Test @Order(50)
void querySelectCountReturnsCountNotItems() {
QueryResponse resp = ddb.query(r -> r
.tableName(TABLE)
.keyConditionExpression("pk = :pk")
.expressionAttributeValues(Map.of(":pk", av("p1")))
.select(Select.COUNT));
assertThat(resp.count()).isEqualTo(5);
assertThat(resp.items()).isEmpty();
}
@Test @Order(51)
void scanSelectCountReturnsCountNotItems() {
ScanResponse resp = ddb.scan(r -> r
.tableName(TABLE)
.filterExpression("pk = :pk")
.expressionAttributeValues(Map.of(":pk", av("p1")))
.select(Select.COUNT));
assertThat(resp.count()).isGreaterThan(0);
assertThat(resp.items()).isEmpty();
}
@Test @Order(52)
void parallelScanPartitionsResultsCorrectly() {
int totalSegments = 2;
List<Map<String, AttributeValue>> segment0 = ddb.scan(r -> r
.tableName(TABLE)
.segment(0).totalSegments(totalSegments)).items();
List<Map<String, AttributeValue>> segment1 = ddb.scan(r -> r
.tableName(TABLE)
.segment(1).totalSegments(totalSegments)).items();
// Key each item by pk#sk to handle multiple items sharing the same sk
Set<String> keys0 = new HashSet<>(), keys1 = new HashSet<>();
segment0.forEach(i -> keys0.add(i.get("pk").s() + "#" + i.get("sk").s()));
segment1.forEach(i -> keys1.add(i.get("pk").s() + "#" + i.get("sk").s()));
// No overlap between segments
assertThat(keys0).doesNotContainAnyElementsOf(keys1);
// Union covers all items in the table
int totalCount = ddb.scan(r -> r.tableName(TABLE)).count();
Set<String> combined = new HashSet<>(keys0);
combined.addAll(keys1);
assertThat(combined).hasSize(totalCount);
}
@Test @Order(53)
void parallelScanRequiresBothSegmentAndTotalSegments() {
assertThatThrownBy(() -> ddb.scan(r -> r.tableName(TABLE).segment(0)))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("ValidationException"));
}
@Test @Order(54)
void queryWithAttributeTypeFunction() {
QueryResponse resp = ddb.query(r -> r
.tableName(TABLE)
.keyConditionExpression("pk = :pk")
.filterExpression("attribute_type(#n, :t)")
.expressionAttributeValues(Map.of(
":pk", av("p1"),
":t", av("S")))
.expressionAttributeNames(Map.of("#n", "name")));
assertThat(resp.items()).hasSize(5);
}
@Test @Order(55)
void queryWithParenthesizedKeyCondition() {
QueryResponse resp = ddb.query(r -> r
.tableName(TABLE)
.keyConditionExpression("(pk = :pk AND sk = :sk)")
.expressionAttributeValues(Map.of(":pk", av("p1"), ":sk", av("s0"))));
assertThat(resp.items()).hasSize(1);
assertThat(resp.items().get(0).get("sk").s()).isEqualTo("s0");
}
@Test @Order(56)
void queryRequiresKeyCondition() {
assertThatThrownBy(() -> ddb.query(r -> r.tableName(TABLE)))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("ValidationException"));
}
// -----------------------------------------------------------------------
// Phase 7 — Tags: ARN validation and non-existent ARN behaviour
// -----------------------------------------------------------------------
@Test @Order(60)
void tagResourceWithInvalidArnThrowsValidationException() {
assertThatThrownBy(() -> ddb.tagResource(r -> r
.resourceArn("not-an-arn")
.tags(Tag.builder().key("k").value("v").build())))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("ValidationException"));
}
@Test @Order(61)
void listTagsOfResourceWithValidFormatNonExistentArnThrowsAccessDeniedException() {
assertThatThrownBy(() -> ddb.listTagsOfResource(r -> r
.resourceArn("arn:aws:dynamodb:us-east-1:000000000000:table/does-not-exist")))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("AccessDeniedException"));
}
// -----------------------------------------------------------------------
// Phase 8 — ReturnValuesOnConditionCheckFailure
// -----------------------------------------------------------------------
@Test @Order(70)
void putItemConditionFailureReturnsOldItemWhenRequested() {
// Seed an existing item
ddb.putItem(r -> r.tableName(TABLE).item(Map.of(
"pk", av("cond-test"), "sk", av("s"), "val", av("old"))));
DynamoDbException ex = (DynamoDbException) catchThrowable(() ->
ddb.putItem(r -> r
.tableName(TABLE)
.item(Map.of("pk", av("cond-test"), "sk", av("s"), "val", av("new")))
.conditionExpression("attribute_not_exists(pk)")
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)));
assertThat(ex).isNotNull();
assertThat(ex.awsErrorDetails().errorCode()).isEqualTo("ConditionalCheckFailedException");
}
@Test @Order(71)
void updateItemConditionFailureReturnsOldItemWhenRequested() {
ddb.putItem(r -> r.tableName(TABLE).item(Map.of(
"pk", av("cond-upd"), "sk", av("s"), "val", av("original"))));
DynamoDbException ex = (DynamoDbException) catchThrowable(() ->
ddb.updateItem(r -> r
.tableName(TABLE)
.key(Map.of("pk", av("cond-upd"), "sk", av("s")))
.updateExpression("SET val = :new")
.conditionExpression("val = :expected")
.expressionAttributeValues(Map.of(":new", av("new"), ":expected", av("wrong")))
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)));
assertThat(ex).isNotNull();
assertThat(ex.awsErrorDetails().errorCode()).isEqualTo("ConditionalCheckFailedException");
}
// -----------------------------------------------------------------------
// Phase 10 — Legacy API: AttributesToGet, AttributeUpdates, QueryFilter, ScanFilter
// -----------------------------------------------------------------------
@Test @Order(80)
void getItemAttributesToGet() {
GetItemResponse resp = ddb.getItem(r -> r
.tableName(TABLE)
.key(Map.of("pk", av("p1"), "sk", av("s0")))
.attributesToGet("name", "count"));
assertThat(resp.item()).containsKeys("name", "count");
assertThat(resp.item()).doesNotContainKey("status");
// AttributesToGet does NOT auto-include key attributes
assertThat(resp.item()).doesNotContainKey("pk");
}
@Test @Order(81)
void attributesToGetAndProjectionExpressionAreMutuallyExclusive() {
assertThatThrownBy(() -> ddb.getItem(r -> r
.tableName(TABLE)
.key(Map.of("pk", av("p1"), "sk", av("s0")))
.attributesToGet("name")
.projectionExpression("name")))
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("ValidationException"));
}
@Test @Order(82)
@SuppressWarnings("deprecation")
void updateItemWithAttributeUpdates() {
ddb.putItem(r -> r.tableName(TABLE).item(Map.of(
"pk", av("legacy-upd"), "sk", av("s"), "color", av("red"))));
ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE)
.key(Map.of("pk", av("legacy-upd"), "sk", av("s")))
.attributeUpdates(Map.of(
"color", AttributeValueUpdate.builder()
.value(av("blue"))
.action(AttributeAction.PUT)
.build()))
.build());
GetItemResponse resp = ddb.getItem(r -> r
.tableName(TABLE)
.key(Map.of("pk", av("legacy-upd"), "sk", av("s"))));
assertThat(resp.item().get("color").s()).isEqualTo("blue");
}
@Test @Order(83)
@SuppressWarnings("deprecation")
void queryWithQueryFilter() {
QueryResponse resp = ddb.query(QueryRequest.builder()
.tableName(TABLE)
.keyConditions(Map.of("pk", Condition.builder()
.comparisonOperator(ComparisonOperator.EQ)
.attributeValueList(av("p1"))
.build()))
.queryFilter(Map.of("name", Condition.builder()
.comparisonOperator(ComparisonOperator.EQ)
.attributeValueList(av("Item-0"))
.build()))
.build());
assertThat(resp.items()).hasSize(1);
assertThat(resp.items().get(0).get("name").s()).isEqualTo("Item-0");
}
@Test @Order(84)
@SuppressWarnings("deprecation")
void scanWithScanFilter() {
ScanResponse resp = ddb.scan(ScanRequest.builder()
.tableName(TABLE)
.scanFilter(Map.of("name", Condition.builder()
.comparisonOperator(ComparisonOperator.EQ)
.attributeValueList(av("Item-2"))
.build()))
.build());
assertThat(resp.items()).hasSize(1);
assertThat(resp.items().get(0).get("name").s()).isEqualTo("Item-2");
}
// -----------------------------------------------------------------------
// Phase 10 — Legacy API: Expected (issue #848)
// -----------------------------------------------------------------------
@Test @Order(85)
@SuppressWarnings("deprecation")
void updateItemLegacyExpectedAttributeMissing() {
// Item does not exist; expecting a specific attribute value should fail
assertThatThrownBy(() -> ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-missing"), "sk", av("s")))
.attributeUpdates(Map.of(
"val", AttributeValueUpdate.builder().value(av("updated")).action(AttributeAction.PUT).build()))
.expected(Map.of("val", ExpectedAttributeValue.builder()
.comparisonOperator(ComparisonOperator.EQ)
.attributeValueList(av("original"))
.build()))
.build()))
.isInstanceOf(ConditionalCheckFailedException.class);
}
@Test @Order(86)
@SuppressWarnings("deprecation")
void updateItemLegacyExpectedValueMismatch() {
ddb.putItem(r -> r.tableName(TABLE).item(Map.of(
"pk", av("legacy-exp-mismatch"), "sk", av("s"),
"count", AttributeValue.builder().n("2").build())));
assertThatThrownBy(() -> ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-mismatch"), "sk", av("s")))
.attributeUpdates(Map.of(
"count", AttributeValueUpdate.builder()
.value(AttributeValue.builder().n("99").build())
.action(AttributeAction.PUT)
.build()))
.expected(Map.of("count", ExpectedAttributeValue.builder()
.comparisonOperator(ComparisonOperator.EQ)
.attributeValueList(AttributeValue.builder().n("1").build())
.build()))
.build()))
.isInstanceOf(ConditionalCheckFailedException.class);
// Verify item was not updated
GetItemResponse resp = ddb.getItem(r -> r
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-mismatch"), "sk", av("s"))));
assertThat(resp.item().get("count").n()).isEqualTo("2");
}
@Test @Order(87)
@SuppressWarnings("deprecation")
void updateItemLegacyExpectedMatchSucceeds() {
ddb.putItem(r -> r.tableName(TABLE).item(Map.of(
"pk", av("legacy-exp-match"), "sk", av("s"),
"count", AttributeValue.builder().n("1").build())));
ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-match"), "sk", av("s")))
.attributeUpdates(Map.of(
"count", AttributeValueUpdate.builder()
.value(AttributeValue.builder().n("99").build())
.action(AttributeAction.PUT)
.build()))
.expected(Map.of("count", ExpectedAttributeValue.builder()
.comparisonOperator(ComparisonOperator.EQ)
.attributeValueList(AttributeValue.builder().n("1").build())
.build()))
.build());
GetItemResponse resp = ddb.getItem(r -> r
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-match"), "sk", av("s"))));
assertThat(resp.item().get("count").n()).isEqualTo("99");
}
// -----------------------------------------------------------------------
// Phase 10 — Legacy API: Expected with Exists
// -----------------------------------------------------------------------
@Test @Order(88)
@SuppressWarnings("deprecation")
void legacyExpectedExistsTrueComparesValue() {
// Version-guarded write pattern: Exists:true is paired with the version the caller
// last read. Both must be honoured — the attribute must exist AND still equal that value.
ddb.putItem(r -> r.tableName(TABLE).item(Map.of(
"pk", av("legacy-exp-version"), "sk", av("s"),
"version", AttributeValue.builder().n("5").build(),
"session", av("original"))));
Map<String, ExpectedAttributeValue> expectVersion5 = Map.of("version",
ExpectedAttributeValue.builder()
.exists(true)
.value(AttributeValue.builder().n("5").build())
.build());
// The expectation is current → the write lands and bumps the version to 6.
ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-version"), "sk", av("s")))
.attributeUpdates(Map.of("version", AttributeValueUpdate.builder()
.value(AttributeValue.builder().n("6").build())
.action(AttributeAction.PUT)
.build()))
.expected(expectVersion5)
.build());
// The same expectation is now stale (stored version is 6) → must be rejected.
assertThatThrownBy(() -> ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-version"), "sk", av("s")))
.attributeUpdates(Map.of("session", AttributeValueUpdate.builder()
.action(AttributeAction.DELETE)
.build()))
.expected(expectVersion5)
.build()))
.isInstanceOf(ConditionalCheckFailedException.class);
// PutItem and DeleteItem route through the same evaluation and must agree.
assertThatThrownBy(() -> ddb.putItem(r -> r
.tableName(TABLE)
.item(Map.of("pk", av("legacy-exp-version"), "sk", av("s"),
"version", AttributeValue.builder().n("7").build()))
.expected(expectVersion5)))
.isInstanceOf(ConditionalCheckFailedException.class);
assertThatThrownBy(() -> ddb.deleteItem(r -> r
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-version"), "sk", av("s")))
.expected(expectVersion5)))
.isInstanceOf(ConditionalCheckFailedException.class);
// None of the stale writes took effect.
GetItemResponse resp = ddb.getItem(r -> r
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-version"), "sk", av("s"))));
assertThat(resp.item().get("version").n()).isEqualTo("6");
assertThat(resp.item().get("session").s()).isEqualTo("original");
// Exists:false still means "attribute must be absent".
assertThatThrownBy(() -> ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-version"), "sk", av("s")))
.attributeUpdates(Map.of("note", AttributeValueUpdate.builder()
.value(av("x")).action(AttributeAction.PUT).build()))
.expected(Map.of("version", ExpectedAttributeValue.builder().exists(false).build()))
.build()))
.isInstanceOf(ConditionalCheckFailedException.class);
}
@Test @Order(89)
@SuppressWarnings("deprecation")
void legacyExpectedRejectsInvalidExistsCombinations() {
// Exists:true with no Value — nothing to compare against.
DynamoDbException noValue = (DynamoDbException) catchThrowable(() -> ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-invalid"), "sk", av("s")))
.attributeUpdates(Map.of("note", AttributeValueUpdate.builder()
.value(av("x")).action(AttributeAction.PUT).build()))
.expected(Map.of("version", ExpectedAttributeValue.builder().exists(true).build()))
.build()));
assertThat(noValue).isNotNull();
assertThat(noValue.awsErrorDetails().errorCode()).isEqualTo("ValidationException");
// Exists:false alongside a Value — contradictory.
DynamoDbException falseWithValue = (DynamoDbException) catchThrowable(() -> ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-invalid"), "sk", av("s")))
.attributeUpdates(Map.of("note", AttributeValueUpdate.builder()
.value(av("x")).action(AttributeAction.PUT).build()))
.expected(Map.of("version", ExpectedAttributeValue.builder()
.exists(false)
.value(AttributeValue.builder().n("5").build())
.build()))
.build()));
assertThat(falseWithValue).isNotNull();
assertThat(falseWithValue.awsErrorDetails().errorCode()).isEqualTo("ValidationException");
// Exists mixed with the ComparisonOperator form.
DynamoDbException mixedForms = (DynamoDbException) catchThrowable(() -> ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE)
.key(Map.of("pk", av("legacy-exp-invalid"), "sk", av("s")))
.attributeUpdates(Map.of("note", AttributeValueUpdate.builder()
.value(av("x")).action(AttributeAction.PUT).build()))
.expected(Map.of("version", ExpectedAttributeValue.builder()
.exists(true)
.comparisonOperator(ComparisonOperator.EQ)
.attributeValueList(AttributeValue.builder().n("5").build())
.build()))
.build()));
assertThat(mixedForms).isNotNull();
assertThat(mixedForms.awsErrorDetails().errorCode()).isEqualTo("ValidationException");
}
// -----------------------------------------------------------------------
// Phase 11 — Enum validation fires before table lookup
// -----------------------------------------------------------------------
@Test @Order(90)
void enumValidationFiresBeforeTableLookupOnPutItem() {
DynamoDbException ex = (DynamoDbException) catchThrowable(() ->
ddb.putItem(r -> r
.tableName("nonexistent-table-xyz")
.item(Map.of("pk", av("x")))
.returnValues(ReturnValue.UPDATED_NEW))); // invalid for PutItem
assertThat(ex).isNotNull();
assertThat(ex.awsErrorDetails().errorCode()).isEqualTo("ValidationException");
assertThat(ex.awsErrorDetails().errorMessage()).doesNotContain("ResourceNotFoundException");
}
@Test @Order(91)
void enumValidationFiresBeforeTableLookupOnDeleteItem() {
DynamoDbException ex = (DynamoDbException) catchThrowable(() ->
ddb.deleteItem(r -> r
.tableName("nonexistent-table-xyz")
.key(Map.of("pk", av("x"), "sk", av("y")))
.returnValues(ReturnValue.UPDATED_NEW))); // invalid for DeleteItem
assertThat(ex).isNotNull();
assertThat(ex.awsErrorDetails().errorCode()).isEqualTo("ValidationException");
}
// -----------------------------------------------------------------------
// Phase 9 — Reserved word rejection in expressions
// -----------------------------------------------------------------------
@Test @Order(100)
void reservedWordAsAttributeNameInConditionThrowsValidationException() {
assertThatThrownBy(() -> ddb.putItem(r -> r
.tableName(TABLE)
.item(Map.of("pk", av("rw-test"), "sk", av("s")))
.conditionExpression("attribute_not_exists(status)"))) // 'status' is a bare reserved word
.isInstanceOf(DynamoDbException.class)
.satisfies(e -> assertThat(((DynamoDbException) e).awsErrorDetails().errorCode())
.isEqualTo("ValidationException"));
}
@Test @Order(101)
void reservedWordWithAliasPasses() {
// Using the #st alias should work fine. The alias must be referenced by the
// expression: AWS rejects ExpressionAttributeNames entries left unused.
assertThatCode(() -> ddb.putItem(r -> r
.tableName(TABLE)
.item(Map.of("pk", av("rw-alias"), "sk", av("s"), "status", av("ok")))
.conditionExpression("attribute_not_exists(#st)")
.expressionAttributeNames(Map.of("#st", "status"))))
.doesNotThrowAnyException();
}
// Reproduces #1604
@Test
@Order(102)
void transactWriteCancellationReasonNullMessageForNonFailedItems() {
ddb.putItem(r -> r.tableName(TABLE).item(Map.of("pk", av("cancel-A"), "sk", av("s"))));
TransactionCanceledException ex = (TransactionCanceledException) catchThrowable(() ->
ddb.transactWriteItems(TransactWriteItemsRequest.builder()
.transactItems(
TransactWriteItem.builder().conditionCheck(ConditionCheck.builder()
.tableName(TABLE)
.key(Map.of("pk", av("cancel-A"), "sk", av("s")))
.conditionExpression("attribute_exists(pk)")
.build()).build(),
TransactWriteItem.builder().conditionCheck(ConditionCheck.builder()
.tableName(TABLE)
.key(Map.of("pk", av("cancel-B"), "sk", av("s")))
.conditionExpression("attribute_exists(pk)")
.build()).build())
.build()));
assertThat(ex.cancellationReasons()).hasSize(2);
assertThat(ex.cancellationReasons().get(0).code()).isEqualTo("None");
assertThat(ex.cancellationReasons().get(0).message()).isNull();
assertThat(ex.cancellationReasons().get(1).code()).isEqualTo("ConditionalCheckFailed");
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private static AttributeValue av(String s) {
return AttributeValue.builder().s(s).build();
}
}
@@ -0,0 +1,190 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest;
import static org.assertj.core.api.Assertions.*;
/**
* Tests for the DynamoDB Enhanced Client against Floci.
*
*/
@DisplayName("DynamoDB Enhanced Client")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DynamoDbEnhancedClientTest {
private static DynamoDbClient dynamoDbClient;
private static DynamoDbEnhancedClient enhancedClient;
private static DynamoDbTable<UserData> userTable;
private static final String TABLE_NAME = "Users";
@BeforeAll
static void setUp() {
dynamoDbClient = TestFixtures.dynamoDbClient();
enhancedClient = DynamoDbEnhancedClient.builder()
.dynamoDbClient(dynamoDbClient)
.build();
userTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserData.class));
userTable.createTable();
}
@AfterAll
static void cleanUp() {
if (dynamoDbClient != null) {
try {
dynamoDbClient.deleteTable(DeleteTableRequest.builder().tableName(TABLE_NAME).build());
} catch (Exception ignored) {}
dynamoDbClient.close();
}
}
/**
* Test updating a nullable boolean from null to true.
* This mimics the Kotlin scenario for optional, nullable, variables in a class like: var useLoyaltyPoints: Boolean? = null
*
* The Enhanced Client generates: SET field1 = :val1, ..., boolField = :boolVal REMOVE nullField1, ...
* The bug was that boolField would not be set because the parser incorrectly
* included "REMOVE nullField1" in the value lookup.
*/
@Test
@Order(1)
void testUpdateNullableBooleanFromNullToTrue() {
String userId = "user-" + System.currentTimeMillis();
// Step 1: Create user WITHOUT the boolean field set
UserData user = new UserData();
user.setUserId(userId);
user.setEntries("initial entries");
long createdTimestampInMillis = 1234567890L;
user.setCreated(createdTimestampInMillis);
// isActive is null (not set)
user.setTempField("temporary data");
userTable.putItem(user);
// Verify initial state
UserData initial = userTable.getItem(r -> r.key(k -> k.partitionValue(userId)));
assertThat(initial).isNotNull();
assertThat(initial.getIsActive()).isNull();
assertThat(initial.getTempField()).isEqualTo("temporary data");
assertThat(initial.getCreated()).isEqualTo(createdTimestampInMillis);
// Step 2: Update user - set boolean to true and other fields
// The Enhanced Client will generate a SET clause with multiple fields
// followed by a REMOVE clause for null fields
user.setIsActive(true); // Set boolean to true
user.setEntries("updated entries");
user.setTempField(null); // This will be REMOVED
user.setCreated(null); // This will be REMOVED
userTable.updateItem(user);
// Step 3: Get the item and verify the boolean was set correctly
UserData updated = userTable.getItem(r -> r.key(k -> k.partitionValue(userId)));
assertThat(updated).isNotNull();
assertThat(updated.getIsActive())
.as("isActive should be true after update")
.isTrue();
assertThat(updated.getEntries()).isEqualTo("updated entries");
assertThat(updated.getTempField()).isNull();
assertThat(updated.getCreated()).isNull();
}
/**
* Test updating a boolean from false to true with Enhanced Client.
*/
@Test
@Order(2)
void testUpdateBooleanFromFalseToTrue() {
String userId = "user-" + System.currentTimeMillis();
// Step 1: Create user WITH boolean = false
UserData user = new UserData();
user.setUserId(userId);
user.setEntries("initial");
user.setIsActive(false);
user.setTempField("temp");
userTable.putItem(user);
// Verify initial state
UserData initial = userTable.getItem(r -> r.key(k -> k.partitionValue(userId)));
assertThat(initial.getIsActive()).isFalse();
// Step 2: Update boolean to true
user.setIsActive(true);
user.setEntries("updated");
user.setTempField(null); // Will be REMOVED
userTable.updateItem(user);
// Step 3: Verify the boolean changed to true
UserData updated = userTable.getItem(r -> r.key(k -> k.partitionValue(userId)));
assertThat(updated).isNotNull();
assertThat(updated.getIsActive())
.as("isActive should be true after update from false")
.isTrue();
assertThat(updated.getEntries()).isEqualTo("updated");
assertThat(updated.getTempField()).isNull();
}
@DynamoDbBean
public static class UserData {
private String userId;
private String entries;
private Long created;
private Boolean isActive;
private String tempField;
@DynamoDbPartitionKey
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getEntries() {
return entries;
}
public void setEntries(String entries) {
this.entries = entries;
}
public Long getCreated() {
return created;
}
public void setCreated(Long created) {
this.created = created;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public String getTempField() {
return tempField;
}
public void setTempField(String tempField) {
this.tempField = tempField;
}
}
}
@@ -0,0 +1,239 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import software.amazon.awssdk.services.dynamodb.waiters.DynamoDbWaiter;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.*;
import java.io.ByteArrayInputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.zip.GZIPInputStream;
import static org.assertj.core.api.Assertions.*;
@DisplayName("DynamoDB Export to S3")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DynamoDbExportTest {
private static DynamoDbClient ddb;
private static S3Client s3;
private static final String TABLE_NAME = "sdk-export-test-table";
private static final String BUCKET_NAME = "sdk-export-test-bucket";
private static String tableArn;
private static String exportArn;
@BeforeAll
static void setup() {
ddb = TestFixtures.dynamoDbClient();
s3 = TestFixtures.s3Client();
// Create S3 bucket
try {
s3.createBucket(CreateBucketRequest.builder().bucket(BUCKET_NAME).build());
} catch (BucketAlreadyExistsException | BucketAlreadyOwnedByYouException ignored) {}
// Create DynamoDB table
CreateTableResponse tableResp = ddb.createTable(CreateTableRequest.builder()
.tableName(TABLE_NAME)
.keySchema(
KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build(),
KeySchemaElement.builder().attributeName("sk").keyType(KeyType.RANGE).build())
.attributeDefinitions(
AttributeDefinition.builder().attributeName("pk")
.attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder().attributeName("sk")
.attributeType(ScalarAttributeType.S).build())
.billingMode(BillingMode.PAY_PER_REQUEST)
.build());
tableArn = tableResp.tableDescription().tableArn();
// Insert 3 items
ddb.putItem(PutItemRequest.builder().tableName(TABLE_NAME)
.item(Map.of(
"pk", AttributeValue.fromS("user-1"),
"sk", AttributeValue.fromS("order-001"),
"total", AttributeValue.fromN("99")))
.build());
ddb.putItem(PutItemRequest.builder().tableName(TABLE_NAME)
.item(Map.of(
"pk", AttributeValue.fromS("user-2"),
"sk", AttributeValue.fromS("order-002"),
"total", AttributeValue.fromN("55")))
.build());
ddb.putItem(PutItemRequest.builder().tableName(TABLE_NAME)
.item(Map.of(
"pk", AttributeValue.fromS("user-3"),
"sk", AttributeValue.fromS("order-003"),
"total", AttributeValue.fromN("150")))
.build());
}
@AfterAll
static void cleanup() {
try {
ddb.deleteTable(DeleteTableRequest.builder().tableName(TABLE_NAME).build());
} catch (Exception ignored) {}
try {
ListObjectsV2Response objects = s3.listObjectsV2(
ListObjectsV2Request.builder().bucket(BUCKET_NAME).build());
for (S3Object obj : objects.contents()) {
s3.deleteObject(DeleteObjectRequest.builder()
.bucket(BUCKET_NAME).key(obj.key()).build());
}
s3.deleteBucket(DeleteBucketRequest.builder().bucket(BUCKET_NAME).build());
} catch (Exception ignored) {}
if (ddb != null) ddb.close();
if (s3 != null) s3.close();
}
@Test
@Order(1)
void exportTableToPointInTime_returnsInProgress() {
ExportTableToPointInTimeResponse resp = ddb.exportTableToPointInTime(
ExportTableToPointInTimeRequest.builder()
.tableArn(tableArn)
.s3Bucket(BUCKET_NAME)
.s3Prefix("exports")
.exportFormat(ExportFormat.DYNAMODB_JSON)
.build());
ExportDescription desc = resp.exportDescription();
assertThat(desc.exportArn()).isNotBlank();
assertThat(desc.exportStatus()).isEqualTo(ExportStatus.IN_PROGRESS);
assertThat(desc.tableArn()).isEqualTo(tableArn);
assertThat(desc.s3Bucket()).isEqualTo(BUCKET_NAME);
assertThat(desc.exportFormat()).isEqualTo(ExportFormat.DYNAMODB_JSON);
assertThat(desc.exportType()).isEqualTo(ExportType.FULL_EXPORT);
exportArn = desc.exportArn();
}
@Test
@Order(2)
void waitUntilExportCompleted_completesSuccessfully() {
assertThat(exportArn).isNotNull();
try (DynamoDbWaiter waiter = DynamoDbWaiter.builder().client(ddb).build()) {
waiter.waitUntilExportCompleted(r -> r.exportArn(exportArn));
}
}
@Test
@Order(3)
void describeExport_returnsCompletedExportWithAllFields() {
assertThat(exportArn).isNotNull();
DescribeExportResponse resp = ddb.describeExport(
DescribeExportRequest.builder().exportArn(exportArn).build());
ExportDescription desc = resp.exportDescription();
assertThat(desc.exportStatus()).isEqualTo(ExportStatus.COMPLETED);
assertThat(desc.tableArn()).isEqualTo(tableArn);
assertThat(desc.s3Bucket()).isEqualTo(BUCKET_NAME);
assertThat(desc.itemCount()).isEqualTo(3L);
assertThat(desc.billedSizeBytes()).isGreaterThan(0L);
assertThat(desc.exportManifest()).isNotBlank();
assertThat(desc.startTime()).isNotNull();
assertThat(desc.endTime()).isNotNull();
}
@Test
@Order(4)
void listExports_byTableArn_returnsExport() {
assertThat(exportArn).isNotNull();
ListExportsResponse resp = ddb.listExports(
ListExportsRequest.builder().tableArn(tableArn).build());
assertThat(resp.exportSummaries()).isNotEmpty();
assertThat(resp.exportSummaries().stream()
.anyMatch(s -> exportArn.equals(s.exportArn())))
.isTrue();
ExportSummary summary = resp.exportSummaries().stream()
.filter(s -> exportArn.equals(s.exportArn()))
.findFirst().orElseThrow();
assertThat(summary.exportStatus()).isEqualTo(ExportStatus.COMPLETED);
assertThat(summary.exportType()).isEqualTo(ExportType.FULL_EXPORT);
}
@Test
@Order(5)
void s3Objects_manifestAndDataExist() throws Exception {
assertThat(exportArn).isNotNull();
DescribeExportResponse descResp = ddb.describeExport(
DescribeExportRequest.builder().exportArn(exportArn).build());
String manifestSummaryKey = descResp.exportDescription().exportManifest();
assertThat(manifestSummaryKey).isNotBlank();
// Verify manifest-summary.json exists
ResponseBytes<GetObjectResponse> manifestSummary = s3.getObjectAsBytes(
GetObjectRequest.builder().bucket(BUCKET_NAME).key(manifestSummaryKey).build());
assertThat(manifestSummary.asByteArray().length).isGreaterThan(0);
String exportId = exportArn.substring(exportArn.lastIndexOf('/') + 1);
String manifestFilesKey = "exports/AWSDynamoDB/" + exportId + "/manifest-files.json";
ResponseBytes<GetObjectResponse> manifestFiles = s3.getObjectAsBytes(
GetObjectRequest.builder().bucket(BUCKET_NAME).key(manifestFilesKey).build());
String dataKey = new String(manifestFiles.asByteArray(), StandardCharsets.UTF_8).trim();
assertThat(dataKey).endsWith(".json.gz");
// Download and decompress the data file
ResponseBytes<GetObjectResponse> dataFile = s3.getObjectAsBytes(
GetObjectRequest.builder().bucket(BUCKET_NAME).key(dataKey).build());
String ndjson = decompressGzip(dataFile.asByteArray());
String[] lines = ndjson.split("\n");
assertThat(lines).hasSize(3);
for (String line : lines) {
assertThat(line).contains("\"Item\"");
assertThat(line).contains("\"pk\"");
}
}
@Test
@Order(6)
void exportTableToPointInTime_invalidExportType_throwsValidationException() {
assertThatThrownBy(() -> ddb.exportTableToPointInTime(
ExportTableToPointInTimeRequest.builder()
.tableArn(tableArn)
.s3Bucket(BUCKET_NAME)
.exportType(ExportType.INCREMENTAL_EXPORT)
.build()))
.isInstanceOf(software.amazon.awssdk.services.dynamodb.model.DynamoDbException.class)
.hasMessageContaining("not supported");
}
@Test
@Order(7)
void describeExport_notFound_throwsExportNotFoundException() {
assertThatThrownBy(() -> ddb.describeExport(
DescribeExportRequest.builder()
.exportArn("arn:aws:dynamodb:us-east-1:000000000000:table/T/export/doesnotexist")
.build()))
.isInstanceOf(software.amazon.awssdk.services.dynamodb.model.DynamoDbException.class);
}
private String decompressGzip(byte[] data) throws Exception {
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(data));
BufferedReader reader = new BufferedReader(new InputStreamReader(gzip, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
if (sb.length() > 0) sb.append('\n');
sb.append(line);
}
return sb.toString();
}
}
}
@@ -0,0 +1,407 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
/**
* Compatibility tests for DynamoDB expression evaluation:
* - Filter expressions with BOOL, IN, OR, NOT, nested parens
* - Dotted paths in UpdateExpression SET/REMOVE
* - ConsumedCapacity in responses
* - Parenthesized BETWEEN in KeyConditionExpression
*/
@DisplayName("DynamoDB Expression & Capacity Tests")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DynamoDbExpressionTests {
private static DynamoDbClient ddb;
private static final String FILTER_TABLE = "expr-filter-test";
private static final String BETWEEN_TABLE = "expr-between-test";
@BeforeAll
static void setup() {
ddb = TestFixtures.dynamoDbClient();
// Table for filter expression tests (hash-only)
ddb.createTable(CreateTableRequest.builder()
.tableName(FILTER_TABLE)
.keySchema(KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build())
.attributeDefinitions(AttributeDefinition.builder().attributeName("pk").attributeType(ScalarAttributeType.S).build())
.billingMode(BillingMode.PAY_PER_REQUEST)
.build());
// Table for BETWEEN / key-condition tests (hash + range)
ddb.createTable(CreateTableRequest.builder()
.tableName(BETWEEN_TABLE)
.keySchema(
KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build(),
KeySchemaElement.builder().attributeName("sk").keyType(KeyType.RANGE).build())
.attributeDefinitions(
AttributeDefinition.builder().attributeName("pk").attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder().attributeName("sk").attributeType(ScalarAttributeType.S).build())
.billingMode(BillingMode.PAY_PER_REQUEST)
.build());
// Seed filter table
ddb.putItem(PutItemRequest.builder().tableName(FILTER_TABLE).item(Map.of(
"pk", AttributeValue.fromS("u1"),
"deleted", AttributeValue.fromBool(false),
"status", AttributeValue.fromN("1"),
"category", AttributeValue.fromS("A")
)).build());
ddb.putItem(PutItemRequest.builder().tableName(FILTER_TABLE).item(Map.of(
"pk", AttributeValue.fromS("u2"),
"deleted", AttributeValue.fromBool(true),
"status", AttributeValue.fromN("2"),
"category", AttributeValue.fromS("B")
)).build());
ddb.putItem(PutItemRequest.builder().tableName(FILTER_TABLE).item(Map.of(
"pk", AttributeValue.fromS("u3"),
"deleted", AttributeValue.fromBool(false),
"status", AttributeValue.fromN("1"),
"category", AttributeValue.fromS("A")
)).build());
// u4 has no "deleted" attribute
ddb.putItem(PutItemRequest.builder().tableName(FILTER_TABLE).item(Map.of(
"pk", AttributeValue.fromS("u4"),
"status", AttributeValue.fromN("3"),
"category", AttributeValue.fromS("C")
)).build());
// Seed between table
for (String sk : new String[]{"2026-01-01T00:00:00Z#a", "2026-06-15T00:00:00Z#b", "2026-12-31T00:00:00Z#c"}) {
ddb.putItem(PutItemRequest.builder().tableName(BETWEEN_TABLE).item(Map.of(
"pk", AttributeValue.fromS("r1"),
"sk", AttributeValue.fromS(sk)
)).build());
}
}
@AfterAll
static void cleanup() {
if (ddb != null) {
try { ddb.deleteTable(DeleteTableRequest.builder().tableName(FILTER_TABLE).build()); } catch (Exception ignored) {}
try { ddb.deleteTable(DeleteTableRequest.builder().tableName(BETWEEN_TABLE).build()); } catch (Exception ignored) {}
ddb.close();
}
}
// ---- BOOL comparison ----
@Test
@Order(1)
@DisplayName("Filter: BOOL not-equal excludes deleted items")
void filterBoolNotEqual() {
ScanResponse resp = ddb.scan(ScanRequest.builder()
.tableName(FILTER_TABLE)
.filterExpression("deleted <> :d")
.expressionAttributeValues(Map.of(":d", AttributeValue.fromBool(true)))
.build());
// u1 (false), u3 (false), u4 (missing → <> true is true)
assertThat(resp.count()).isEqualTo(3);
}
@Test
@Order(2)
@DisplayName("Filter: BOOL equal matches false")
void filterBoolEqual() {
ScanResponse resp = ddb.scan(ScanRequest.builder()
.tableName(FILTER_TABLE)
.filterExpression("deleted = :d")
.expressionAttributeValues(Map.of(":d", AttributeValue.fromBool(false)))
.build());
assertThat(resp.count()).isEqualTo(2);
}
// ---- IN operator ----
@Test
@Order(3)
@DisplayName("Filter: IN operator with single value")
void filterInSingle() {
ScanResponse resp = ddb.scan(ScanRequest.builder()
.tableName(FILTER_TABLE)
.filterExpression("#s IN (:v0)")
.expressionAttributeNames(Map.of("#s", "status"))
.expressionAttributeValues(Map.of(":v0", AttributeValue.fromN("1")))
.build());
assertThat(resp.count()).isEqualTo(2);
}
@Test
@Order(4)
@DisplayName("Filter: IN operator with multiple values")
void filterInMultiple() {
ScanResponse resp = ddb.scan(ScanRequest.builder()
.tableName(FILTER_TABLE)
.filterExpression("#s IN (:v0, :v1)")
.expressionAttributeNames(Map.of("#s", "status"))
.expressionAttributeValues(Map.of(
":v0", AttributeValue.fromN("1"),
":v1", AttributeValue.fromN("3")))
.build());
assertThat(resp.count()).isEqualTo(3);
}
// ---- OR operator ----
@Test
@Order(5)
@DisplayName("Filter: OR operator")
void filterOr() {
ScanResponse resp = ddb.scan(ScanRequest.builder()
.tableName(FILTER_TABLE)
.filterExpression("#s = :v1 OR #s = :v2")
.expressionAttributeNames(Map.of("#s", "status"))
.expressionAttributeValues(Map.of(
":v1", AttributeValue.fromN("1"),
":v2", AttributeValue.fromN("2")))
.build());
assertThat(resp.count()).isEqualTo(3);
}
// ---- NOT operator ----
@Test
@Order(6)
@DisplayName("Filter: NOT operator")
void filterNot() {
ScanResponse resp = ddb.scan(ScanRequest.builder()
.tableName(FILTER_TABLE)
.filterExpression("NOT deleted = :d")
.expressionAttributeValues(Map.of(":d", AttributeValue.fromBool(true)))
.build());
assertThat(resp.count()).isEqualTo(3);
}
// ---- Nested parentheses with AND + OR ----
@Test
@Order(7)
@DisplayName("Filter: parenthesized AND + OR")
void filterParenthesizedAndOr() {
ScanResponse resp = ddb.scan(ScanRequest.builder()
.tableName(FILTER_TABLE)
.filterExpression("(#s = :v1 OR #s = :v3) AND category = :catA")
.expressionAttributeNames(Map.of("#s", "status"))
.expressionAttributeValues(Map.of(
":v1", AttributeValue.fromN("1"),
":v3", AttributeValue.fromN("3"),
":catA", AttributeValue.fromS("A")))
.build());
assertThat(resp.count()).isEqualTo(2);
}
// ---- Dotted path in UpdateExpression ----
@Test
@Order(8)
@DisplayName("UpdateExpression: SET with dotted nested path")
void updateDottedPath() {
// Put an item with a nested map
String pk = "dotted-test";
ddb.putItem(PutItemRequest.builder()
.tableName(FILTER_TABLE)
.item(Map.of(
"pk", AttributeValue.fromS(pk),
"details", AttributeValue.builder().m(Map.of(
"name", AttributeValue.fromS("original")
)).build()))
.build());
// Update nested attribute via dotted path
ddb.updateItem(UpdateItemRequest.builder()
.tableName(FILTER_TABLE)
.key(Map.of("pk", AttributeValue.fromS(pk)))
.updateExpression("SET details.#sub = :val")
.expressionAttributeNames(Map.of("#sub", "name"))
.expressionAttributeValues(Map.of(":val", AttributeValue.fromS("updated")))
.build());
GetItemResponse get = ddb.getItem(GetItemRequest.builder()
.tableName(FILTER_TABLE)
.key(Map.of("pk", AttributeValue.fromS(pk)))
.build());
assertThat(get.item().get("details").m().get("name").s()).isEqualTo("updated");
// Clean up
ddb.deleteItem(DeleteItemRequest.builder()
.tableName(FILTER_TABLE)
.key(Map.of("pk", AttributeValue.fromS(pk)))
.build());
}
// ---- ConsumedCapacity ----
@Test
@Order(9)
@DisplayName("ConsumedCapacity: TOTAL returns capacity in Scan")
void consumedCapacityTotal() {
ScanResponse resp = ddb.scan(ScanRequest.builder()
.tableName(FILTER_TABLE)
.returnConsumedCapacity(ReturnConsumedCapacity.TOTAL)
.build());
assertThat(resp.consumedCapacity()).isNotNull();
assertThat(resp.consumedCapacity().tableName()).isEqualTo(FILTER_TABLE);
assertThat(resp.consumedCapacity().capacityUnits()).isGreaterThan(0);
}
@Test
@Order(10)
@DisplayName("ConsumedCapacity: NONE omits capacity")
void consumedCapacityNone() {
ScanResponse resp = ddb.scan(ScanRequest.builder()
.tableName(FILTER_TABLE)
.returnConsumedCapacity(ReturnConsumedCapacity.NONE)
.build());
assertThat(resp.consumedCapacity()).isNull();
}
@Test
@Order(11)
@DisplayName("ConsumedCapacity: TOTAL in GetItem")
void consumedCapacityGetItem() {
GetItemResponse resp = ddb.getItem(GetItemRequest.builder()
.tableName(FILTER_TABLE)
.key(Map.of("pk", AttributeValue.fromS("u1")))
.returnConsumedCapacity(ReturnConsumedCapacity.TOTAL)
.build());
assertThat(resp.consumedCapacity()).isNotNull();
assertThat(resp.consumedCapacity().capacityUnits()).isGreaterThan(0);
}
@Test
@Order(12)
@DisplayName("ConsumedCapacity: TOTAL in PutItem")
void consumedCapacityPutItem() {
PutItemResponse resp = ddb.putItem(PutItemRequest.builder()
.tableName(FILTER_TABLE)
.item(Map.of(
"pk", AttributeValue.fromS("cap-test"),
"data", AttributeValue.fromS("v")))
.returnConsumedCapacity(ReturnConsumedCapacity.TOTAL)
.build());
assertThat(resp.consumedCapacity()).isNotNull();
assertThat(resp.consumedCapacity().capacityUnits()).isGreaterThan(0);
// Clean up
ddb.deleteItem(DeleteItemRequest.builder()
.tableName(FILTER_TABLE)
.key(Map.of("pk", AttributeValue.fromS("cap-test")))
.build());
}
// ---- Parenthesized BETWEEN in KeyConditionExpression ----
@Test
@Order(13)
@DisplayName("Query: parenthesized BETWEEN in KeyConditionExpression")
void queryParenthesizedBetween() {
QueryResponse resp = ddb.query(QueryRequest.builder()
.tableName(BETWEEN_TABLE)
.keyConditionExpression("pk = :pk AND (sk BETWEEN :start AND :end)")
.expressionAttributeValues(Map.of(
":pk", AttributeValue.fromS("r1"),
":start", AttributeValue.fromS("2026-01-01T00:00:00Z#"),
":end", AttributeValue.fromS("2026-12-31T23:59:59Z#")))
.build());
assertThat(resp.count()).isEqualTo(3);
}
// ---- SET arithmetic ----
@Test
@Order(14)
@DisplayName("UpdateExpression: SET with if_not_exists + arithmetic increment")
void updateSetIfNotExistsPlusIncrement() {
String table = "expr-arith-test";
ddb.createTable(CreateTableRequest.builder()
.tableName(table)
.keySchema(KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build())
.attributeDefinitions(AttributeDefinition.builder().attributeName("pk").attributeType(ScalarAttributeType.S).build())
.billingMode(BillingMode.PAY_PER_REQUEST)
.build());
try {
// First increment on non-existent item: if_not_exists(counter, 0) + 1 = 1
ddb.updateItem(UpdateItemRequest.builder()
.tableName(table)
.key(Map.of("pk", AttributeValue.fromS("k1")))
.updateExpression("SET #cnt = if_not_exists(#cnt, :start) + :inc")
.expressionAttributeNames(Map.of("#cnt", "counter"))
.expressionAttributeValues(Map.of(
":start", AttributeValue.builder().n("0").build(),
":inc", AttributeValue.builder().n("1").build()))
.build());
GetItemResponse r1 = ddb.getItem(GetItemRequest.builder()
.tableName(table)
.key(Map.of("pk", AttributeValue.fromS("k1")))
.build());
assertThat(r1.item().get("counter").n()).isEqualTo("1");
// Second increment: existing (1) + 1 = 2
ddb.updateItem(UpdateItemRequest.builder()
.tableName(table)
.key(Map.of("pk", AttributeValue.fromS("k1")))
.updateExpression("SET #cnt = if_not_exists(#cnt, :start) + :inc")
.expressionAttributeNames(Map.of("#cnt", "counter"))
.expressionAttributeValues(Map.of(
":start", AttributeValue.builder().n("0").build(),
":inc", AttributeValue.builder().n("1").build()))
.build());
GetItemResponse r2 = ddb.getItem(GetItemRequest.builder()
.tableName(table)
.key(Map.of("pk", AttributeValue.fromS("k1")))
.build());
assertThat(r2.item().get("counter").n()).isEqualTo("2");
// Subtraction: existing (2) - 1 = 1
ddb.updateItem(UpdateItemRequest.builder()
.tableName(table)
.key(Map.of("pk", AttributeValue.fromS("k1")))
.updateExpression("SET #cnt = #cnt - :dec")
.expressionAttributeNames(Map.of("#cnt", "counter"))
.expressionAttributeValues(Map.of(
":dec", AttributeValue.builder().n("1").build()))
.build());
GetItemResponse r3 = ddb.getItem(GetItemRequest.builder()
.tableName(table)
.key(Map.of("pk", AttributeValue.fromS("k1")))
.build());
assertThat(r3.item().get("counter").n()).isEqualTo("1");
} finally {
ddb.deleteTable(DeleteTableRequest.builder().tableName(table).build());
}
}
@Test
@Order(15)
@DisplayName("Query: compact format BETWEEN — no spaces around AND")
void queryCompactBetween() {
QueryResponse resp = ddb.query(QueryRequest.builder()
.tableName(BETWEEN_TABLE)
.keyConditionExpression("(#f0 = :v0)AND(#f1 BETWEEN :v1 AND :v2)")
.expressionAttributeNames(Map.of("#f0", "pk", "#f1", "sk"))
.expressionAttributeValues(Map.of(
":v0", AttributeValue.fromS("r1"),
":v1", AttributeValue.fromS("2026-01-01T00:00:00Z#"),
":v2", AttributeValue.fromS("2026-12-31T23:59:59Z#z")))
.build());
assertThat(resp.count()).isEqualTo(3);
}
}
@@ -0,0 +1,157 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.*;
import static org.assertj.core.api.Assertions.*;
@DisplayName("DynamoDB Import from S3")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DynamoDbImportTest {
private static DynamoDbClient ddb;
private static S3Client s3;
private static final String TABLE_NAME = "sdk-import-test-table";
private static final String BUCKET_NAME = "sdk-import-test-bucket";
private static String importArn;
@BeforeAll
static void setup() {
ddb = TestFixtures.dynamoDbClient();
s3 = TestFixtures.s3Client();
try {
s3.createBucket(CreateBucketRequest.builder().bucket(BUCKET_NAME).build());
} catch (BucketAlreadyExistsException | BucketAlreadyOwnedByYouException ignored) {}
s3.putObject(PutObjectRequest.builder().bucket(BUCKET_NAME).key("imp/data.json").build(),
RequestBody.fromString("{\"Item\":{\"pk\":{\"S\":\"imported1\"}}}\n{\"Item\":{\"pk\":{\"S\":\"imported2\"}}}\n"));
}
@AfterAll
static void cleanup() {
try {
ddb.deleteTable(DeleteTableRequest.builder().tableName(TABLE_NAME).build());
} catch (Exception ignored) {}
try {
var objects = s3.listObjectsV2(ListObjectsV2Request.builder().bucket(BUCKET_NAME).build());
for (var obj : objects.contents()) {
s3.deleteObject(DeleteObjectRequest.builder()
.bucket(BUCKET_NAME).key(obj.key()).build());
}
s3.deleteBucket(DeleteBucketRequest.builder().bucket(BUCKET_NAME).build());
} catch (Exception ignored) {}
if (ddb != null) {
ddb.close();
}
if (s3 != null) {
s3.close();
}
}
private static ImportTableDescription describeImport() {
return ddb.describeImport(DescribeImportRequest.builder().importArn(importArn).build())
.importTableDescription();
}
private static ImportTableRequest importRequest() {
return ImportTableRequest.builder()
.s3BucketSource(S3BucketSource.builder().s3Bucket(BUCKET_NAME).s3KeyPrefix("imp/").build())
.inputFormat(InputFormat.DYNAMODB_JSON)
.inputCompressionType(InputCompressionType.NONE)
.tableCreationParameters(TableCreationParameters.builder()
.tableName(TABLE_NAME)
.attributeDefinitions(AttributeDefinition.builder()
.attributeName("pk").attributeType(ScalarAttributeType.S).build())
.keySchema(KeySchemaElement.builder()
.attributeName("pk").keyType(KeyType.HASH).build())
.billingMode(BillingMode.PAY_PER_REQUEST)
.build())
.build();
}
@Test
@Order(1)
void importTable_returnsInProgress() {
var resp = ddb.importTable(importRequest());
var desc = resp.importTableDescription();
assertThat(desc.importArn()).contains("/import/");
assertThat(desc.importStatus()).isEqualTo(ImportStatus.IN_PROGRESS);
assertThat(desc.tableArn()).endsWith(":table/" + TABLE_NAME);
assertThat(desc.s3BucketSource().s3Bucket()).isEqualTo(BUCKET_NAME);
assertThat(desc.s3BucketSource().s3KeyPrefix()).isEqualTo("imp/");
assertThat(desc.inputFormat()).isEqualTo(InputFormat.DYNAMODB_JSON);
assertThat(desc.inputCompressionType()).isEqualTo(InputCompressionType.NONE);
assertThat(desc.tableCreationParameters().tableName()).isEqualTo(TABLE_NAME);
assertThat(desc.startTime()).isNotNull();
importArn = desc.importArn();
}
@Test
@Order(2)
void listImports_containsImport() {
assertThat(importArn).isNotNull();
var resp = ddb.listImports(ListImportsRequest.builder().build());
assertThat(resp.importSummaryList())
.extracting(ImportSummary::importArn)
.contains(importArn);
}
@Test
@Order(3)
void describeImport_reachesCompleted() throws Exception {
assertThat(importArn).isNotNull();
var desc = describeImport();
var deadline = System.currentTimeMillis() + 10_000;
while (desc.importStatus() == ImportStatus.IN_PROGRESS && System.currentTimeMillis() < deadline) {
Thread.sleep(100);
desc = describeImport();
}
assertThat(desc.importStatus())
.as("%s: %s", desc.failureCode(), desc.failureMessage())
.isEqualTo(ImportStatus.COMPLETED);
assertThat(desc.processedItemCount()).isEqualTo(2L);
assertThat(desc.importedItemCount()).isEqualTo(2L);
assertThat(desc.errorCount()).isZero();
assertThat(desc.endTime()).isNotNull();
}
@Test
@Order(4)
void importedTable_isActiveAndHoldsTheItems() {
var table = ddb.describeTable(DescribeTableRequest.builder().tableName(TABLE_NAME).build());
assertThat(table.table().tableStatus()).isEqualTo(TableStatus.ACTIVE);
var scan = ddb.scan(ScanRequest.builder().tableName(TABLE_NAME).build());
assertThat(scan.count()).isEqualTo(2);
assertThat(scan.items())
.extracting(item -> item.get("pk").s())
.containsExactlyInAnyOrder("imported1", "imported2");
}
@Test
@Order(5)
void importTable_existingTable_throwsResourceInUseException() {
assertThatThrownBy(() -> ddb.importTable(importRequest()))
.isInstanceOf(ResourceInUseException.class);
}
@Test
@Order(6)
void describeImport_notFound_throwsImportNotFoundException() {
assertThatThrownBy(() -> ddb.describeImport(DescribeImportRequest.builder()
.importArn("arn:aws:dynamodb:us-east-1:000000000000:table/T/import/doesnotexist")
.build()))
.isInstanceOf(ImportNotFoundException.class);
}
}
@@ -0,0 +1,368 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
@DisplayName("DynamoDB PartiQL")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DynamoDbPartiQLTest {
private static DynamoDbClient ddb;
private static final String TABLE = "partiql-test-table";
private static final String INDEX_TABLE = "partiql-index-table";
@BeforeAll
static void setup() {
ddb = TestFixtures.dynamoDbClient();
try {
ddb.deleteTable(DeleteTableRequest.builder().tableName(TABLE).build());
} catch (Exception ignored) {}
ddb.createTable(CreateTableRequest.builder()
.tableName(TABLE)
.keySchema(
KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build(),
KeySchemaElement.builder().attributeName("sk").keyType(KeyType.RANGE).build()
)
.attributeDefinitions(
AttributeDefinition.builder().attributeName("pk").attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder().attributeName("sk").attributeType(ScalarAttributeType.S).build()
)
.billingMode(BillingMode.PAY_PER_REQUEST)
.build());
try {
ddb.deleteTable(DeleteTableRequest.builder().tableName(INDEX_TABLE).build());
} catch (Exception ignored) {}
ddb.createTable(CreateTableRequest.builder()
.tableName(INDEX_TABLE)
.keySchema(
KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build(),
KeySchemaElement.builder().attributeName("sk").keyType(KeyType.RANGE).build()
)
.attributeDefinitions(
AttributeDefinition.builder().attributeName("pk").attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder().attributeName("sk").attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder().attributeName("status").attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder().attributeName("createdAt").attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder().attributeName("alternate").attributeType(ScalarAttributeType.S).build()
)
.globalSecondaryIndexes(GlobalSecondaryIndex.builder()
.indexName("status-index")
.keySchema(
KeySchemaElement.builder().attributeName("status").keyType(KeyType.HASH).build(),
KeySchemaElement.builder().attributeName("createdAt").keyType(KeyType.RANGE).build()
)
.projection(Projection.builder().projectionType(ProjectionType.ALL).build())
.build())
.localSecondaryIndexes(LocalSecondaryIndex.builder()
.indexName("alternate-index")
.keySchema(
KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build(),
KeySchemaElement.builder().attributeName("alternate").keyType(KeyType.RANGE).build()
)
.projection(Projection.builder().projectionType(ProjectionType.ALL).build())
.build())
.billingMode(BillingMode.PAY_PER_REQUEST)
.build());
ddb.putItem(PutItemRequest.builder()
.tableName(INDEX_TABLE)
.item(Map.of(
"pk", AttributeValue.builder().s("idxpk").build(),
"sk", AttributeValue.builder().s("s1").build(),
"status", AttributeValue.builder().s("active").build(),
"createdAt", AttributeValue.builder().s("2026-01-01").build(),
"alternate", AttributeValue.builder().s("alt-1").build()))
.build());
ddb.putItem(PutItemRequest.builder()
.tableName(INDEX_TABLE)
.item(Map.of(
"pk", AttributeValue.builder().s("idxpk").build(),
"sk", AttributeValue.builder().s("s2").build(),
"status", AttributeValue.builder().s("active").build(),
"createdAt", AttributeValue.builder().s("2026-01-02").build(),
"alternate", AttributeValue.builder().s("alt-2").build()))
.build());
}
@AfterAll
static void cleanup() {
if (ddb != null) {
try {
ddb.deleteTable(DeleteTableRequest.builder().tableName(TABLE).build());
} catch (Exception ignored) {}
try {
ddb.deleteTable(DeleteTableRequest.builder().tableName(INDEX_TABLE).build());
} catch (Exception ignored) {}
ddb.close();
}
}
@Test
@Order(1)
void insertViaPartiQL() {
ExecuteStatementResponse resp = ddb.executeStatement(r -> r
.statement("INSERT INTO \"" + TABLE + "\" VALUE {'pk': 'pk1', 'sk': 'sk1', 'name': 'Alice', 'age': 30}"));
assertThat(resp.items()).isEmpty();
}
@Test
@Order(2)
void selectByPkOnly() {
ExecuteStatementResponse resp = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + TABLE + "\" WHERE pk = 'pk1' AND sk = 'sk1'"));
assertThat(resp.items()).hasSize(1);
assertThat(resp.items().get(0).get("name").s()).isEqualTo("Alice");
}
@Test
@Order(3)
void selectWithProjection() {
ExecuteStatementResponse resp = ddb.executeStatement(r -> r
.statement("SELECT name FROM \"" + TABLE + "\" WHERE pk = 'pk1' AND sk = 'sk1'"));
assertThat(resp.items()).hasSize(1);
Map<String, AttributeValue> item = resp.items().get(0);
assertThat(item).containsKey("name");
assertThat(item).doesNotContainKey("age");
}
@Test
@Order(4)
void insertDuplicateThrows() {
assertThatThrownBy(() -> ddb.executeStatement(r -> r
.statement("INSERT INTO \"" + TABLE + "\" VALUE {'pk': 'pk1', 'sk': 'sk1', 'name': 'Bob'}")))
.isInstanceOf(DynamoDbException.class)
.hasMessageContaining("Duplicate");
}
@Test
@Order(5)
void updateSetAttribute() {
ddb.executeStatement(r -> r
.statement("UPDATE \"" + TABLE + "\" SET name = 'Charlie' WHERE pk = 'pk1' AND sk = 'sk1'"));
ExecuteStatementResponse verify = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + TABLE + "\" WHERE pk = 'pk1' AND sk = 'sk1'"));
assertThat(verify.items().get(0).get("name").s()).isEqualTo("Charlie");
}
@Test
@Order(6)
void updateRemoveAttribute() {
ddb.executeStatement(r -> r
.statement("UPDATE \"" + TABLE + "\" REMOVE age WHERE pk = 'pk1' AND sk = 'sk1'"));
ExecuteStatementResponse verify = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + TABLE + "\" WHERE pk = 'pk1' AND sk = 'sk1'"));
assertThat(verify.items().get(0).containsKey("age")).isFalse();
}
@Test
@Order(7)
void selectWithPlaceholders() {
ExecuteStatementResponse resp = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + TABLE + "\" WHERE pk = ? AND sk = ?")
.parameters(
AttributeValue.fromS("pk1"),
AttributeValue.fromS("sk1")
));
assertThat(resp.items()).hasSize(1);
}
@Test
@Order(8)
void insertMultipleItemsAndQueryRange() {
for (int i = 2; i <= 5; i++) {
final int idx = i;
ddb.executeStatement(r -> r
.statement("INSERT INTO \"" + TABLE + "\" VALUE {'pk': 'pk2', 'sk': 'sk" + idx + "', 'val': " + idx + "}"));
}
ExecuteStatementResponse resp = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + TABLE + "\" WHERE pk = 'pk2'"));
assertThat(resp.items()).hasSize(4);
}
@Test
@Order(9)
void selectWithBeginsWith() {
ExecuteStatementResponse resp = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + TABLE + "\" WHERE pk = 'pk2' AND begins_with(sk, 'sk')"));
assertThat(resp.items()).hasSize(4);
}
@Test
@Order(10)
void deleteItem() {
ddb.executeStatement(r -> r
.statement("DELETE FROM \"" + TABLE + "\" WHERE pk = 'pk1' AND sk = 'sk1'"));
ExecuteStatementResponse verify = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + TABLE + "\" WHERE pk = 'pk1' AND sk = 'sk1'"));
assertThat(verify.items()).isEmpty();
}
@Test
@Order(11)
void executeTransactionInsertAndDelete() {
// Insert a fresh item, then delete it in a single transaction
ddb.executeStatement(r -> r
.statement("INSERT INTO \"" + TABLE + "\" VALUE {'pk': 'txpk', 'sk': 'txsk1', 'data': 'x'}"));
ddb.executeStatement(r -> r
.statement("INSERT INTO \"" + TABLE + "\" VALUE {'pk': 'txpk', 'sk': 'txsk2', 'data': 'y'}"));
ExecuteTransactionResponse txResp = ddb.executeTransaction(r -> r
.transactStatements(
ParameterizedStatement.builder()
.statement("DELETE FROM \"" + TABLE + "\" WHERE pk = 'txpk' AND sk = 'txsk1'")
.build(),
ParameterizedStatement.builder()
.statement("UPDATE \"" + TABLE + "\" SET data = 'z' WHERE pk = 'txpk' AND sk = 'txsk2'")
.build()
));
assertThat(txResp.responses()).isEmpty();
ExecuteStatementResponse verify = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + TABLE + "\" WHERE pk = 'txpk' AND sk = 'txsk2'"));
assertThat(verify.items().get(0).get("data").s()).isEqualTo("z");
}
@Test
@Order(12)
void executeTransactionDuplicateInsertThrows() {
ddb.executeStatement(r -> r
.statement("INSERT INTO \"" + TABLE + "\" VALUE {'pk': 'conflict', 'sk': 'sk', 'v': 1}"));
assertThatThrownBy(() -> ddb.executeTransaction(r -> r
.transactStatements(
ParameterizedStatement.builder()
.statement("INSERT INTO \"" + TABLE + "\" VALUE {'pk': 'conflict', 'sk': 'sk', 'v': 2}")
.build()
)))
.isInstanceOf(TransactionCanceledException.class);
}
@Test
@Order(13)
void batchExecuteStatementMixedResults() {
ddb.executeStatement(r -> r
.statement("INSERT INTO \"" + TABLE + "\" VALUE {'pk': 'batchpk', 'sk': 'bsk1', 'hit': 1}"));
BatchExecuteStatementResponse resp = ddb.batchExecuteStatement(r -> r
.statements(
BatchStatementRequest.builder()
.statement("SELECT * FROM \"" + TABLE + "\" WHERE pk = 'batchpk' AND sk = 'bsk1'")
.build(),
BatchStatementRequest.builder()
.statement("SELECT * FROM \"" + TABLE + "\" WHERE pk = 'batchpk' AND sk = 'bsk-missing'")
.build()
));
assertThat(resp.responses()).hasSize(2);
assertThat(resp.responses().get(0).item()).containsKey("hit");
assertThat(resp.responses().get(1).item()).isEmpty();
}
@Test
@Order(14)
void executeStatementOnGsiRejectsConsistentRead() {
assertThatThrownBy(() -> ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + INDEX_TABLE + "\".\"status-index\"")
.consistentRead(true)))
.isInstanceOf(DynamoDbException.class)
.hasMessageContaining("Strongly consistent read is not supported on Global Secondary Indexes");
}
@Test
@Order(15)
void executeStatementReadsThroughGsi() {
// Equality on the index partition key routes through an index query.
ExecuteStatementResponse resp = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + INDEX_TABLE + "\".\"status-index\" WHERE status = 'active'"));
assertThat(resp.items()).hasSize(2);
// Without equality on the index partition key, the statement performs
// a full index scan and applies the remaining conditions as a filter.
ExecuteStatementResponse filtered = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + INDEX_TABLE + "\".\"status-index\" WHERE sk = 's2'"));
assertThat(filtered.items()).hasSize(1);
assertThat(filtered.items().get(0).get("sk").s()).isEqualTo("s2");
}
@Test
@Order(16)
void executeStatementOnLsiAcceptsConsistentRead() {
ExecuteStatementResponse resp = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + INDEX_TABLE + "\".\"alternate-index\" WHERE pk = 'idxpk'")
.consistentRead(true));
assertThat(resp.items()).hasSize(2);
}
@Test
@Order(17)
void executeStatementOnUnknownIndexFailsWithoutIndexName() {
// ExecuteStatement omits the index name from this message, unlike
// Query/Scan (characterised on real AWS, eu-west-1, 2026-09-02).
assertThatThrownBy(() -> ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + INDEX_TABLE + "\".\"no-such-index\"")))
.isInstanceOfSatisfying(DynamoDbException.class, e -> {
assertThat(e.awsErrorDetails().errorCode()).isEqualTo("ValidationException");
// ExecuteStatement omits the index name from this message,
// unlike Query/Scan (characterised on real AWS, eu-west-1,
// 2026-09-02).
assertThat(e.awsErrorDetails().errorMessage())
.isEqualTo("The table does not have the specified index");
});
}
@Test
@Order(18)
void batchExecuteStatementRejectsIndexQualifiedSelectPerSlot() {
BatchExecuteStatementResponse resp = ddb.batchExecuteStatement(r -> r
.statements(BatchStatementRequest.builder()
.statement("SELECT * FROM \"" + INDEX_TABLE + "\".\"status-index\" WHERE status = 'active'")
.build()));
assertThat(resp.responses()).hasSize(1);
assertThat(resp.responses().get(0).error().codeAsString()).isEqualTo("ValidationError");
assertThat(resp.responses().get(0).error().message()).isEqualTo(
"Select statements within BatchExecuteStatement must specify the primary key in the where clause.");
}
@Test
@Order(19)
void executeStatementRejectsForeignNextToken() {
ExecuteStatementResponse page = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + INDEX_TABLE + "\" WHERE pk = 'idxpk'")
.limit(1));
assertThat(page.nextToken()).isNotBlank();
// Characterised on real AWS (eu-west-1, 2026-09-03): the token is bound
// to the issuing statement, so replaying it on another statement or
// access path is rejected even when the target key schema matches.
assertThatThrownBy(() -> ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + INDEX_TABLE + "\".\"status-index\" WHERE status = 'active'")
.limit(1)
.nextToken(page.nextToken())))
.isInstanceOfSatisfying(DynamoDbException.class, e -> {
assertThat(e.awsErrorDetails().errorCode()).isEqualTo("ValidationException");
assertThat(e.awsErrorDetails().errorMessage())
.isEqualTo("NextToken does not match request");
});
}
@Test
@Order(20)
void executeStatementReadsEveryAttributeThroughAllProjectionIndex() {
// status-index projects ALL, so a qualified read returns attributes that
// belong to no key, and naming one explicitly stays legal.
ExecuteStatementResponse all = ddb.executeStatement(r -> r
.statement("SELECT * FROM \"" + INDEX_TABLE + "\".\"status-index\" WHERE status = 'active'"));
assertThat(all.items()).hasSize(2);
assertThat(all.items().get(0)).containsKey("alternate");
ExecuteStatementResponse explicit = ddb.executeStatement(r -> r
.statement("SELECT alternate FROM \"" + INDEX_TABLE + "\".\"status-index\" WHERE status = 'active'"));
assertThat(explicit.items()).hasSize(2);
assertThat(explicit.items().get(0).get("alternate").s()).startsWith("alt-");
}
}
@@ -0,0 +1,136 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
@DisplayName("DynamoDB Scan Condition Filters")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DynamoDbScanConditionTests {
private static DynamoDbClient ddb;
private static final String TABLE_NAME = "scan-condition-test";
@BeforeAll
static void setup() {
ddb = TestFixtures.dynamoDbClient();
ddb.createTable(CreateTableRequest.builder()
.tableName(TABLE_NAME)
.keySchema(KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build())
.attributeDefinitions(AttributeDefinition.builder().attributeName("pk").attributeType(ScalarAttributeType.S).build())
.billingMode(BillingMode.PAY_PER_REQUEST)
.build());
for (int i = 1; i <= 5; i++) {
ddb.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(Map.of(
"pk", AttributeValue.fromS("item-" + i),
"score", AttributeValue.fromN(String.valueOf(i * 10)),
"name", AttributeValue.fromS("name-" + i)
)).build());
}
}
@AfterAll
static void cleanup() {
if (ddb != null) {
try {
ddb.deleteTable(DeleteTableRequest.builder().tableName(TABLE_NAME).build());
} catch (Exception ignored) {}
ddb.close();
}
}
@Test
@Order(1)
@DisplayName("Scan with EQ filter")
void scanFilterEq() {
ScanResponse resp = ddb.scan(ScanRequest.builder().tableName(TABLE_NAME)
.scanFilter(Map.of("score", Condition.builder()
.comparisonOperator(ComparisonOperator.EQ)
.attributeValueList(AttributeValue.fromN("30"))
.build()))
.build());
assertThat(resp.count()).isEqualTo(1);
}
@Test
@Order(2)
@DisplayName("Scan with GT filter")
void scanFilterGt() {
ScanResponse resp = ddb.scan(ScanRequest.builder().tableName(TABLE_NAME)
.scanFilter(Map.of("score", Condition.builder()
.comparisonOperator(ComparisonOperator.GT)
.attributeValueList(AttributeValue.fromN("30"))
.build()))
.build());
assertThat(resp.count()).isEqualTo(2);
}
@Test
@Order(3)
@DisplayName("Scan with LE filter")
void scanFilterLe() {
ScanResponse resp = ddb.scan(ScanRequest.builder().tableName(TABLE_NAME)
.scanFilter(Map.of("score", Condition.builder()
.comparisonOperator(ComparisonOperator.LE)
.attributeValueList(AttributeValue.fromN("30"))
.build()))
.build());
assertThat(resp.count()).isEqualTo(3);
}
@Test
@Order(4)
@DisplayName("Scan with BEGINS_WITH filter")
void scanFilterBeginsWith() {
ScanResponse resp = ddb.scan(ScanRequest.builder().tableName(TABLE_NAME)
.scanFilter(Map.of("name", Condition.builder()
.comparisonOperator(ComparisonOperator.BEGINS_WITH)
.attributeValueList(AttributeValue.fromS("name-"))
.build()))
.build());
assertThat(resp.count()).isEqualTo(5);
}
@Test
@Order(5)
@DisplayName("Scan with BETWEEN filter")
void scanFilterBetween() {
ScanResponse resp = ddb.scan(ScanRequest.builder().tableName(TABLE_NAME)
.scanFilter(Map.of("score", Condition.builder()
.comparisonOperator(ComparisonOperator.BETWEEN)
.attributeValueList(AttributeValue.fromN("20"), AttributeValue.fromN("40"))
.build()))
.build());
assertThat(resp.count()).isEqualTo(3);
}
@Test
@Order(6)
@DisplayName("Scan with multiple conditions (AND)")
void scanFilterMultipleConditions() {
ScanResponse resp = ddb.scan(ScanRequest.builder().tableName(TABLE_NAME)
.scanFilter(Map.of(
"score", Condition.builder()
.comparisonOperator(ComparisonOperator.GE)
.attributeValueList(AttributeValue.fromN("30"))
.build(),
"name", Condition.builder()
.comparisonOperator(ComparisonOperator.EQ)
.attributeValueList(AttributeValue.fromS("name-3"))
.build()))
.build());
assertThat(resp.count()).isEqualTo(1);
}
}
@@ -0,0 +1,391 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.BatchGetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.BatchGetItemResponse;
import software.amazon.awssdk.services.dynamodb.model.BatchWriteItemRequest;
import software.amazon.awssdk.services.dynamodb.model.CreateReplicationGroupMemberAction;
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
import software.amazon.awssdk.services.dynamodb.model.CreateTableResponse;
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
import software.amazon.awssdk.services.dynamodb.model.DeleteReplicationGroupMemberAction;
import software.amazon.awssdk.services.dynamodb.model.DeleteRequest;
import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest;
import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest;
import software.amazon.awssdk.services.dynamodb.model.DescribeTableResponse;
import software.amazon.awssdk.services.dynamodb.model.DescribeTimeToLiveRequest;
import software.amazon.awssdk.services.dynamodb.model.DescribeTimeToLiveResponse;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
import software.amazon.awssdk.services.dynamodb.model.KeysAndAttributes;
import software.amazon.awssdk.services.dynamodb.model.KeyType;
import software.amazon.awssdk.services.dynamodb.model.ListTablesResponse;
import software.amazon.awssdk.services.dynamodb.model.ListTagsOfResourceRequest;
import software.amazon.awssdk.services.dynamodb.model.ListTagsOfResourceResponse;
import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput;
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
import software.amazon.awssdk.services.dynamodb.model.PutRequest;
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
import software.amazon.awssdk.services.dynamodb.model.QueryResponse;
import software.amazon.awssdk.services.dynamodb.model.ReplicationGroupUpdate;
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
import software.amazon.awssdk.services.dynamodb.model.ScanResponse;
import software.amazon.awssdk.services.dynamodb.model.TableStatus;
import software.amazon.awssdk.services.dynamodb.model.TagResourceRequest;
import software.amazon.awssdk.services.dynamodb.model.TimeToLiveStatus;
import software.amazon.awssdk.services.dynamodb.model.UntagResourceRequest;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse;
import software.amazon.awssdk.services.dynamodb.model.UpdateReplicationGroupMemberAction;
import software.amazon.awssdk.services.dynamodb.model.UpdateTableRequest;
import software.amazon.awssdk.services.dynamodb.model.UpdateTableResponse;
import software.amazon.awssdk.services.dynamodb.model.WriteRequest;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
@DisplayName("DynamoDB")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DynamoDbTest {
private static DynamoDbClient ddb;
private static final String TABLE_NAME = "sdk-test-table";
private static String tableArn;
@BeforeAll
static void setup() {
ddb = TestFixtures.dynamoDbClient();
}
@AfterAll
static void cleanup() {
if (ddb != null) {
try {
ddb.deleteTable(DeleteTableRequest.builder().tableName(TABLE_NAME).build());
} catch (Exception ignored) {}
ddb.close();
}
}
@Test
@Order(1)
void createTable() {
CreateTableResponse response = ddb.createTable(CreateTableRequest.builder()
.tableName(TABLE_NAME)
.keySchema(
KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build(),
KeySchemaElement.builder().attributeName("sk").keyType(KeyType.RANGE).build()
)
.attributeDefinitions(
AttributeDefinition.builder().attributeName("pk").attributeType(ScalarAttributeType.S).build(),
AttributeDefinition.builder().attributeName("sk").attributeType(ScalarAttributeType.S).build()
)
.provisionedThroughput(ProvisionedThroughput.builder()
.readCapacityUnits(5L).writeCapacityUnits(5L).build())
.build());
tableArn = response.tableDescription().tableArn();
assertThat(response.tableDescription().tableStatus()).isEqualTo(TableStatus.ACTIVE);
}
@Test
@Order(2)
void describeTable() {
DescribeTableResponse response = ddb.describeTable(
DescribeTableRequest.builder().tableName(TABLE_NAME).build());
assertThat(response.table().tableName()).isEqualTo(TABLE_NAME);
}
@Test
@Order(3)
void listTables() {
ListTablesResponse response = ddb.listTables();
assertThat(response.tableNames()).contains(TABLE_NAME);
}
@Test
@Order(4)
void putItem() {
for (int i = 1; i <= 3; i++) {
ddb.putItem(PutItemRequest.builder()
.tableName(TABLE_NAME)
.item(Map.of(
"pk", AttributeValue.builder().s("user-1").build(),
"sk", AttributeValue.builder().s("item-" + i).build(),
"data", AttributeValue.builder().s("value-" + i).build()
))
.build());
}
ddb.putItem(PutItemRequest.builder()
.tableName(TABLE_NAME)
.item(Map.of(
"pk", AttributeValue.builder().s("user-2").build(),
"sk", AttributeValue.builder().s("item-1").build(),
"data", AttributeValue.builder().s("other-value").build()
))
.build());
}
@Test
@Order(5)
void getItem() {
GetItemResponse response = ddb.getItem(GetItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of(
"pk", AttributeValue.builder().s("user-1").build(),
"sk", AttributeValue.builder().s("item-2").build()
))
.build());
assertThat(response.hasItem()).isTrue();
assertThat(response.item().get("data").s()).isEqualTo("value-2");
}
@Test
@Order(6)
void updateItem() {
UpdateItemResponse response = ddb.updateItem(UpdateItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of(
"pk", AttributeValue.builder().s("user-1").build(),
"sk", AttributeValue.builder().s("item-1").build()
))
.updateExpression("SET #d = :newVal")
.expressionAttributeNames(Map.of("#d", "data"))
.expressionAttributeValues(Map.of(
":newVal", AttributeValue.builder().s("updated-value").build()
))
.returnValues(ReturnValue.ALL_NEW)
.build());
assertThat(response.attributes().get("data").s()).isEqualTo("updated-value");
}
@Test
@Order(7)
void query() {
QueryResponse response = ddb.query(QueryRequest.builder()
.tableName(TABLE_NAME)
.keyConditionExpression("pk = :pk")
.expressionAttributeValues(Map.of(
":pk", AttributeValue.builder().s("user-1").build()
))
.build());
assertThat(response.count()).isEqualTo(3);
}
@Test
@Order(8)
void scan() {
ScanResponse response = ddb.scan(ScanRequest.builder()
.tableName(TABLE_NAME).build());
assertThat(response.count()).isEqualTo(4);
}
@Test
@Order(9)
void batchWriteItem() {
ddb.batchWriteItem(BatchWriteItemRequest.builder()
.requestItems(Map.of(TABLE_NAME, List.of(
WriteRequest.builder().putRequest(PutRequest.builder()
.item(Map.of(
"pk", AttributeValue.builder().s("user-3").build(),
"sk", AttributeValue.builder().s("item-1").build(),
"data", AttributeValue.builder().s("batch-value-1").build()
)).build()).build(),
WriteRequest.builder().putRequest(PutRequest.builder()
.item(Map.of(
"pk", AttributeValue.builder().s("user-3").build(),
"sk", AttributeValue.builder().s("item-2").build(),
"data", AttributeValue.builder().s("batch-value-2").build()
)).build()).build()
)))
.build());
ScanResponse scanResponse = ddb.scan(ScanRequest.builder().tableName(TABLE_NAME).build());
assertThat(scanResponse.count()).isEqualTo(6);
}
@Test
@Order(10)
void batchGetItem() {
BatchGetItemResponse response = ddb.batchGetItem(BatchGetItemRequest.builder()
.requestItems(Map.of(TABLE_NAME, KeysAndAttributes.builder()
.keys(List.of(
Map.of(
"pk", AttributeValue.builder().s("user-1").build(),
"sk", AttributeValue.builder().s("item-1").build()
),
Map.of(
"pk", AttributeValue.builder().s("user-3").build(),
"sk", AttributeValue.builder().s("item-2").build()
)
))
.build()))
.build());
assertThat(response.responses().get(TABLE_NAME)).hasSize(2);
}
@Test
@Order(11)
void updateTable() {
UpdateTableResponse response = ddb.updateTable(UpdateTableRequest.builder()
.tableName(TABLE_NAME)
.provisionedThroughput(ProvisionedThroughput.builder()
.readCapacityUnits(10L).writeCapacityUnits(10L).build())
.build());
assertThat(response.tableDescription().provisionedThroughput().readCapacityUnits())
.isEqualTo(10L);
}
@Test
@Order(12)
void updateTableReplicaLifecycle() {
String replicaRegion = "us-west-2";
UpdateTableResponse added = ddb.updateTable(UpdateTableRequest.builder()
.tableName(TABLE_NAME)
.replicaUpdates(ReplicationGroupUpdate.builder()
.create(CreateReplicationGroupMemberAction.builder().regionName(replicaRegion).build())
.build())
.build());
assertThat(added.tableDescription().replicas())
.anyMatch(replica -> replicaRegion.equals(replica.regionName())
&& "ACTIVE".equals(replica.replicaStatusAsString()));
assertThat(ddb.describeTable(DescribeTableRequest.builder().tableName(TABLE_NAME).build())
.table().replicas())
.anyMatch(replica -> replicaRegion.equals(replica.regionName()));
UpdateTableResponse updated = ddb.updateTable(UpdateTableRequest.builder()
.tableName(TABLE_NAME)
.replicaUpdates(ReplicationGroupUpdate.builder()
.update(UpdateReplicationGroupMemberAction.builder().regionName(replicaRegion).build())
.build())
.build());
assertThat(updated.tableDescription().replicas())
.anyMatch(replica -> replicaRegion.equals(replica.regionName())
&& "ACTIVE".equals(replica.replicaStatusAsString()));
UpdateTableResponse removed = ddb.updateTable(UpdateTableRequest.builder()
.tableName(TABLE_NAME)
.replicaUpdates(ReplicationGroupUpdate.builder()
.delete(DeleteReplicationGroupMemberAction.builder().regionName(replicaRegion).build())
.build())
.build());
assertThat(removed.tableDescription().replicas())
.noneMatch(replica -> replicaRegion.equals(replica.regionName()));
}
@Test
@Order(13)
void describeTimeToLive() {
DescribeTimeToLiveResponse response = ddb.describeTimeToLive(
DescribeTimeToLiveRequest.builder().tableName(TABLE_NAME).build());
assertThat(response.timeToLiveDescription().timeToLiveStatus())
.isEqualTo(TimeToLiveStatus.DISABLED);
}
@Test
@Order(14)
void tagResource() {
Assumptions.assumeTrue(tableArn != null);
ddb.tagResource(TagResourceRequest.builder()
.resourceArn(tableArn)
.tags(
software.amazon.awssdk.services.dynamodb.model.Tag.builder().key("env").value("test").build(),
software.amazon.awssdk.services.dynamodb.model.Tag.builder().key("team").value("backend").build()
)
.build());
}
@Test
@Order(15)
void listTagsOfResource() {
Assumptions.assumeTrue(tableArn != null);
ListTagsOfResourceResponse response = ddb.listTagsOfResource(
ListTagsOfResourceRequest.builder().resourceArn(tableArn).build());
assertThat(response.tags())
.anyMatch(t -> "env".equals(t.key()) && "test".equals(t.value()))
.anyMatch(t -> "team".equals(t.key()) && "backend".equals(t.value()));
}
@Test
@Order(16)
void untagResource() {
Assumptions.assumeTrue(tableArn != null);
ddb.untagResource(UntagResourceRequest.builder()
.resourceArn(tableArn).tagKeys("team").build());
ListTagsOfResourceResponse response = ddb.listTagsOfResource(
ListTagsOfResourceRequest.builder().resourceArn(tableArn).build());
assertThat(response.tags())
.anyMatch(t -> "env".equals(t.key()) && "test".equals(t.value()))
.noneMatch(t -> "team".equals(t.key()));
}
@Test
@Order(17)
void batchWriteItemDelete() {
ddb.batchWriteItem(BatchWriteItemRequest.builder()
.requestItems(Map.of(TABLE_NAME, List.of(
WriteRequest.builder().deleteRequest(DeleteRequest.builder()
.key(Map.of(
"pk", AttributeValue.builder().s("user-3").build(),
"sk", AttributeValue.builder().s("item-1").build()
)).build()).build(),
WriteRequest.builder().deleteRequest(DeleteRequest.builder()
.key(Map.of(
"pk", AttributeValue.builder().s("user-3").build(),
"sk", AttributeValue.builder().s("item-2").build()
)).build()).build()
)))
.build());
ScanResponse scanResponse = ddb.scan(ScanRequest.builder().tableName(TABLE_NAME).build());
assertThat(scanResponse.count()).isEqualTo(4);
}
@Test
@Order(18)
void deleteItem() {
ddb.deleteItem(DeleteItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of(
"pk", AttributeValue.builder().s("user-2").build(),
"sk", AttributeValue.builder().s("item-1").build()
))
.build());
}
@Test
@Order(19)
void deleteTable() {
ddb.deleteTable(DeleteTableRequest.builder().tableName(TABLE_NAME).build());
ListTablesResponse response = ddb.listTables();
assertThat(response.tableNames()).doesNotContain(TABLE_NAME);
}
}
@@ -0,0 +1,67 @@
package com.floci.test;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.ec2.Ec2Client;
import software.amazon.awssdk.services.ec2.model.Ec2Exception;
import software.amazon.awssdk.services.ec2.model.RequestLaunchTemplateData;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class Ec2DescribeLaunchTemplatesTest {
@Test
void missingNameCanTriggerCreateThenDescribe() {
String name = "sdk-describe-" + UUID.randomUUID();
try (Ec2Client ec2 = TestFixtures.ec2Client()) {
Ec2Exception error = assertThrows(Ec2Exception.class,
() -> ec2.describeLaunchTemplates(r -> r.launchTemplateNames(name)));
assertThat(error.statusCode()).isEqualTo(400);
assertThat(error.awsErrorDetails().errorCode())
.isEqualTo("InvalidLaunchTemplateName.NotFoundException");
String id = ec2.createLaunchTemplate(r -> r.launchTemplateName(name)
.launchTemplateData(RequestLaunchTemplateData.builder()
.imageId("ami-0abcdef1234567890").build()))
.launchTemplate().launchTemplateId();
try {
assertThat(ec2.describeLaunchTemplates(r -> r.launchTemplateNames(name)).launchTemplates())
.singleElement().satisfies(t -> assertThat(t.launchTemplateId()).isEqualTo(id));
assertThat(ec2.describeLaunchTemplates(r -> r.launchTemplateIds(id)).launchTemplates())
.singleElement().satisfies(t -> assertThat(t.launchTemplateName()).isEqualTo(name));
Ec2Exception mixed = assertThrows(Ec2Exception.class,
() -> ec2.describeLaunchTemplates(r -> r.launchTemplateNames(name, name + "-missing")));
assertThat(mixed.awsErrorDetails().errorCode())
.isEqualTo("InvalidLaunchTemplateName.NotFoundException");
Ec2Exception mixedIds = assertThrows(Ec2Exception.class,
() -> ec2.describeLaunchTemplates(r -> r.launchTemplateIds(id, "lt-00000000000000000")));
assertThat(mixedIds.awsErrorDetails().errorCode()).isEqualTo("InvalidLaunchTemplateId.NotFound");
assertThat(ec2.describeLaunchTemplates(r -> r.launchTemplateNames(name)
.filters(f -> f.name("launch-template-name").values(name + "-missing"))).launchTemplates())
.isEmpty();
} finally {
ec2.deleteLaunchTemplate(r -> r.launchTemplateId(id));
}
}
}
@Test
void missingIdIsAnSdkError() {
try (Ec2Client ec2 = TestFixtures.ec2Client()) {
Ec2Exception error = assertThrows(Ec2Exception.class,
() -> ec2.describeLaunchTemplates(r -> r.launchTemplateIds("lt-00000000000000000")));
assertThat(error.statusCode()).isEqualTo(400);
assertThat(error.awsErrorDetails().errorCode()).isEqualTo("InvalidLaunchTemplateId.NotFound");
}
}
@Test
void unmatchedFilterStillReturnsAnEmptyList() {
try (Ec2Client ec2 = TestFixtures.ec2Client()) {
assertThat(ec2.describeLaunchTemplates(r -> r.filters(f -> f.name("launch-template-name")
.values("missing-" + UUID.randomUUID()))).launchTemplates()).isEmpty();
}
}
}
@@ -0,0 +1,45 @@
package com.floci.test;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.ec2.Ec2Client;
import software.amazon.awssdk.services.ec2.model.Ec2Exception;
import software.amazon.awssdk.services.ec2.model.DescribeSpotPriceHistoryRequest;
import software.amazon.awssdk.services.ec2.model.InstanceType;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class Ec2DescribeSpotPriceHistoryTest {
@Test
void describeSpotPriceHistoryReturnsEmptyHistoryWhenNoSnapshotIsConfigured() {
try (Ec2Client ec2 = TestFixtures.ec2Client()) {
var response = ec2.describeSpotPriceHistory(DescribeSpotPriceHistoryRequest.builder()
.instanceTypes(InstanceType.M5_LARGE, InstanceType.fromValue("t4g.medium"))
.productDescriptions("Linux/UNIX")
.availabilityZone("us-east-1a")
.startTime(Instant.parse("2026-01-01T00:00:00Z"))
.endTime(Instant.parse("2026-01-02T00:00:00Z"))
.maxResults(100)
.build());
assertThat(response.spotPriceHistory()).isEmpty();
assertThat(response.nextToken()).isEmpty();
}
}
@Test
void describeSpotPriceHistoryHonorsDryRun() {
try (Ec2Client ec2 = TestFixtures.ec2Client()) {
Ec2Exception exception = assertThrows(Ec2Exception.class,
() -> ec2.describeSpotPriceHistory(DescribeSpotPriceHistoryRequest.builder()
.dryRun(true)
.build()));
assertThat(exception.statusCode()).isEqualTo(412);
assertThat(exception.awsErrorDetails().errorCode()).isEqualTo("DryRunOperation");
}
}
}
@@ -0,0 +1,78 @@
package com.floci.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import software.amazon.awssdk.services.ec2.Ec2Client;
import software.amazon.awssdk.services.ec2.model.InstanceType;
import software.amazon.awssdk.services.ec2.model.RequestLaunchTemplateData;
import software.amazon.awssdk.services.ec2.model.RunInstancesRequest;
import software.amazon.awssdk.services.iam.IamClient;
import java.util.UUID;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
class Ec2InstanceProfilePathTest {
static Stream<Arguments> launches() {
return Stream.of("direct", "template", "fleet", "override").flatMap(mode ->
Stream.of("/", "/workers/nested/").map(path -> Arguments.of(mode, path)));
}
@ParameterizedTest
@MethodSource("launches")
@DisplayName("EC2 launches resolve profile names to the full IAM ARN, including its path")
void preservesInstanceProfilePath(String mode, String path) {
String name = "profile-path-" + UUID.randomUUID();
try (Ec2Client ec2 = TestFixtures.ec2Client(); IamClient iam = TestFixtures.iamClient()) {
String profileArn = iam.createInstanceProfile(r -> r.instanceProfileName(name).path(path))
.instanceProfile().arn();
String instanceId = null;
String templateId = null;
try {
RunInstancesRequest.Builder launch = RunInstancesRequest.builder().minCount(1).maxCount(1);
if (mode.equals("direct")) {
launch.imageId("ami-0abcdef1234567890").instanceType(InstanceType.T3_MICRO)
.iamInstanceProfile(p -> p.name(name));
} else {
templateId = ec2.createLaunchTemplate(r -> r.launchTemplateName(name)
.launchTemplateData(RequestLaunchTemplateData.builder()
.imageId("ami-0abcdef1234567890").instanceType(InstanceType.T3_MICRO)
.iamInstanceProfile(p -> p.name(mode.equals("override") ? "missing-" + name : name))
.build())).launchTemplate().launchTemplateId();
String selectedTemplate = templateId;
launch.launchTemplate(t -> t.launchTemplateId(selectedTemplate).version("$Latest"));
if (mode.equals("override")) {
launch.iamInstanceProfile(p -> p.name(name));
}
}
if (mode.equals("fleet")) {
String selectedTemplate = templateId;
instanceId = ec2.createFleet(r -> r.type("instant")
.launchTemplateConfigs(c -> c.launchTemplateSpecification(t ->
t.launchTemplateId(selectedTemplate).version("$Latest")))
.targetCapacitySpecification(t -> t.totalTargetCapacity(1)
.defaultTargetCapacityType("on-demand")))
.instances().get(0).instanceIds().get(0);
} else {
instanceId = ec2.runInstances(launch.build()).instances().get(0).instanceId();
}
String selectedInstance = instanceId;
assertThat(ec2.describeInstances(r -> r.instanceIds(selectedInstance))
.reservations().get(0).instances().get(0).iamInstanceProfile().arn()).isEqualTo(profileArn);
} finally {
if (instanceId != null) {
String cleanupId = instanceId;
ec2.terminateInstances(r -> r.instanceIds(cleanupId));
}
if (templateId != null) {
String cleanupId = templateId;
ec2.deleteLaunchTemplate(r -> r.launchTemplateId(cleanupId));
}
iam.deleteInstanceProfile(r -> r.instanceProfileName(name));
}
}
}
}
@@ -0,0 +1,355 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.ec2.Ec2Client;
import software.amazon.awssdk.services.ec2.model.*;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/**
* Verifies that RunInstances / DescribeInstances responses include all fields
* that the Terraform AWS provider (v5+) dereferences without nil-checks.
*
* Issue: https://github.com/floci-io/floci/issues/871
*
* The crash in terraform-provider-aws resourceInstanceRead is caused by nil
* pointer dereferences on missing response fields — most critically the ENI
* attachment block, which Terraform reads to identify the primary network
* interface by DeviceIndex.
*/
@DisplayName("EC2 Instance Response Shape (issue #871)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class Ec2InstanceResponseShapeTest {
private static Ec2Client ec2;
private static String instanceId;
private static String instanceIdForCleanup;
private static Instance instance;
@BeforeAll
static void setup() {
ec2 = TestFixtures.ec2Client();
RunInstancesResponse run = ec2.runInstances(RunInstancesRequest.builder()
.imageId("ami-0abcdef1234567890")
.instanceType(InstanceType.T3_MICRO)
.minCount(1)
.maxCount(1)
.build());
instanceId = run.instances().get(0).instanceId();
instanceIdForCleanup = instanceId;
DescribeInstancesResponse describe = ec2.describeInstances(
DescribeInstancesRequest.builder().instanceIds(instanceId).build());
instance = describe.reservations().get(0).instances().get(0);
}
@AfterAll
static void cleanup() {
if (ec2 != null) {
try {
if (instanceId != null) {
ec2.terminateInstances(TerminateInstancesRequest.builder()
.instanceIds(instanceId).build());
}
if (instanceIdForCleanup != null) {
for (int i = 0; i < 30; i++) {
Thread.sleep(1000);
DescribeInstancesResponse r = ec2.describeInstances(
DescribeInstancesRequest.builder().instanceIds(instanceIdForCleanup).build());
if (!r.reservations().isEmpty() && !r.reservations().get(0).instances().isEmpty()) {
InstanceStateName state = r.reservations().get(0).instances().get(0).state().name();
if (state == InstanceStateName.TERMINATED) break;
}
}
}
} catch (Exception ignored) {}
ec2.close();
}
}
// ── Core instance fields ─────────────────────────────────────────────────
@Test
@Order(1)
@DisplayName("instanceId and state are present")
void instanceIdAndState() {
assertThat(instance.instanceId()).isNotNull().startsWith("i-");
assertThat(instance.state()).isNotNull();
assertThat(instance.state().name()).isNotNull();
}
@Test
@Order(2)
@DisplayName("cpuOptions is not null with coreCount and threadsPerCore")
void cpuOptions() {
assertThat(instance.cpuOptions())
.as("cpuOptions must not be null — Terraform dereferences CpuOptions.CoreCount directly")
.isNotNull();
assertThat(instance.cpuOptions().coreCount())
.as("cpuOptions.coreCount must not be null")
.isNotNull()
.isGreaterThan(0);
assertThat(instance.cpuOptions().threadsPerCore())
.as("cpuOptions.threadsPerCore must not be null")
.isNotNull()
.isGreaterThan(0);
}
@Test
@Order(3)
@DisplayName("metadataOptions is not null with required sub-fields")
void metadataOptions() {
assertThat(instance.metadataOptions())
.as("metadataOptions must not be null — Terraform reads HttpTokens/HttpEndpoint")
.isNotNull();
assertThat(instance.metadataOptions().httpTokens())
.as("metadataOptions.httpTokens must not be null")
.isNotNull();
assertThat(instance.metadataOptions().httpEndpoint())
.as("metadataOptions.httpEndpoint must not be null")
.isNotNull();
assertThat(instance.metadataOptions().httpPutResponseHopLimit())
.as("metadataOptions.httpPutResponseHopLimit must not be null")
.isNotNull();
}
@Test
@Order(4)
@DisplayName("maintenanceOptions is not null")
void maintenanceOptions() {
assertThat(instance.maintenanceOptions())
.as("maintenanceOptions must not be null — Terraform reads AutoRecovery")
.isNotNull();
assertThat(instance.maintenanceOptions().autoRecovery())
.as("maintenanceOptions.autoRecovery must not be null")
.isNotNull();
}
@Test
@Order(5)
@DisplayName("enclaveOptions is not null")
void enclaveOptions() {
assertThat(instance.enclaveOptions())
.as("enclaveOptions must not be null — Terraform reads Enabled flag")
.isNotNull();
assertThat(instance.enclaveOptions().enabled())
.as("enclaveOptions.enabled must not be null")
.isNotNull();
}
@Test
@Order(6)
@DisplayName("hibernationOptions is not null")
void hibernationOptions() {
assertThat(instance.hibernationOptions())
.as("hibernationOptions must not be null — Terraform reads Configured flag")
.isNotNull();
assertThat(instance.hibernationOptions().configured())
.as("hibernationOptions.configured must not be null")
.isNotNull();
}
@Test
@Order(7)
@DisplayName("privateDnsNameOptions is not null with hostnameType")
void privateDnsNameOptions() {
assertThat(instance.privateDnsNameOptions())
.as("privateDnsNameOptions must not be null — Terraform reads HostnameType")
.isNotNull();
assertThat(instance.privateDnsNameOptions().hostnameType())
.as("privateDnsNameOptions.hostnameType must not be null")
.isNotNull();
}
@Test
@Order(8)
@DisplayName("capacityReservationSpecification is not null")
void capacityReservationSpecification() {
assertThat(instance.capacityReservationSpecification())
.as("capacityReservationSpecification must not be null — Terraform reads Preference")
.isNotNull();
assertThat(instance.capacityReservationSpecification().capacityReservationPreference())
.as("capacityReservationSpecification.preference must not be null")
.isNotNull();
}
@Test
@Order(9)
@DisplayName("stateReason is null for a running instance")
void stateReason() {
assertThat(instance.stateReason())
.as("stateReason must be null for a running instance with no state reason. " +
"AWS omits the element entirely; a non-null stateReason with empty code/message " +
"crashes Terraform's Go XML decoder.")
.isNull();
}
@Test
@Order(10)
@DisplayName("blockDeviceMappings contains root device entry with all required EBS fields")
void blockDeviceMappings() {
assertThat(instance.blockDeviceMappings())
.as("blockDeviceMappings must not be empty — Terraform reads root_block_device attributes")
.isNotEmpty();
InstanceBlockDeviceMapping rootDevice = instance.blockDeviceMappings().get(0);
assertThat(rootDevice.deviceName())
.as("blockDeviceMapping deviceName must not be null")
.isNotNull();
assertThat(rootDevice.ebs())
.as("blockDeviceMapping ebs must not be null")
.isNotNull();
assertThat(rootDevice.ebs().volumeId())
.as("blockDeviceMapping ebs.volumeId must not be null — Terraform calls DescribeVolumes(volumeId); " +
"empty/null causes InvalidVolume.NotFound for volume '' (issue #871)")
.isNotNull()
.startsWith("vol-");
assertThat(rootDevice.ebs().status())
.as("blockDeviceMapping ebs.status must not be null")
.isNotNull()
.isEqualTo(AttachmentStatus.ATTACHED);
assertThat(rootDevice.ebs().deleteOnTermination())
.as("blockDeviceMapping ebs.deleteOnTermination must not be null")
.isNotNull();
assertThat(rootDevice.ebs().attachTime())
.as("blockDeviceMapping ebs.attachTime must not be null — present in all real AWS responses")
.isNotNull();
}
@Test
@Order(16)
@DisplayName("root volume referenced in blockDeviceMappings is queryable via DescribeVolumes")
void rootVolumeDescribable() {
String volumeId = instance.blockDeviceMappings().get(0).ebs().volumeId();
assertThat(volumeId).as("volumeId from blockDeviceMapping must not be null").isNotNull();
DescribeVolumesResponse resp = ec2.describeVolumes(
DescribeVolumesRequest.builder().volumeIds(volumeId).build());
assertThat(resp.volumes()).as("DescribeVolumes must return the root volume").isNotEmpty();
Volume vol = resp.volumes().get(0);
assertThat(vol.volumeId()).isEqualTo(volumeId);
assertThat(vol.stateAsString())
.as("root volume state must be in-use while instance is running")
.isEqualTo("in-use");
}
// ── Primary network interface (ENI) ──────────────────────────────────────
@Test
@Order(11)
@DisplayName("networkInterfaces contains at least one ENI")
void networkInterfacesNotEmpty() {
assertThat(instance.networkInterfaces())
.as("networkInterfaces must not be empty")
.isNotEmpty();
}
@Test
@Order(12)
@DisplayName("primary ENI has non-null attachment — critical for Terraform DeviceIndex read")
void eniAttachmentNotNull() {
InstanceNetworkInterface primaryEni = instance.networkInterfaces().get(0);
assertThat(primaryEni.attachment())
.as("ENI attachment must not be null — Terraform reads Attachment.DeviceIndex " +
"to find the primary interface; nil causes panic in resourceInstanceRead")
.isNotNull();
}
@Test
@Order(13)
@DisplayName("primary ENI attachment has deviceIndex == 0")
void eniAttachmentDeviceIndex() {
InstanceNetworkInterface primaryEni = instance.networkInterfaces().get(0);
assertThat(primaryEni.attachment().deviceIndex())
.as("Primary ENI must have deviceIndex 0")
.isNotNull()
.isEqualTo(0);
}
@Test
@Order(14)
@DisplayName("primary ENI attachment has attachmentId")
void eniAttachmentId() {
InstanceNetworkInterface primaryEni = instance.networkInterfaces().get(0);
assertThat(primaryEni.attachment().attachmentId())
.as("ENI attachmentId must not be null")
.isNotNull()
.startsWith("eni-attach-");
}
@Test
@Order(15)
@DisplayName("primary ENI has privateIpAddresses list")
void eniPrivateIpAddresses() {
InstanceNetworkInterface primaryEni = instance.networkInterfaces().get(0);
assertThat(primaryEni.privateIpAddresses())
.as("ENI privateIpAddresses must not be empty")
.isNotEmpty();
assertThat(primaryEni.privateIpAddresses().get(0).privateIpAddress())
.as("ENI primary private IP must not be null")
.isNotNull();
assertThat(primaryEni.privateIpAddresses().get(0).primary())
.as("ENI first privateIpAddress must have primary=true")
.isNotNull()
.isTrue();
}
// ── Security groups (issue #1644) ────────────────────────────────────────
@Test
@Order(17)
@DisplayName("instance and primary ENI expose security groups")
void securityGroupsOnInstanceAndEni() {
assertThat(instance.securityGroups())
.as("instance securityGroups must not be empty")
.isNotEmpty();
assertThat(instance.securityGroups().get(0).groupId()).isNotBlank();
assertThat(instance.securityGroups().get(0).groupName()).isNotBlank();
InstanceNetworkInterface primaryEni = instance.networkInterfaces().get(0);
assertThat(primaryEni.groups())
.as("primary ENI groups must not be empty")
.isNotEmpty();
assertThat(primaryEni.groups().get(0).groupId()).isNotBlank();
}
@Test
@Order(18)
@DisplayName("DescribeInstanceAttribute groupSet returns Groups (Ansible ec2_instance path)")
void describeInstanceAttributeGroupSet() {
DescribeInstanceAttributeResponse attr = ec2.describeInstanceAttribute(
DescribeInstanceAttributeRequest.builder()
.instanceId(instanceId)
.attribute(InstanceAttributeName.GROUP_SET)
.build());
assertThat(attr.instanceId()).isEqualTo(instanceId);
assertThat(attr.groups())
.as("groupSet attribute must return the instance security groups"
+ " — amazon.aws ec2_instance reads value['Groups'] from this call")
.isNotEmpty();
assertThat(attr.groups().get(0).groupId()).isNotBlank();
assertThat(attr.groups().get(0).groupName()).isNotBlank();
}
// ── TerminateInstances state transitions ─────────────────────────────────
@Test
@Order(20)
@DisplayName("TerminateInstances returns correct state transition")
void terminateInstances() {
TerminateInstancesResponse resp = ec2.terminateInstances(
TerminateInstancesRequest.builder().instanceIds(instanceId).build());
assertThat(resp.terminatingInstances()).isNotEmpty();
InstanceStateChange change = resp.terminatingInstances().get(0);
assertThat(change.instanceId()).isEqualTo(instanceId);
assertThat(change.currentState().name()).isEqualTo(InstanceStateName.SHUTTING_DOWN);
instanceId = null;
}
}
@@ -0,0 +1,191 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.ec2.Ec2Client;
import software.amazon.awssdk.services.ec2.model.*;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/**
* Verifies pagination for DescribeNetworkInterfaces (MaxResults / NextToken).
*
* <p>Launches 6 instances to produce 6 ENIs, then exercises full pagination:
* first page truncated with nextToken, second page continued from token,
* and final page omitting nextToken.
*/
@DisplayName("EC2 DescribeNetworkInterfaces Pagination")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class Ec2NetworkInterfacePaginationTest {
private static Ec2Client ec2;
private static String vpcId;
private static String subnetId;
private static String sgId;
private static String keyName;
private static final List<String> instanceIds = new ArrayList<>();
@BeforeAll
static void setup() {
ec2 = TestFixtures.ec2Client();
// --- Create VPC ---
CreateVpcResponse vpcResp = ec2.createVpc(CreateVpcRequest.builder()
.cidrBlock("10.1.0.0/16").build());
vpcId = vpcResp.vpc().vpcId();
assertThat(vpcId).isNotNull().startsWith("vpc-");
// --- Create Subnet ---
CreateSubnetResponse subnetResp = ec2.createSubnet(CreateSubnetRequest.builder()
.vpcId(vpcId).cidrBlock("10.1.1.0/24").build());
subnetId = subnetResp.subnet().subnetId();
assertThat(subnetId).isNotNull().startsWith("subnet-");
// --- Create Security Group ---
String sgName = TestFixtures.uniqueName("pagination-sg");
CreateSecurityGroupResponse sgResp = ec2.createSecurityGroup(
CreateSecurityGroupRequest.builder()
.groupName(sgName).description("Pagination test SG").vpcId(vpcId).build());
sgId = sgResp.groupId();
assertThat(sgId).isNotNull().startsWith("sg-");
// --- Create Key Pair ---
keyName = TestFixtures.uniqueName("pagination-key");
ec2.createKeyPair(CreateKeyPairRequest.builder().keyName(keyName).build());
// --- Launch 6 instances to get 6 ENIs ---
RunInstancesResponse runResp = ec2.runInstances(RunInstancesRequest.builder()
.imageId("ami-0abcdef1234567890")
.instanceType(InstanceType.T2_MICRO)
.minCount(6)
.maxCount(6)
.keyName(keyName)
.subnetId(subnetId)
.securityGroupIds(List.of(sgId))
.build());
for (Instance inst : runResp.instances()) {
instanceIds.add(inst.instanceId());
}
assertThat(instanceIds).hasSize(6);
}
@AfterAll
static void cleanup() {
if (ec2 != null) {
// Terminate instances
if (!instanceIds.isEmpty()) {
try {
ec2.terminateInstances(TerminateInstancesRequest.builder()
.instanceIds(instanceIds).build());
} catch (Exception ignored) {}
}
// Delete subnet
try {
if (subnetId != null) {
ec2.deleteSubnet(DeleteSubnetRequest.builder().subnetId(subnetId).build());
}
} catch (Exception ignored) {}
// Delete SG
try {
if (sgId != null) {
ec2.deleteSecurityGroup(DeleteSecurityGroupRequest.builder().groupId(sgId).build());
}
} catch (Exception ignored) {}
// Delete key pair
try {
if (keyName != null) {
ec2.deleteKeyPair(DeleteKeyPairRequest.builder().keyName(keyName).build());
}
} catch (Exception ignored) {}
// Delete VPC
try {
if (vpcId != null) {
ec2.deleteVpc(DeleteVpcRequest.builder().vpcId(vpcId).build());
}
} catch (Exception ignored) {}
ec2.close();
}
}
private DescribeNetworkInterfacesRequest.Builder vpcFilter() {
return DescribeNetworkInterfacesRequest.builder()
.filters(Filter.builder().name("vpc-id").values(vpcId).build());
}
@Test
@Order(1)
@DisplayName("DescribeNetworkInterfaces without pagination returns all 6 ENIs")
void describeNetworkInterfacesAll() {
DescribeNetworkInterfacesResponse resp = ec2.describeNetworkInterfaces(
vpcFilter().build());
assertThat(resp.networkInterfaces()).hasSize(6);
assertThat(resp.nextToken()).isNull();
}
@Test
@Order(2)
@DisplayName("MaxResults=5 truncates first page and returns nextToken")
void describeNetworkInterfacesFirstPage() {
DescribeNetworkInterfacesResponse resp = ec2.describeNetworkInterfaces(
vpcFilter().maxResults(5).build());
assertThat(resp.networkInterfaces()).hasSize(5);
assertThat(resp.nextToken()).isNotNull().isNotEmpty();
}
@Test
@Order(3)
@DisplayName("NextToken continuation returns remaining ENIs, no nextToken on final page")
void describeNetworkInterfacesContinuation() {
// Page 1: get nextToken
DescribeNetworkInterfacesResponse page1 = ec2.describeNetworkInterfaces(
vpcFilter().maxResults(5).build());
assertThat(page1.nextToken()).isNotNull();
// Page 2: use nextToken
DescribeNetworkInterfacesResponse page2 = ec2.describeNetworkInterfaces(
vpcFilter().maxResults(5).nextToken(page1.nextToken()).build());
assertThat(page2.networkInterfaces()).hasSize(1);
assertThat(page2.nextToken()).isNull();
// Verify all 6 ENIs accounted for across both pages
List<String> page1Ids = page1.networkInterfaces().stream()
.map(NetworkInterface::networkInterfaceId).toList();
List<String> page2Ids = page2.networkInterfaces().stream()
.map(NetworkInterface::networkInterfaceId).toList();
// No overlap between pages
assertThat(page1Ids).doesNotContainAnyElementsOf(page2Ids);
// Combined = 6 unique
List<String> all = new ArrayList<>(page1Ids);
all.addAll(page2Ids);
assertThat(all).hasSize(6).doesNotHaveDuplicates();
}
@Test
@Order(4)
@DisplayName("NextToken past end returns empty result set")
void describeNetworkInterfacesTokenPastEnd() {
// Get a valid token from first page
DescribeNetworkInterfacesResponse page1 = ec2.describeNetworkInterfaces(
vpcFilter().maxResults(5).build());
String token = page1.nextToken();
// Use it to get the last page
DescribeNetworkInterfacesResponse page2 = ec2.describeNetworkInterfaces(
vpcFilter().maxResults(5).nextToken(token).build());
// Using the token from page2 (which is null since it's the last page)
// would be invalid, but using a valid token again should still work
DescribeNetworkInterfacesResponse repeat = ec2.describeNetworkInterfaces(
vpcFilter().maxResults(5).nextToken(token).build());
assertThat(repeat.networkInterfaces()).hasSize(1);
assertThat(repeat.nextToken()).isNull();
}
}
@@ -0,0 +1,75 @@
package com.floci.test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.ec2.Ec2Client;
import software.amazon.awssdk.services.ec2.model.Ec2Exception;
import software.amazon.awssdk.services.ec2.model.Filter;
import software.amazon.awssdk.services.ec2.model.InstanceType;
import software.amazon.awssdk.services.ec2.model.ResourceType;
import software.amazon.awssdk.services.ec2.model.RunInstancesRequest;
import software.amazon.awssdk.services.ec2.model.RunInstancesResponse;
import software.amazon.awssdk.services.ec2.model.Tag;
import software.amazon.awssdk.services.ec2.model.TagSpecification;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class Ec2RunInstancesDryRunTest {
@Test
@DisplayName("Invalid RunInstances counts retain their validation error with DryRun enabled")
void invalidDryRunReturnsValidationError() {
try (Ec2Client ec2 = TestFixtures.ec2Client()) {
Ec2Exception error = assertThrows(Ec2Exception.class, () -> ec2.runInstances(r ->
r.imageId("ami-0abcdef1234567890").instanceType(InstanceType.T3_MICRO)
.minCount(0).maxCount(1).dryRun(true)));
assertThat(error.statusCode()).isEqualTo(400);
assertThat(error.awsErrorDetails().errorCode()).isEqualTo("InvalidParameterValue");
}
}
@Test
@DisplayName("A dry run with a missing subnet returns the launch validation error")
void missingSubnetIsValidatedDuringDryRun() {
try (Ec2Client ec2 = TestFixtures.ec2Client()) {
Ec2Exception error = assertThrows(Ec2Exception.class, () -> ec2.runInstances(r ->
r.imageId("ami-0abcdef1234567890").instanceType(InstanceType.T3_MICRO)
.minCount(1).maxCount(1).subnetId("subnet-missing").dryRun(true)));
assertThat(error.statusCode()).isEqualTo(400);
assertThat(error.awsErrorDetails().errorCode()).isEqualTo("InvalidSubnetID.NotFound");
}
}
@Test
@DisplayName("RunInstances dry run returns DryRunOperation without reserving an instance or client token")
void dryRunHasNoLaunchSideEffects() {
String marker = UUID.randomUUID().toString();
try (Ec2Client ec2 = TestFixtures.ec2Client()) {
RunInstancesRequest request = RunInstancesRequest.builder()
.imageId("ami-0abcdef1234567890").instanceType(InstanceType.T3_MICRO)
.minCount(1).maxCount(1).clientToken(marker)
.tagSpecifications(TagSpecification.builder().resourceType(ResourceType.INSTANCE)
.tags(Tag.builder().key("DryRunTest").value(marker).build()).build())
.build();
try {
Ec2Exception error = assertThrows(Ec2Exception.class,
() -> ec2.runInstances(request.toBuilder().dryRun(true).build()));
assertThat(error.statusCode()).isEqualTo(412);
assertThat(error.awsErrorDetails().errorCode()).isEqualTo("DryRunOperation");
assertThat(ec2.describeInstances(r -> r.filters(Filter.builder()
.name("tag:DryRunTest").values(marker).build())).reservations())
.flatExtracting(reservation -> reservation.instances()).isEmpty();
RunInstancesResponse launched = ec2.runInstances(request);
assertThat(launched.instances()).hasSize(1);
assertThat(launched.instances().get(0).instanceId()).isNotBlank();
} finally {
ec2.describeInstances(r -> r.filters(Filter.builder().name("tag:DryRunTest").values(marker).build()))
.reservations().stream().flatMap(reservation -> reservation.instances().stream())
.forEach(instance -> ec2.terminateInstances(r -> r.instanceIds(instance.instanceId())));
}
}
}
}
@@ -0,0 +1,977 @@
package com.floci.test;
import org.junit.jupiter.api.*;
import software.amazon.awssdk.services.ec2.Ec2Client;
import software.amazon.awssdk.services.ec2.model.*;
import software.amazon.awssdk.services.ec2.model.Tag;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import static org.assertj.core.api.Assertions.*;
@DisplayName("EC2 Elastic Compute Cloud")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class Ec2Tests {
private static final Logger LOG = Logger.getLogger(Ec2Tests.class.getName());
private static Ec2Client ec2;
private static String vpcId;
private static String subnetId;
private static String sgId;
private static String keyName;
private static String igwId;
private static String rtId;
private static String rtbAssocId;
private static String allocationId;
private static String instanceId;
private static String fleetInstanceId;
@BeforeAll
static void setup() {
ec2 = TestFixtures.ec2Client();
keyName = "sdk-test-key";
}
@Test
@Order(0)
@DisplayName("DescribeVpnGateways - empty when none exist")
void describeVpnGatewaysEmpty() {
assertThat(ec2.describeVpnGateways().vpnGateways()).isEmpty();
assertThat(ec2.describeVpnGateways(DescribeVpnGatewaysRequest.builder()
.filters(Filter.builder()
.name("attachment.vpc-id")
.values("vpc-0123456789abcdef0")
.build())
.build()).vpnGateways()).isEmpty();
assertThat(ec2.describeVpnGateways(DescribeVpnGatewaysRequest.builder()
.filters(Filter.builder().name("tag-value").values("TeamA").build())
.build()).vpnGateways()).isEmpty();
assertThatThrownBy(() -> ec2.describeVpnGateways(DescribeVpnGatewaysRequest.builder()
.vpnGatewayIds("vgw-0123456789abcdef0")
.build()))
.isInstanceOfSatisfying(Ec2Exception.class, error -> {
assertThat(error.statusCode()).isEqualTo(400);
assertThat(error.awsErrorDetails().errorCode())
.isEqualTo("InvalidVpnGatewayID.NotFound");
assertThat(error.awsErrorDetails().errorMessage())
.isEqualTo("The vpnGateway ID 'vgw-0123456789abcdef0' does not exist");
assertThat(error.requestId()).isNotBlank();
});
}
@Test
@Order(0)
@DisplayName("DescribeEgressOnlyInternetGateways - empty when none exist")
void describeEgressOnlyInternetGatewaysEmpty() {
assertThat(ec2.describeEgressOnlyInternetGateways().egressOnlyInternetGateways()).isEmpty();
assertThat(ec2.describeEgressOnlyInternetGateways(
DescribeEgressOnlyInternetGatewaysRequest.builder()
.filters(Filter.builder()
.name("tag:Owner")
.values("TeamA")
.build())
.build()).egressOnlyInternetGateways()).isEmpty();
assertThat(ec2.describeEgressOnlyInternetGateways(
DescribeEgressOnlyInternetGatewaysRequest.builder()
.filters(Filter.builder().name("tag-value").values("TeamA").build())
.build()).egressOnlyInternetGateways()).isEmpty();
DescribeEgressOnlyInternetGatewaysResponse paged =
ec2.describeEgressOnlyInternetGateways(
DescribeEgressOnlyInternetGatewaysRequest.builder().maxResults(5).build());
assertThat(paged.egressOnlyInternetGateways()).isEmpty();
assertThat(paged.nextToken()).isNull();
assertThat(ec2.describeEgressOnlyInternetGateways(
DescribeEgressOnlyInternetGatewaysRequest.builder()
.egressOnlyInternetGatewayIds("eigw-0123456789abcdef0")
.build()).egressOnlyInternetGateways()).isEmpty();
}
@AfterAll
static void cleanup() {
if (ec2 != null) {
try {
if (fleetInstanceId != null) {
ec2.terminateInstances(TerminateInstancesRequest.builder().instanceIds(fleetInstanceId).build());
}
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to terminate CreateFleet test instance " + fleetInstanceId, e);
}
try {
if (instanceId != null) {
ec2.terminateInstances(TerminateInstancesRequest.builder().instanceIds(instanceId).build());
}
} catch (Exception ignored) {}
try {
if (allocationId != null) {
ec2.releaseAddress(ReleaseAddressRequest.builder().allocationId(allocationId).build());
}
} catch (Exception ignored) {}
try {
if (rtbAssocId != null) {
ec2.disassociateRouteTable(DisassociateRouteTableRequest.builder().associationId(rtbAssocId).build());
}
} catch (Exception ignored) {}
try {
if (igwId != null && vpcId != null) {
ec2.detachInternetGateway(DetachInternetGatewayRequest.builder().internetGatewayId(igwId).vpcId(vpcId).build());
ec2.deleteInternetGateway(DeleteInternetGatewayRequest.builder().internetGatewayId(igwId).build());
}
} catch (Exception ignored) {}
try {
if (rtId != null) {
ec2.deleteRouteTable(DeleteRouteTableRequest.builder().routeTableId(rtId).build());
}
} catch (Exception ignored) {}
try {
if (subnetId != null) {
ec2.deleteSubnet(DeleteSubnetRequest.builder().subnetId(subnetId).build());
}
} catch (Exception ignored) {}
try {
if (sgId != null) {
ec2.deleteSecurityGroup(DeleteSecurityGroupRequest.builder().groupId(sgId).build());
}
} catch (Exception ignored) {}
try {
if (keyName != null) {
ec2.deleteKeyPair(DeleteKeyPairRequest.builder().keyName(keyName).build());
}
} catch (Exception ignored) {}
try {
if (vpcId != null) {
ec2.deleteVpc(DeleteVpcRequest.builder().vpcId(vpcId).build());
}
} catch (Exception ignored) {}
ec2.close();
}
}
/** Polls DescribeInstances until the instance reaches the target state (up to 60 s). */
private static Instance waitForState(String id, InstanceStateName target) throws InterruptedException {
for (int i = 0; i < 60; i++) {
DescribeInstancesResponse resp = ec2.describeInstances(
DescribeInstancesRequest.builder().instanceIds(id).build());
Instance inst = resp.reservations().get(0).instances().get(0);
if (inst.state().name() == target) {
return inst;
}
Thread.sleep(1000);
}
throw new AssertionError("Instance " + id + " did not reach state " + target + " within 60 s");
}
@Test
@Order(1)
@DisplayName("DescribeVpcs - default VPC exists")
void describeVpcsDefaultExists() {
DescribeVpcsResponse resp = ec2.describeVpcs();
boolean hasDefault = resp.vpcs().stream().anyMatch(Vpc::isDefault);
assertThat(hasDefault).isTrue();
}
@Test
@Order(2)
@DisplayName("DescribeSubnets - at least 3 default subnets")
void describeSubnetsDefaultCount() {
DescribeSubnetsResponse resp = ec2.describeSubnets();
long defaultCount = resp.subnets().stream().filter(Subnet::defaultForAz).count();
assertThat(defaultCount).isGreaterThanOrEqualTo(3);
}
@Test
@Order(3)
@DisplayName("DescribeSecurityGroups - default SG exists")
void describeSecurityGroupsDefault() {
DescribeSecurityGroupsResponse resp = ec2.describeSecurityGroups();
boolean hasDefault = resp.securityGroups().stream()
.anyMatch(sg -> "default".equals(sg.groupName()));
assertThat(hasDefault).isTrue();
}
@Test
@Order(4)
@DisplayName("DescribeAvailabilityZones - 3 zones")
void describeAvailabilityZones() {
DescribeAvailabilityZonesResponse resp = ec2.describeAvailabilityZones();
assertThat(resp.availabilityZones()).hasSize(3);
}
@Test
@Order(5)
@DisplayName("DescribeRegions - non-empty list")
void describeRegions() {
DescribeRegionsResponse resp = ec2.describeRegions();
assertThat(resp.regions()).isNotEmpty();
}
@Test
@Order(6)
@DisplayName("DescribeImages - static AMI list")
void describeImages() {
DescribeImagesResponse resp = ec2.describeImages();
assertThat(resp.images()).isNotEmpty();
assertThat(resp.images()).allMatch(img -> img.imageId().startsWith("ami-"));
}
@Test
@Order(7)
@DisplayName("DescribeLaunchTemplateVersions - user data readback")
void describeLaunchTemplateVersionsReturnsEncodedUserData() {
String encodedUserData = Base64.getEncoder()
.encodeToString("#!/bin/sh\necho sdk-launch-template\n".getBytes(StandardCharsets.UTF_8));
String launchTemplateId = ec2.createLaunchTemplate(CreateLaunchTemplateRequest.builder()
.launchTemplateName("sdk-user-data-readback")
.launchTemplateData(RequestLaunchTemplateData.builder()
.imageId("ami-0abcdef1234567890")
.instanceType(InstanceType.T3_MICRO)
.userData(encodedUserData)
.build())
.build()).launchTemplate().launchTemplateId();
try {
DescribeLaunchTemplateVersionsResponse described = ec2.describeLaunchTemplateVersions(
DescribeLaunchTemplateVersionsRequest.builder()
.launchTemplateId(launchTemplateId)
.versions("$Latest")
.build());
assertThat(described.launchTemplateVersions()).hasSize(1);
assertThat(described.launchTemplateVersions().get(0).launchTemplateData().userData())
.isEqualTo(encodedUserData);
}
finally {
ec2.deleteLaunchTemplate(DeleteLaunchTemplateRequest.builder()
.launchTemplateId(launchTemplateId)
.build());
}
}
@Test
@Order(7)
@DisplayName("DescribeInstanceTypes - non-empty list")
void describeInstanceTypes() {
DescribeInstanceTypesResponse resp = ec2.describeInstanceTypes(
DescribeInstanceTypesRequest.builder().build());
assertThat(resp.instanceTypes()).isNotEmpty();
}
@Test
@Order(7)
@DisplayName("DescribeInstanceTypes - network compatibility metadata")
void describeInstanceTypeNetworkCompatibilityMetadata() {
DescribeInstanceTypesResponse resp = ec2.describeInstanceTypes(DescribeInstanceTypesRequest.builder()
.instanceTypes(InstanceType.M5_LARGE, InstanceType.fromValue("t4g.medium"))
.build());
assertThat(resp.instanceTypes()).hasSize(2);
assertThat(resp.instanceTypes()).allSatisfy(instanceType -> {
assertThat(instanceType.networkInfo()).isNotNull();
assertThat(instanceType.networkInfo().encryptionInTransitSupported()).isNotNull();
assertThat(instanceType.networkInfo().defaultNetworkCardIndex()).isNotNull();
assertThat(instanceType.networkInfo().networkCards()).isNotEmpty();
assertThat(instanceType.networkInfo().ipv4AddressesPerInterface()).isNotNull();
});
assertThat(resp.instanceTypes()).allMatch(instanceType ->
!instanceType.networkInfo().encryptionInTransitSupported());
}
@Test
@Order(7)
@DisplayName("DescribeInstanceTypes - network card capacity metadata")
void describeInstanceTypeNetworkCardCapacityMetadata() {
DescribeInstanceTypesResponse resp = ec2.describeInstanceTypes(DescribeInstanceTypesRequest.builder()
.instanceTypes(InstanceType.M5_LARGE, InstanceType.fromValue("t4g.medium"))
.build());
assertThat(resp.instanceTypes()).hasSize(2);
assertThat(resp.instanceTypes()).anySatisfy(instanceType -> {
assertThat(instanceType.instanceType()).isEqualTo(InstanceType.M5_LARGE);
assertThat(instanceType.networkInfo().defaultNetworkCardIndex()).isEqualTo(0);
assertThat(instanceType.networkInfo().ipv4AddressesPerInterface()).isEqualTo(10);
assertThat(instanceType.networkInfo().networkCards()).singleElement().satisfies(networkCard -> {
assertThat(networkCard.networkCardIndex()).isEqualTo(0);
assertThat(networkCard.maximumNetworkInterfaces()).isEqualTo(3);
});
});
assertThat(resp.instanceTypes()).anySatisfy(instanceType -> {
assertThat(instanceType.instanceTypeAsString()).isEqualTo("t4g.medium");
assertThat(instanceType.networkInfo().defaultNetworkCardIndex()).isEqualTo(0);
assertThat(instanceType.networkInfo().ipv4AddressesPerInterface()).isEqualTo(6);
assertThat(instanceType.networkInfo().networkCards()).singleElement().satisfies(networkCard -> {
assertThat(networkCard.networkCardIndex()).isEqualTo(0);
assertThat(networkCard.maximumNetworkInterfaces()).isEqualTo(3);
});
});
DescribeInstanceTypesResponse largerArmResponse = ec2.describeInstanceTypes(DescribeInstanceTypesRequest.builder()
.instanceTypes(InstanceType.fromValue("m8gd.2xlarge"))
.build());
assertThat(largerArmResponse.instanceTypes()).singleElement().satisfies(instanceType -> {
assertThat(instanceType.networkInfo().defaultNetworkCardIndex()).isEqualTo(0);
assertThat(instanceType.networkInfo().ipv4AddressesPerInterface()).isEqualTo(15);
assertThat(instanceType.networkInfo().networkCards()).singleElement().satisfies(networkCard -> {
assertThat(networkCard.networkCardIndex()).isEqualTo(0);
assertThat(networkCard.maximumNetworkInterfaces()).isEqualTo(4);
});
});
}
@Test
@Order(7)
@DisplayName("DescribeInstanceTypes - processor architectures")
void describeInstanceTypeProcessorArchitectures() {
DescribeInstanceTypesResponse resp = ec2.describeInstanceTypes(DescribeInstanceTypesRequest.builder()
.instanceTypes(InstanceType.fromValue("m6gd.2xlarge"))
.build());
assertThat(resp.instanceTypes()).hasSize(1);
assertThat(resp.instanceTypes().get(0).processorInfo().supportedArchitecturesAsStrings())
.containsExactly("arm64");
}
/** Regression coverage for the Karpenter instance-type compatibility contract. */
@Test
@Order(7)
@DisplayName("DescribeInstanceTypes - supported usage classes")
void describeInstanceTypeSupportedUsageClasses() {
DescribeInstanceTypesResponse resp = ec2.describeInstanceTypes(DescribeInstanceTypesRequest.builder()
.instanceTypes(InstanceType.M5_LARGE, InstanceType.fromValue("t4g.medium"),
InstanceType.fromValue("m6gd.large"))
.build());
assertThat(resp.instanceTypes()).hasSize(3);
assertThat(resp.instanceTypes()).allSatisfy(instanceType ->
assertThat(instanceType.supportedUsageClassesAsStrings())
.containsExactlyInAnyOrder("on-demand", "spot"));
}
@Test
@Order(7)
@DisplayName("CreateFleet - dry-run and instant on-demand launch")
void createFleetDryRunAndLaunch() {
String launchTemplateId = ec2.createLaunchTemplate(CreateLaunchTemplateRequest.builder()
.launchTemplateName("sdk-create-fleet")
.launchTemplateData(RequestLaunchTemplateData.builder()
.imageId("ami-0abcdef1234567890")
.instanceType(InstanceType.T3_MICRO)
.build())
.build()).launchTemplate().launchTemplateId();
FleetLaunchTemplateConfigRequest config = FleetLaunchTemplateConfigRequest.builder()
.launchTemplateSpecification(FleetLaunchTemplateSpecificationRequest.builder()
.launchTemplateId(launchTemplateId)
.version("1")
.build())
.overrides(FleetLaunchTemplateOverridesRequest.builder()
.instanceType(InstanceType.T3_MICRO)
.imageId("ami-0abcdef1234567890")
.build())
.build();
CreateFleetRequest request = CreateFleetRequest.builder()
.type(FleetType.INSTANT)
.launchTemplateConfigs(config)
.targetCapacitySpecification(TargetCapacitySpecificationRequest.builder()
.totalTargetCapacity(1)
.defaultTargetCapacityType(DefaultTargetCapacityType.ON_DEMAND)
.build())
.build();
try {
assertThatThrownBy(() -> ec2.createFleet(request.toBuilder().dryRun(true).build()))
.isInstanceOf(Ec2Exception.class)
.satisfies(error -> assertThat(((Ec2Exception) error).awsErrorDetails().errorCode())
.isEqualTo("DryRunOperation"));
CreateFleetResponse response = ec2.createFleet(request);
assertThat(response.fleetId()).startsWith("fleet-");
assertThat(response.instances()).hasSize(1);
assertThat(response.instances().get(0).instanceIds()).hasSize(1);
fleetInstanceId = response.instances().get(0).instanceIds().get(0);
assertThat(response.instances().get(0).instanceType()).isEqualTo(InstanceType.T3_MICRO);
assertThat(response.instances().get(0).lifecycle()).isEqualTo(InstanceLifecycle.ON_DEMAND);
}
finally {
ec2.deleteLaunchTemplate(DeleteLaunchTemplateRequest.builder()
.launchTemplateId(launchTemplateId)
.build());
}
}
@Test
@Order(8)
@DisplayName("CreateVpc - create VPC with CIDR")
void createVpc() {
CreateVpcResponse resp = ec2.createVpc(CreateVpcRequest.builder()
.cidrBlock("10.0.0.0/16").build());
vpcId = resp.vpc().vpcId();
assertThat(vpcId).isNotNull().startsWith("vpc-");
assertThat(resp.vpc().cidrBlock()).isEqualTo("10.0.0.0/16");
assertThat(resp.vpc().state()).isEqualTo(VpcState.AVAILABLE);
}
@Test
@Order(9)
@DisplayName("DescribeVpcs - by ID")
void describeVpcsById() {
DescribeVpcsResponse resp = ec2.describeVpcs(DescribeVpcsRequest.builder()
.vpcIds(vpcId).build());
assertThat(resp.vpcs()).hasSize(1);
assertThat(resp.vpcs().get(0).vpcId()).isEqualTo(vpcId);
}
@Test
@Order(10)
@DisplayName("DescribeVpcs - non-existent ID returns error")
void describeVpcsNotFound() {
assertThatThrownBy(() -> ec2.describeVpcs(DescribeVpcsRequest.builder()
.vpcIds("vpc-doesnotexist").build()))
.isInstanceOf(Ec2Exception.class)
.satisfies(e -> {
Ec2Exception ec2Ex = (Ec2Exception) e;
assertThat(ec2Ex.awsErrorDetails().errorCode()).isEqualTo("InvalidVpcID.NotFound");
});
}
@Test
@Order(11)
@DisplayName("CreateSubnet - create subnet in VPC")
void createSubnet() {
CreateSubnetResponse resp = ec2.createSubnet(CreateSubnetRequest.builder()
.vpcId(vpcId)
.cidrBlock("10.0.1.0/24")
.availabilityZone("us-east-1a")
.tagSpecifications(
TagSpecification.builder()
.resourceType(ResourceType.SUBNET)
.tags(Tag.builder().key("example.io:managed-by").value("sdk-test").build())
.build(),
TagSpecification.builder()
.resourceType(ResourceType.SUBNET)
.tags(
Tag.builder().key("Name").value("sdk-test-subnet").build(),
Tag.builder().key("omitted-value").build(),
Tag.builder().key("explicit-empty-value").value("").build())
.build())
.build());
subnetId = resp.subnet().subnetId();
assertThat(subnetId).isNotNull().startsWith("subnet-");
assertThat(resp.subnet().vpcId()).isEqualTo(vpcId);
assertThat(resp.subnet().cidrBlock()).isEqualTo("10.0.1.0/24");
assertThat(resp.subnet().tags()).extracting(Tag::key, Tag::value)
.containsExactlyInAnyOrder(
tuple("example.io:managed-by", "sdk-test"),
tuple("Name", "sdk-test-subnet"),
tuple("omitted-value", ""),
tuple("explicit-empty-value", ""));
}
@Test
@Order(12)
@DisplayName("DescribeSubnets - by ID")
void describeSubnetsById() {
DescribeSubnetsResponse resp = ec2.describeSubnets(DescribeSubnetsRequest.builder()
.subnetIds(subnetId).build());
assertThat(resp.subnets()).hasSize(1);
assertThat(resp.subnets().get(0).subnetId()).isEqualTo(subnetId);
assertThat(resp.subnets().get(0).tags()).extracting(Tag::key, Tag::value)
.containsExactlyInAnyOrder(
tuple("example.io:managed-by", "sdk-test"),
tuple("Name", "sdk-test-subnet"),
tuple("omitted-value", ""),
tuple("explicit-empty-value", ""));
assertThat(ec2.describeTags(DescribeTagsRequest.builder()
.filters(Filter.builder().name("resource-id").values(subnetId).build())
.build()).tags())
.extracting(TagDescription::key, TagDescription::value)
.containsExactlyInAnyOrder(
tuple("example.io:managed-by", "sdk-test"),
tuple("Name", "sdk-test-subnet"),
tuple("omitted-value", ""),
tuple("explicit-empty-value", ""));
}
@Test
@Order(13)
@DisplayName("CreateSecurityGroup - create SG in VPC")
void createSecurityGroup() {
CreateSecurityGroupResponse resp = ec2.createSecurityGroup(
CreateSecurityGroupRequest.builder()
.groupName("sdk-test-sg")
.description("SDK test security group")
.vpcId(vpcId)
.build());
sgId = resp.groupId();
assertThat(sgId).isNotNull().startsWith("sg-");
}
@Test
@Order(14)
@DisplayName("AuthorizeSecurityGroupIngress - add SSH rule")
void authorizeSecurityGroupIngress() {
ec2.authorizeSecurityGroupIngress(AuthorizeSecurityGroupIngressRequest.builder()
.groupId(sgId)
.ipPermissions(IpPermission.builder()
.ipProtocol("tcp")
.fromPort(22)
.toPort(22)
.ipRanges(IpRange.builder().cidrIp("0.0.0.0/0").build())
.build())
.build());
}
@Test
@Order(15)
@DisplayName("DescribeSecurityGroups - ingress rule present")
void describeSecurityGroupsIngressRule() {
DescribeSecurityGroupsResponse resp = ec2.describeSecurityGroups(
DescribeSecurityGroupsRequest.builder().groupIds(sgId).build());
boolean hasSshRule = resp.securityGroups().get(0).ipPermissions().stream()
.anyMatch(p -> p.fromPort() != null && p.fromPort() == 22);
assertThat(hasSshRule).isTrue();
}
@Test
@Order(16)
@DisplayName("CreateKeyPair - create SSH key pair")
void createKeyPair() {
CreateKeyPairResponse resp = ec2.createKeyPair(CreateKeyPairRequest.builder()
.keyName(keyName).build());
assertThat(resp.keyName()).isEqualTo(keyName);
assertThat(resp.keyPairId()).isNotNull();
assertThat(resp.keyMaterial()).isNotNull().isNotEmpty();
}
@Test
@Order(17)
@DisplayName("DescribeKeyPairs - key pair exists")
void describeKeyPairs() {
DescribeKeyPairsResponse resp = ec2.describeKeyPairs(DescribeKeyPairsRequest.builder()
.keyNames(keyName).build());
assertThat(resp.keyPairs()).hasSize(1);
assertThat(resp.keyPairs().get(0).keyName()).isEqualTo(keyName);
}
@Test
@Order(18)
@DisplayName("CreateKeyPair - duplicate returns error")
void createKeyPairDuplicate() {
assertThatThrownBy(() -> ec2.createKeyPair(CreateKeyPairRequest.builder()
.keyName(keyName).build()))
.isInstanceOf(Ec2Exception.class)
.satisfies(e -> {
Ec2Exception ec2Ex = (Ec2Exception) e;
assertThat(ec2Ex.awsErrorDetails().errorCode()).isEqualTo("InvalidKeyPair.Duplicate");
});
}
@Test
@Order(18)
@DisplayName("CreateKeyPair - missing KeyName returns MissingParameter")
void createKeyPairWithoutNameIsRejected() {
assertThatThrownBy(() -> ec2.createKeyPair(CreateKeyPairRequest.builder().build()))
.isInstanceOf(Ec2Exception.class)
.satisfies(e -> {
Ec2Exception ec2Ex = (Ec2Exception) e;
assertThat(ec2Ex.awsErrorDetails().errorCode()).isEqualTo("MissingParameter");
assertThat(ec2Ex.statusCode()).isEqualTo(400);
});
}
@Test
@Order(19)
@DisplayName("CreateInternetGateway - create IGW")
void createInternetGateway() {
CreateInternetGatewayResponse resp = ec2.createInternetGateway(
CreateInternetGatewayRequest.builder().build());
igwId = resp.internetGateway().internetGatewayId();
assertThat(igwId).isNotNull().startsWith("igw-");
}
@Test
@Order(20)
@DisplayName("AttachInternetGateway - attach to VPC")
void attachInternetGateway() {
ec2.attachInternetGateway(AttachInternetGatewayRequest.builder()
.internetGatewayId(igwId)
.vpcId(vpcId)
.build());
}
@Test
@Order(21)
@DisplayName("DescribeInternetGateways - attachment reflected")
void describeInternetGatewaysAttached() {
DescribeInternetGatewaysResponse resp = ec2.describeInternetGateways(
DescribeInternetGatewaysRequest.builder()
.internetGatewayIds(igwId).build());
boolean attached = resp.internetGateways().get(0).attachments().stream()
.anyMatch(a -> vpcId.equals(a.vpcId()));
assertThat(attached).isTrue();
}
@Test
@Order(22)
@DisplayName("CreateRouteTable - create route table")
void createRouteTable() {
CreateRouteTableResponse resp = ec2.createRouteTable(CreateRouteTableRequest.builder()
.vpcId(vpcId).build());
rtId = resp.routeTable().routeTableId();
assertThat(rtId).isNotNull().startsWith("rtb-");
assertThat(resp.routeTable().vpcId()).isEqualTo(vpcId);
}
@Test
@Order(23)
@DisplayName("CreateRoute - add default route to IGW")
void createRoute() {
ec2.createRoute(CreateRouteRequest.builder()
.routeTableId(rtId)
.destinationCidrBlock("0.0.0.0/0")
.gatewayId(igwId)
.build());
}
@Test
@Order(24)
@DisplayName("AssociateRouteTable - associate with subnet")
void associateRouteTable() {
AssociateRouteTableResponse resp = ec2.associateRouteTable(
AssociateRouteTableRequest.builder()
.routeTableId(rtId)
.subnetId(subnetId)
.build());
rtbAssocId = resp.associationId();
assertThat(rtbAssocId).isNotNull().startsWith("rtbassoc-");
}
@Test
@Order(25)
@DisplayName("AllocateAddress - allocate EIP")
void allocateAddress() {
AllocateAddressResponse resp = ec2.allocateAddress(AllocateAddressRequest.builder()
.domain(DomainType.VPC).build());
allocationId = resp.allocationId();
assertThat(allocationId).isNotNull().startsWith("eipalloc-");
assertThat(resp.publicIp()).isNotNull();
}
@Test
@Order(26)
@DisplayName("DescribeAddresses - EIP exists")
void describeAddresses() {
DescribeAddressesResponse resp = ec2.describeAddresses(
DescribeAddressesRequest.builder()
.allocationIds(allocationId).build());
assertThat(resp.addresses()).hasSize(1);
assertThat(resp.addresses().get(0).allocationId()).isEqualTo(allocationId);
}
@Test
@Order(27)
@DisplayName("RunInstances - launch EC2 instance")
void runInstances() {
RunInstancesResponse resp = ec2.runInstances(RunInstancesRequest.builder()
.imageId("ami-0abcdef1234567890")
.instanceType(InstanceType.T2_MICRO)
.minCount(1)
.maxCount(1)
.keyName(keyName)
.subnetId(subnetId)
.securityGroupIds(List.of(sgId))
.build());
instanceId = resp.instances().get(0).instanceId();
Instance launched = resp.instances().get(0);
assertThat(instanceId).isNotNull().startsWith("i-");
assertThat(launched.state().name()).isEqualTo(InstanceStateName.PENDING);
assertThat(launched.instanceType()).isEqualTo(InstanceType.T2_MICRO);
assertThat(launched.keyName()).isEqualTo(keyName);
}
@Test
@Order(28)
@DisplayName("DescribeInstances - by ID")
void describeInstancesById() throws InterruptedException {
Instance found = waitForState(instanceId, InstanceStateName.RUNNING);
assertThat(found.instanceId()).isEqualTo(instanceId);
assertThat(found.state().name()).isEqualTo(InstanceStateName.RUNNING);
}
@Test
@Order(29)
@DisplayName("DescribeInstances - filter by state")
void describeInstancesFilterByState() {
DescribeInstancesResponse resp = ec2.describeInstances(
DescribeInstancesRequest.builder()
.filters(Filter.builder()
.name("instance-state-name")
.values("running")
.build())
.build());
boolean found = resp.reservations().stream()
.flatMap(r -> r.instances().stream())
.anyMatch(i -> instanceId.equals(i.instanceId()));
assertThat(found).isTrue();
}
@Test
@Order(30)
@DisplayName("DescribeInstanceStatus - instance status available")
void describeInstanceStatus() {
DescribeInstanceStatusResponse resp = ec2.describeInstanceStatus(
DescribeInstanceStatusRequest.builder().instanceIds(instanceId).build());
assertThat(resp.instanceStatuses()).isNotEmpty();
assertThat(resp.instanceStatuses().get(0).instanceId()).isEqualTo(instanceId);
}
@Test
@Order(31)
@DisplayName("AssociateAddress - associate EIP to instance")
void associateAddress() {
AssociateAddressResponse resp = ec2.associateAddress(
AssociateAddressRequest.builder()
.allocationId(allocationId)
.instanceId(instanceId)
.build());
String assocId = resp.associationId();
assertThat(assocId).isNotNull().startsWith("eipassoc-");
}
@Test
@Order(32)
@DisplayName("DisassociateAddress - disassociate EIP")
void disassociateAddress() {
// Get the association ID first
DescribeAddressesResponse addrResp = ec2.describeAddresses(
DescribeAddressesRequest.builder().allocationIds(allocationId).build());
String assocId = addrResp.addresses().get(0).associationId();
if (assocId != null) {
ec2.disassociateAddress(DisassociateAddressRequest.builder()
.associationId(assocId).build());
}
}
@Test
@Order(33)
@DisplayName("StopInstances - stop instance")
void stopInstances() {
StopInstancesResponse resp = ec2.stopInstances(StopInstancesRequest.builder()
.instanceIds(instanceId).build());
assertThat(resp.stoppingInstances().get(0).currentState().name())
.isEqualTo(InstanceStateName.STOPPING);
}
@Test
@Order(34)
@DisplayName("StartInstances - start instance")
void startInstances() throws InterruptedException {
waitForState(instanceId, InstanceStateName.STOPPED);
StartInstancesResponse resp = ec2.startInstances(StartInstancesRequest.builder()
.instanceIds(instanceId).build());
assertThat(resp.startingInstances().get(0).currentState().name())
.isEqualTo(InstanceStateName.PENDING);
}
@Test
@Order(35)
@DisplayName("RebootInstances - reboot instance")
void rebootInstances() {
ec2.rebootInstances(RebootInstancesRequest.builder()
.instanceIds(instanceId).build());
}
@Test
@Order(36)
@DisplayName("DescribeInstances - non-existent ID returns error")
void describeInstancesNotFound() {
assertThatThrownBy(() -> ec2.describeInstances(DescribeInstancesRequest.builder()
.instanceIds("i-0000000000000dead").build()))
.isInstanceOf(Ec2Exception.class)
.satisfies(e -> {
Ec2Exception ec2Ex = (Ec2Exception) e;
assertThat(ec2Ex.awsErrorDetails().errorCode()).isEqualTo("InvalidInstanceID.NotFound");
});
}
@Test
@Order(37)
@DisplayName("CreateTags - add tags to instance")
void createTags() {
ec2.createTags(CreateTagsRequest.builder()
.resources(instanceId)
.tags(software.amazon.awssdk.services.ec2.model.Tag.builder().key("Name").value("sdk-test-instance").build())
.build());
}
@Test
@Order(38)
@DisplayName("DescribeInstances - tags reflected on instance")
void describeInstancesTagsReflected() {
DescribeInstancesResponse resp = ec2.describeInstances(
DescribeInstancesRequest.builder().instanceIds(instanceId).build());
boolean hasTag = resp.reservations().get(0).instances().get(0).tags().stream()
.anyMatch(t -> "Name".equals(t.key()) && "sdk-test-instance".equals(t.value()));
assertThat(hasTag).isTrue();
}
@Test
@Order(39)
@DisplayName("TerminateInstances - terminate instance")
void terminateInstances() throws InterruptedException {
waitForState(instanceId, InstanceStateName.RUNNING);
TerminateInstancesResponse resp = ec2.terminateInstances(
TerminateInstancesRequest.builder().instanceIds(instanceId).build());
assertThat(resp.terminatingInstances().get(0).currentState().name())
.isEqualTo(InstanceStateName.SHUTTING_DOWN);
}
@Test
@Order(40)
@DisplayName("ReleaseAddress - release EIP")
void releaseAddress() {
ec2.releaseAddress(ReleaseAddressRequest.builder()
.allocationId(allocationId).build());
allocationId = null;
}
@Test
@Order(41)
@DisplayName("DisassociateRouteTable - disassociate route table")
void disassociateRouteTable() {
ec2.disassociateRouteTable(DisassociateRouteTableRequest.builder()
.associationId(rtbAssocId).build());
rtbAssocId = null;
}
@Test
@Order(42)
@DisplayName("DetachAndDeleteInternetGateway - cleanup IGW")
void detachAndDeleteInternetGateway() {
ec2.detachInternetGateway(DetachInternetGatewayRequest.builder()
.internetGatewayId(igwId).vpcId(vpcId).build());
ec2.deleteInternetGateway(DeleteInternetGatewayRequest.builder()
.internetGatewayId(igwId).build());
igwId = null;
}
@Test
@Order(43)
@DisplayName("DeleteRouteTable - delete route table")
void deleteRouteTable() {
ec2.deleteRouteTable(DeleteRouteTableRequest.builder()
.routeTableId(rtId).build());
rtId = null;
}
@Test
@Order(44)
@DisplayName("DeleteSubnet - delete subnet")
void deleteSubnet() {
ec2.deleteSubnet(DeleteSubnetRequest.builder().subnetId(subnetId).build());
subnetId = null;
}
@Test
@Order(45)
@DisplayName("DeleteSecurityGroup - delete security group")
void deleteSecurityGroup() {
ec2.deleteSecurityGroup(DeleteSecurityGroupRequest.builder()
.groupId(sgId).build());
sgId = null;
}
@Test
@Order(46)
@DisplayName("DeleteKeyPair - delete key pair")
void deleteKeyPair() {
DeleteKeyPairResponse resp = ec2.deleteKeyPair(DeleteKeyPairRequest.builder().keyName(keyName).build());
assertThat(resp.returnValue()).isTrue();
assertThat(resp.keyPairId()).isNotNull().startsWith("key-");
}
@Test
@Order(47)
@DisplayName("ModifyVpcAttribute - set enableDnsSupport=false")
void modifyVpcAttributeDnsSupport() {
ec2.modifyVpcAttribute(r -> r.vpcId(vpcId)
.enableDnsSupport(a -> a.value(false)));
}
@Test
@Order(48)
@DisplayName("DescribeVpcAttribute - enableDnsSupport round-trip")
void describeVpcAttributeDnsSupport() {
DescribeVpcAttributeResponse resp = ec2.describeVpcAttribute(r -> r
.vpcId(vpcId)
.attribute(VpcAttributeName.ENABLE_DNS_SUPPORT));
assertThat(resp.vpcId()).isEqualTo(vpcId);
assertThat(resp.enableDnsSupport().value()).isFalse();
}
@Test
@Order(49)
@DisplayName("DescribeVpcEndpointServices - lists the common services with AZs")
void describeVpcEndpointServices() {
DescribeVpcEndpointServicesResponse resp = ec2.describeVpcEndpointServices(
DescribeVpcEndpointServicesRequest.builder().build());
assertThat(resp.serviceNames()).contains("com.amazonaws.us-east-1.s3");
assertThat(resp.serviceDetails()).isNotEmpty();
ServiceDetail s3 = resp.serviceDetails().stream()
.filter(d -> d.serviceName().equals("com.amazonaws.us-east-1.s3"))
.findFirst()
.orElseThrow();
// S3 is the one service offering both endpoint types.
assertThat(s3.serviceType().stream().map(ServiceTypeDetail::serviceTypeAsString).toList())
.containsExactlyInAnyOrder("Gateway", "Interface");
assertThat(s3.availabilityZones()).isNotEmpty();
ServiceDetail ecr = resp.serviceDetails().stream()
.filter(d -> d.serviceName().equals("com.amazonaws.us-east-1.ecr.api"))
.findFirst()
.orElseThrow();
assertThat(ecr.serviceType().stream().map(ServiceTypeDetail::serviceTypeAsString).toList())
.containsExactly("Interface");
}
@Test
@Order(50)
@DisplayName("DeleteVpc - delete VPC")
void deleteVpc() {
ec2.deleteVpc(DeleteVpcRequest.builder().vpcId(vpcId).build());
vpcId = null;
}
}

Some files were not shown because too many files have changed in this diff Show More