second first commit
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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
+227
@@ -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");
|
||||
}
|
||||
}
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
}
|
||||
+38
@@ -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());
|
||||
}
|
||||
}
|
||||
+91
@@ -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();
|
||||
}
|
||||
}
|
||||
+322
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+514
@@ -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");
|
||||
}
|
||||
}
|
||||
+530
@@ -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");
|
||||
}
|
||||
}
|
||||
+787
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+462
@@ -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();
|
||||
}
|
||||
}
|
||||
+235
@@ -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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+138
@@ -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);
|
||||
}
|
||||
}
|
||||
+171
@@ -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);
|
||||
}
|
||||
}
|
||||
+116
@@ -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);
|
||||
}
|
||||
}
|
||||
+146
@@ -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");
|
||||
}
|
||||
}
|
||||
+81
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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);
|
||||
}
|
||||
}
|
||||
+67
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -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);
|
||||
}
|
||||
}
|
||||
+222
@@ -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");
|
||||
}
|
||||
}
|
||||
+239
@@ -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;
|
||||
}
|
||||
}
|
||||
+70
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
@@ -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");
|
||||
}
|
||||
}
|
||||
+208
@@ -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");
|
||||
}
|
||||
}
|
||||
+195
@@ -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");
|
||||
}
|
||||
}
|
||||
+201
@@ -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");
|
||||
}
|
||||
}
|
||||
+212
@@ -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");
|
||||
}
|
||||
}
|
||||
+236
@@ -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");
|
||||
}
|
||||
}
|
||||
+171
@@ -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");
|
||||
}
|
||||
}
|
||||
+270
@@ -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);
|
||||
}
|
||||
}
|
||||
+328
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+272
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+170
@@ -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);
|
||||
}
|
||||
}
|
||||
+162
@@ -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);
|
||||
}
|
||||
}
|
||||
+79
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -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();
|
||||
}
|
||||
}
|
||||
+174
@@ -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();
|
||||
}
|
||||
}
|
||||
+193
@@ -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);
|
||||
}
|
||||
}
|
||||
+92
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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));
|
||||
}
|
||||
}
|
||||
+48
@@ -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)));
|
||||
}
|
||||
}
|
||||
+134
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -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 + "\"}}";
|
||||
}
|
||||
}
|
||||
+424
@@ -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"));
|
||||
}
|
||||
}
|
||||
+445
@@ -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();
|
||||
}
|
||||
}
|
||||
+250
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+536
@@ -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);
|
||||
}
|
||||
}
|
||||
+365
@@ -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);
|
||||
}
|
||||
}
|
||||
+69
@@ -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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -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://<domain> 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);
|
||||
}
|
||||
}
|
||||
+71
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+290
@@ -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();
|
||||
}
|
||||
}
|
||||
+288
@@ -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();
|
||||
}
|
||||
}
|
||||
+866
@@ -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();
|
||||
}
|
||||
}
|
||||
+190
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+239
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+407
@@ -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);
|
||||
}
|
||||
}
|
||||
+157
@@ -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);
|
||||
}
|
||||
}
|
||||
+368
@@ -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-");
|
||||
}
|
||||
}
|
||||
+136
@@ -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);
|
||||
}
|
||||
}
|
||||
+67
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+355
@@ -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;
|
||||
}
|
||||
}
|
||||
+191
@@ -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();
|
||||
}
|
||||
}
|
||||
+75
@@ -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
Reference in New Issue
Block a user