second first commit

This commit is contained in:
Zakaria
2026-09-22 08:57:00 -04:00
parent e88d7e2ff3
commit 0aef7fb2d8
4766 changed files with 1236986 additions and 0 deletions
@@ -0,0 +1,19 @@
FROM amazon/aws-cli:latest
RUN yum install -y git jq bc openssl parallel
WORKDIR /app
# Install bats and helpers
RUN git clone --depth 1 https://github.com/bats-core/bats-core.git /opt/bats-core \
&& git clone --depth 1 https://github.com/bats-core/bats-support.git /opt/bats-support \
&& git clone --depth 1 https://github.com/bats-core/bats-assert.git /opt/bats-assert
COPY test/ test/
COPY run-bats-in-container.sh /usr/local/bin/run-bats-in-container.sh
ENV FLOCI_ENDPOINT=http://floci:4566
ENV BATS_LIB_PATH=/opt
RUN chmod +x /usr/local/bin/run-bats-in-container.sh && mkdir -p /results
ENTRYPOINT ["run-bats-in-container.sh"]
@@ -0,0 +1,57 @@
# sdk-test-awscli
Compatibility tests for [Floci](https://github.com/hectorvent/floci) using the **AWS CLI v2 (2.22.35)**.
Tests are plain bash scripts that call `aws` CLI commands with `--endpoint-url` pointed at the emulator.
## Services Covered
| Group | Description |
| ------------------ | ---------------------------------------------------- |
| `ssm` | Parameter Store — put, get, path, tags |
| `sqs` | Queues, send/receive/delete, attributes |
| `sns` | Topics, publish, attributes |
| `s3` | Buckets, objects, tagging, copy, delete |
| `dynamodb` | Tables, put/get/update/query/delete items |
| `iam` | Users, roles, create/get/delete |
| `sts` | GetCallerIdentity |
| `ses` | Identities, sending, quotas, notification attributes |
| `secretsmanager` | Create/get/put/list/tag/delete secrets |
| `kms` | Keys, aliases, encrypt/decrypt |
| `cognito` | User pools, clients |
| `s3-notifications` | S3 → SQS event notifications |
## Requirements
- AWS CLI v2
- bash
- jq
- bats-core (installed via `just setup-awscli`)
## Running
```bash
# All groups (from compatibility-tests/)
just test-awscli
# Run bats directly
./lib/run-bats-with-junit.sh sdk-test-awscli/test/ sdk-test-awscli/test-results/junit.xml
```
## Configuration
| Variable | Default | Description |
| ---------------- | ----------------------- | ----------------------- |
| `FLOCI_ENDPOINT` | `http://localhost:4566` | Floci emulator endpoint |
AWS credentials are always `test` / `test` / `us-east-1`.
## Docker
```bash
docker build -t floci-sdk-awscli .
docker run --rm --network host floci-sdk-awscli
# Custom endpoint (macOS/Windows)
docker run --rm -e FLOCI_ENDPOINT=http://host.docker.internal:4566 floci-sdk-awscli
```
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
report_dir="$(mktemp -d /tmp/bats-junit-XXXXXX)"
trap 'rm -rf "$report_dir"' EXIT
set +e
# --no-parallelize-within-files: bats-core defaults to running tests in parallel
# both across files and within a file when --jobs > 1. Several tests in this
# suite share state across tests in the same file via setup_file/teardown_file
# (e.g. ses.bats, s3-notifications.bats), which races ordering-dependent tests.
# Cross-file parallelism is preserved.
/opt/bats-core/bin/bats --jobs 4 --no-parallelize-within-files \
--report-formatter junit -o "$report_dir" test/
status=$?
set -e
if [ -f "$report_dir/report.xml" ]; then
mv "$report_dir/report.xml" /results/junit.xml
fi
exit "$status"
@@ -0,0 +1,238 @@
#!/usr/bin/env bats
# ACM integration tests
setup() {
load 'test_helper/common-setup'
CERT_ARN=""
IMPORTED_CERT_ARN=""
}
teardown() {
if [ -n "$CERT_ARN" ]; then
aws_cmd acm delete-certificate --certificate-arn "$CERT_ARN" >/dev/null 2>&1 || true
fi
if [ -n "$IMPORTED_CERT_ARN" ]; then
aws_cmd acm delete-certificate --certificate-arn "$IMPORTED_CERT_ARN" >/dev/null 2>&1 || true
fi
}
# ============================================
# US1: Certificate Lifecycle
# ============================================
@test "ACM: request certificate" {
run aws_cmd acm request-certificate --domain-name "cli-test.example.com" --validation-method DNS
assert_success
CERT_ARN=$(json_get "$output" '.CertificateArn')
[ -n "$CERT_ARN" ]
[[ "$CERT_ARN" =~ ^arn:aws:acm: ]]
}
@test "ACM: describe certificate" {
out=$(aws_cmd acm request-certificate --domain-name "cli-test.example.com" --validation-method DNS)
CERT_ARN=$(json_get "$out" '.CertificateArn')
run aws_cmd acm describe-certificate --certificate-arn "$CERT_ARN"
assert_success
domain=$(json_get "$output" '.Certificate.DomainName')
status=$(json_get "$output" '.Certificate.Status')
[ "$domain" = "cli-test.example.com" ]
[ "$status" = "ISSUED" ]
}
@test "ACM: get certificate" {
out=$(aws_cmd acm request-certificate --domain-name "cli-test.example.com" --validation-method DNS)
CERT_ARN=$(json_get "$out" '.CertificateArn')
run aws_cmd acm get-certificate --certificate-arn "$CERT_ARN"
assert_success
cert=$(json_get "$output" '.Certificate')
[ -n "$cert" ]
[[ "$cert" =~ "BEGIN CERTIFICATE" ]]
}
@test "ACM: list certificates" {
out=$(aws_cmd acm request-certificate --domain-name "cli-test.example.com" --validation-method DNS)
CERT_ARN=$(json_get "$out" '.CertificateArn')
run aws_cmd acm list-certificates
assert_success
found=$(echo "$output" | jq --arg arn "$CERT_ARN" '.CertificateSummaryList | any(.CertificateArn == $arn)')
[ "$found" = "true" ]
}
@test "ACM: delete certificate" {
out=$(aws_cmd acm request-certificate --domain-name "cli-test.example.com" --validation-method DNS)
CERT_ARN=$(json_get "$out" '.CertificateArn')
run aws_cmd acm delete-certificate --certificate-arn "$CERT_ARN"
assert_success
CERT_ARN=""
# Verify it's gone
run aws_cmd acm describe-certificate --certificate-arn "$(json_get "$out" '.CertificateArn')"
assert_failure
}
# ============================================
# US2: Import and Export
# ============================================
@test "ACM: import certificate" {
# Generate self-signed certificate with openssl
local key_file cert_file
key_file=$(mktemp)
cert_file=$(mktemp)
openssl req -x509 -newkey rsa:2048 -keyout "$key_file" -out "$cert_file" \
-days 365 -nodes -subj "/CN=cli-import.example.com" 2>/dev/null
run aws_cmd acm import-certificate \
--certificate "fileb://$cert_file" \
--private-key "fileb://$key_file"
rm -f "$key_file" "$cert_file"
assert_success
IMPORTED_CERT_ARN=$(json_get "$output" '.CertificateArn')
[ -n "$IMPORTED_CERT_ARN" ]
}
@test "ACM: get imported certificate" {
local key_file cert_file
key_file=$(mktemp)
cert_file=$(mktemp)
openssl req -x509 -newkey rsa:2048 -keyout "$key_file" -out "$cert_file" \
-days 365 -nodes -subj "/CN=cli-import.example.com" 2>/dev/null
out=$(aws_cmd acm import-certificate \
--certificate "fileb://$cert_file" \
--private-key "fileb://$key_file")
rm -f "$key_file" "$cert_file"
IMPORTED_CERT_ARN=$(json_get "$out" '.CertificateArn')
run aws_cmd acm get-certificate --certificate-arn "$IMPORTED_CERT_ARN"
assert_success
cert=$(json_get "$output" '.Certificate')
[[ "$cert" =~ "BEGIN CERTIFICATE" ]]
}
@test "ACM: export certificate" {
local key_file cert_file
key_file=$(mktemp)
cert_file=$(mktemp)
openssl req -x509 -newkey rsa:2048 -keyout "$key_file" -out "$cert_file" \
-days 365 -nodes -subj "/CN=cli-export.example.com" 2>/dev/null
out=$(aws_cmd acm import-certificate \
--certificate "fileb://$cert_file" \
--private-key "fileb://$key_file")
rm -f "$key_file" "$cert_file"
IMPORTED_CERT_ARN=$(json_get "$out" '.CertificateArn')
run aws_cmd acm export-certificate \
--certificate-arn "$IMPORTED_CERT_ARN" \
--passphrase "dGVzdC1wYXNzcGhyYXNl"
assert_success
cert=$(json_get "$output" '.Certificate')
[ -n "$cert" ]
pk=$(json_get "$output" '.PrivateKey')
[ -n "$pk" ]
}
@test "ACM: export requested certificate fails" {
out=$(aws_cmd acm request-certificate --domain-name "cli-noexport.example.com" --validation-method DNS)
CERT_ARN=$(json_get "$out" '.CertificateArn')
run aws_cmd acm export-certificate \
--certificate-arn "$CERT_ARN" \
--passphrase "dGVzdC1wYXNzcGhyYXNl"
assert_failure
}
# ============================================
# US3: Tagging
# ============================================
@test "ACM: add and list tags" {
out=$(aws_cmd acm request-certificate --domain-name "cli-tag.example.com" --validation-method DNS)
CERT_ARN=$(json_get "$out" '.CertificateArn')
run aws_cmd acm add-tags-to-certificate \
--certificate-arn "$CERT_ARN" \
--tags Key=Env,Value=test Key=Project,Value=floci
assert_success
run aws_cmd acm list-tags-for-certificate --certificate-arn "$CERT_ARN"
assert_success
env_tag=$(echo "$output" | jq '.Tags[] | select(.Key == "Env") | .Value' -r)
[ "$env_tag" = "test" ]
project_tag=$(echo "$output" | jq '.Tags[] | select(.Key == "Project") | .Value' -r)
[ "$project_tag" = "floci" ]
}
@test "ACM: remove tags" {
out=$(aws_cmd acm request-certificate --domain-name "cli-untag.example.com" --validation-method DNS)
CERT_ARN=$(json_get "$out" '.CertificateArn')
aws_cmd acm add-tags-to-certificate \
--certificate-arn "$CERT_ARN" \
--tags Key=Env,Value=test Key=Project,Value=floci
run aws_cmd acm remove-tags-from-certificate \
--certificate-arn "$CERT_ARN" \
--tags Key=Env
assert_success
run aws_cmd acm list-tags-for-certificate --certificate-arn "$CERT_ARN"
assert_success
env_gone=$(echo "$output" | jq '.Tags[] | select(.Key == "Env")' 2>/dev/null)
[ -z "$env_gone" ]
project_tag=$(echo "$output" | jq '.Tags[] | select(.Key == "Project") | .Value' -r)
[ "$project_tag" = "floci" ]
}
# ============================================
# US4: Account Configuration
# ============================================
@test "ACM: put and get account configuration" {
run aws_cmd acm put-account-configuration \
--expiry-events DaysBeforeExpiry=45 \
--idempotency-token "cli-test-$(date +%s)"
assert_success
run aws_cmd acm get-account-configuration
assert_success
days=$(json_get "$output" '.ExpiryEvents.DaysBeforeExpiry')
[ "$days" = "45" ]
}
# ============================================
# US5: Error Handling
# ============================================
@test "ACM: describe non-existent certificate" {
run aws_cmd acm describe-certificate \
--certificate-arn "arn:aws:acm:us-east-1:000000000000:certificate/00000000-0000-0000-0000-000000000000"
assert_failure
}
@test "ACM: request certificate with SANs" {
run aws_cmd acm request-certificate \
--domain-name "cli-san.example.com" \
--validation-method DNS \
--subject-alternative-names "alt1.example.com" "alt2.example.com"
assert_success
CERT_ARN=$(json_get "$output" '.CertificateArn')
run aws_cmd acm describe-certificate --certificate-arn "$CERT_ARN"
assert_success
sans=$(json_get "$output" '.Certificate.SubjectAlternativeNames')
[[ "$sans" =~ "alt1.example.com" ]]
[[ "$sans" =~ "alt2.example.com" ]]
}
@test "ACM: import invalid PEM" {
run aws_cmd acm import-certificate \
--certificate "not-valid-pem-data" \
--private-key "also-not-valid-pem"
assert_failure
}
@@ -0,0 +1,310 @@
#!/usr/bin/env bats
# CloudFormation tests
setup() {
load 'test_helper/common-setup'
STACK_NAME="bats-cfn-stack-$(unique_name)"
TEMPLATE_FILE=$(mktemp /tmp/cfn-bats-XXXXXX.yaml)
}
teardown() {
aws_cmd cloudformation delete-stack --stack-name "$STACK_NAME" >/dev/null 2>&1 || true
[ -n "$TEMPLATE_FILE" ] && rm -f "$TEMPLATE_FILE"
}
# ── CreateStack / DescribeStacks ──────────────────────────────────────────────
@test "CloudFormation: create stack reaches CREATE_COMPLETE" {
cat > "$TEMPLATE_FILE" << 'EOF'
AWSTemplateFormatVersion: '2010-09-09'
Resources:
MyQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: bats-cfn-basic-queue
EOF
run aws_cmd cloudformation create-stack \
--stack-name "$STACK_NAME" \
--template-body "file://$TEMPLATE_FILE"
assert_success
run aws_cmd cloudformation describe-stacks --stack-name "$STACK_NAME"
assert_success
local stack_status
stack_status=$(json_get "$output" '.Stacks[0].StackStatus')
[ "$stack_status" = "CREATE_COMPLETE" ]
}
@test "CloudFormation: describe-stack-resources lists provisioned resources" {
cat > "$TEMPLATE_FILE" << 'EOF'
AWSTemplateFormatVersion: '2010-09-09'
Resources:
MyQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: bats-cfn-resources-queue
EOF
aws_cmd cloudformation create-stack \
--stack-name "$STACK_NAME" \
--template-body "file://$TEMPLATE_FILE" >/dev/null
run aws_cmd cloudformation describe-stack-resources --stack-name "$STACK_NAME"
assert_success
local count
count=$(json_get "$output" '.StackResources | length')
[ "$count" -gt 0 ]
}
@test "CloudFormation: delete stack removes resources" {
cat > "$TEMPLATE_FILE" << 'EOF'
AWSTemplateFormatVersion: '2010-09-09'
Resources:
MyQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: bats-cfn-delete-queue
EOF
aws_cmd cloudformation create-stack \
--stack-name "$STACK_NAME" \
--template-body "file://$TEMPLATE_FILE" >/dev/null
run aws_cmd cloudformation delete-stack --stack-name "$STACK_NAME"
assert_success
run aws_cmd cloudformation describe-stacks --stack-name "$STACK_NAME"
# Stack should no longer exist
[[ "$output" == *"does not exist"* ]]
STACK_NAME="" # prevent teardown from trying again
}
# ── aws cloudformation deploy (CreateChangeSet + ExecuteChangeSet by ARN) ─────
#
# Regression: DescribeChangeSet / ExecuteChangeSet failed when called with the
# changeset ARN (the AWS CLI always passes the ARN, not the short name).
# See: https://github.com/floci-io/floci/issues/606
@test "CloudFormation: deploy creates stack via changeset" {
cat > "$TEMPLATE_FILE" << 'EOF'
AWSTemplateFormatVersion: '2010-09-09'
Resources:
MyQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: bats-cfn-deploy-queue
EOF
run aws_cmd cloudformation deploy \
--stack-name "$STACK_NAME" \
--template-file "$TEMPLATE_FILE"
assert_success
run aws_cmd cloudformation describe-stacks --stack-name "$STACK_NAME"
assert_success
local stack_status
stack_status=$(json_get "$output" '.Stacks[0].StackStatus')
[ "$stack_status" = "CREATE_COMPLETE" ]
}
@test "CloudFormation: deploy provisions resources correctly" {
cat > "$TEMPLATE_FILE" << 'EOF'
AWSTemplateFormatVersion: '2010-09-09'
Resources:
MyQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: bats-cfn-deploy-res-queue
EOF
aws_cmd cloudformation deploy \
--stack-name "$STACK_NAME" \
--template-file "$TEMPLATE_FILE" >/dev/null
run aws_cmd cloudformation describe-stack-resources --stack-name "$STACK_NAME"
assert_success
local resource_status
resource_status=$(json_get "$output" '.StackResources[0].ResourceStatus')
[ "$resource_status" = "CREATE_COMPLETE" ]
}
# ── EC2 VPC/Subnet provisioning (regression: issue #1297) ─────────────────────
#
# CloudFormation must create AWS::EC2::VPC / AWS::EC2::Subnet resources for real
# (in EC2), not stub them. Before the fix, describe-stacks reported CREATE_COMPLETE
# and exported the subnet ids, but ec2 describe-subnets did not return them and
# elbv2 create-load-balancer failed with "The subnet ID '...' does not exist".
# https://github.com/floci-io/floci/issues/1297
@test "CloudFormation: VPC/subnet stack exports subnet ids that EC2 and ELBv2 can use" {
cat > "$TEMPLATE_FILE" << 'EOF'
{
"Resources": {
"Vpc": {
"Type": "AWS::EC2::VPC",
"Properties": {"CidrBlock": "10.20.0.0/16"}
},
"Subnet1": {
"Type": "AWS::EC2::Subnet",
"Properties": {
"VpcId": {"Ref": "Vpc"},
"CidrBlock": "10.20.1.0/24",
"AvailabilityZone": "us-east-1a"
}
},
"Subnet2": {
"Type": "AWS::EC2::Subnet",
"Properties": {
"VpcId": {"Ref": "Vpc"},
"CidrBlock": "10.20.2.0/24",
"AvailabilityZone": "us-east-1b"
}
}
},
"Outputs": {
"Subnet1Id": {"Value": {"Ref": "Subnet1"}},
"Subnet2Id": {"Value": {"Ref": "Subnet2"}}
}
}
EOF
run aws_cmd cloudformation create-stack \
--stack-name "$STACK_NAME" \
--template-body "file://$TEMPLATE_FILE"
assert_success
run aws_cmd cloudformation describe-stacks --stack-name "$STACK_NAME"
assert_success
local stack_status
stack_status=$(json_get "$output" '.Stacks[0].StackStatus')
[ "$stack_status" = "CREATE_COMPLETE" ]
# The exported subnet ids must be real subnet-xxxx ids (not the old stub shape).
local subnet1 subnet2
subnet1=$(json_get "$output" '.Stacks[0].Outputs[] | select(.OutputKey=="Subnet1Id") | .OutputValue')
subnet2=$(json_get "$output" '.Stacks[0].Outputs[] | select(.OutputKey=="Subnet2Id") | .OutputValue')
[[ "$subnet1" == subnet-* ]]
[[ "$subnet2" == subnet-* ]]
# describe-subnets must return the exact subnet ids CloudFormation exported.
run aws_cmd ec2 describe-subnets --subnet-ids "$subnet1" "$subnet2"
assert_success
local count
count=$(json_get "$output" '.Subnets | length')
[ "$count" -eq 2 ]
# ELBv2 create-load-balancer must accept the exported subnets (no SubnetNotFound).
run aws_cmd elbv2 create-load-balancer \
--name "bats-vpc-alb-${RANDOM}" \
--type application \
--scheme internal \
--subnets "$subnet1" "$subnet2"
assert_success
local lb_arn
lb_arn=$(json_get "$output" '.LoadBalancers[0].LoadBalancerArn')
[ -n "$lb_arn" ]
[[ "$lb_arn" == *":loadbalancer/"* ]]
}
# ── DeleteStack with managed S3 buckets (regression: issue #1539) ──────────────
#
# Real AWS CloudFormation does not report a stack as DELETE_COMPLETE while a
# managed resource still exists. Deleting a stack that owns a NON-EMPTY
# AWS::S3::Bucket must put the stack into DELETE_FAILED (S3 DeleteBucket refuses
# a non-empty bucket) and leave the bucket in place — it must NOT silently report
# success while the bucket and its objects remain.
# An EMPTY managed bucket must be deleted as part of the stack delete.
# https://github.com/floci-io/floci/issues/1539
# Poll describe-stacks until the stack reaches the expected status, or report the
# special value DOES_NOT_EXIST once the stack is gone.
cfn_wait_status() {
local stack="$1" expected="$2" tries="${3:-40}"
local i out status
for ((i = 0; i < tries; i++)); do
# `|| true`: describe-stacks exits non-zero once the stack is gone, which
# would otherwise abort this command substitution under bats' errexit.
out=$(aws_cmd cloudformation describe-stacks --stack-name "$stack" 2>&1 || true)
if [[ "$out" == *"does not exist"* ]]; then
status="DOES_NOT_EXIST"
else
status=$(echo "$out" | jq -r '.Stacks[0].StackStatus' 2>/dev/null)
fi
if [ "$status" = "$expected" ]; then
return 0
fi
sleep 0.25
done
echo "timeout waiting for stack '$stack' to reach '$expected' (last status: '$status')" >&2
return 1
}
@test "CloudFormation: delete-stack with non-empty S3 bucket fails and keeps the bucket (issue #1539)" {
local bucket="bats-cfn-nonempty-$(unique_name bkt)"
cat > "$TEMPLATE_FILE" << EOF
{
"AWSTemplateFormatVersion": "2010-09-09",
"Resources": {
"Bucket": {
"Type": "AWS::S3::Bucket",
"Properties": { "BucketName": "$bucket" }
}
}
}
EOF
run aws_cmd cloudformation create-stack \
--stack-name "$STACK_NAME" \
--template-body "file://$TEMPLATE_FILE"
assert_success
cfn_wait_status "$STACK_NAME" "CREATE_COMPLETE"
# Put an object so the managed bucket is non-empty.
local body_file
body_file=$(mktemp)
echo -n "hello floci 1539" > "$body_file"
run aws_cmd s3api put-object --bucket "$bucket" --key "object.txt" --body "$body_file"
assert_success
rm -f "$body_file"
run aws_cmd cloudformation delete-stack --stack-name "$STACK_NAME"
assert_success
# AWS leaves the stack in DELETE_FAILED — it must NOT report DELETE_COMPLETE
# (i.e. the stack must NOT silently disappear).
cfn_wait_status "$STACK_NAME" "DELETE_FAILED"
# The managed bucket and its object must still exist after the failed delete.
run aws_cmd s3api head-bucket --bucket "$bucket"
assert_success
# Cleanup: empty + remove the leftover bucket, then drop the stack.
aws_cmd s3 rm "s3://$bucket" --recursive >/dev/null 2>&1 || true
aws_cmd s3api delete-bucket --bucket "$bucket" >/dev/null 2>&1 || true
}
@test "CloudFormation: delete-stack with empty S3 bucket removes the bucket" {
local bucket="bats-cfn-empty-$(unique_name bkt)"
cat > "$TEMPLATE_FILE" << EOF
{
"AWSTemplateFormatVersion": "2010-09-09",
"Resources": {
"Bucket": {
"Type": "AWS::S3::Bucket",
"Properties": { "BucketName": "$bucket" }
}
}
}
EOF
run aws_cmd cloudformation create-stack \
--stack-name "$STACK_NAME" \
--template-body "file://$TEMPLATE_FILE"
assert_success
cfn_wait_status "$STACK_NAME" "CREATE_COMPLETE"
run aws_cmd s3api head-bucket --bucket "$bucket"
assert_success
run aws_cmd cloudformation delete-stack --stack-name "$STACK_NAME"
assert_success
cfn_wait_status "$STACK_NAME" "DOES_NOT_EXIST"
STACK_NAME="" # prevent teardown from trying again
# The empty managed bucket must be gone.
run aws_cmd s3api head-bucket --bucket "$bucket"
assert_failure
}
@@ -0,0 +1,39 @@
#!/usr/bin/env bats
# CloudFront AWS CLI compatibility tests
setup() {
load 'test_helper/common-setup'
}
@test "CloudFront: list response headers policies decodes the modeled payload" {
run aws_cmd cloudfront list-response-headers-policies \
--type managed \
--max-items 1 \
--no-paginate
assert_success
[ "$(json_get "$output" '.ResponseHeadersPolicyList.Quantity')" = "1" ]
[ "$(json_get "$output" '.ResponseHeadersPolicyList.MaxItems')" = "1" ]
[ "$(json_get "$output" '.ResponseHeadersPolicyList.Items | length')" = "1" ]
[ "$(json_get "$output" '.ResponseHeadersPolicyList.Items[0].Type')" = "managed" ]
[ -n "$(json_get "$output" \
'.ResponseHeadersPolicyList.Items[0].ResponseHeadersPolicy.Id // empty')" ]
[ -n "$(json_get "$output" \
'.ResponseHeadersPolicyList.NextMarker // empty')" ]
first_id="$(json_get "$output" \
'.ResponseHeadersPolicyList.Items[0].ResponseHeadersPolicy.Id')"
next_marker="$(json_get "$output" \
'.ResponseHeadersPolicyList.NextMarker')"
run aws_cmd cloudfront list-response-headers-policies \
--type managed \
--marker "$next_marker" \
--max-items 1 \
--no-paginate
assert_success
[ "$(json_get "$output" '.ResponseHeadersPolicyList.Quantity')" = "1" ]
[ "$(json_get "$output" \
'.ResponseHeadersPolicyList.Items[0].ResponseHeadersPolicy.Id')" != "$first_id" ]
}
@@ -0,0 +1,256 @@
#!/usr/bin/env bats
# Cognito tests
setup() {
load 'test_helper/common-setup'
POOL_ID=""
CLIENT_ID=""
}
teardown() {
if [ -n "$POOL_ID" ]; then
aws_cmd cognito-idp delete-user-pool --user-pool-id "$POOL_ID" >/dev/null 2>&1 || true
fi
}
@test "Cognito: create user pool" {
run aws_cmd cognito-idp create-user-pool --pool-name "bats-test-pool-$(unique_name)"
assert_success
POOL_ID=$(json_get "$output" '.UserPool.Id')
[ -n "$POOL_ID" ]
}
@test "Cognito: create user pool client" {
out=$(aws_cmd cognito-idp create-user-pool --pool-name "bats-test-pool-$(unique_name)")
POOL_ID=$(json_get "$out" '.UserPool.Id')
run aws_cmd cognito-idp create-user-pool-client \
--user-pool-id "$POOL_ID" \
--client-name "bats-test-client"
assert_success
CLIENT_ID=$(json_get "$output" '.UserPoolClient.ClientId')
[ -n "$CLIENT_ID" ]
}
@test "Cognito: list user pool clients returns only description fields" {
out=$(aws_cmd cognito-idp create-user-pool --pool-name "bats-test-pool-$(unique_name)")
POOL_ID=$(json_get "$out" '.UserPool.Id')
aws_cmd cognito-idp create-user-pool-client \
--user-pool-id "$POOL_ID" \
--client-name "bats-list-client" \
--generate-secret >/dev/null
run aws_cmd cognito-idp list-user-pool-clients --user-pool-id "$POOL_ID"
assert_success
# Should have the required fields
client_id=$(echo "$output" | jq -r '.UserPoolClients[0].ClientId')
[ -n "$client_id" ]
client_name=$(echo "$output" | jq -r '.UserPoolClients[0].ClientName')
[ "$client_name" = "bats-list-client" ]
# Must NOT have fields that belong to the full UserPoolClient type
has_secret=$(echo "$output" | jq 'any(.UserPoolClients[]; has("ClientSecret"))')
[ "$has_secret" = "false" ]
has_generate=$(echo "$output" | jq 'any(.UserPoolClients[]; has("GenerateSecret"))')
[ "$has_generate" = "false" ]
has_flows=$(echo "$output" | jq 'any(.UserPoolClients[]; has("AllowedOAuthFlows"))')
[ "$has_flows" = "false" ]
}
@test "Cognito: admin create user" {
out=$(aws_cmd cognito-idp create-user-pool --pool-name "bats-test-pool-$(unique_name)")
POOL_ID=$(json_get "$out" '.UserPool.Id')
run aws_cmd cognito-idp admin-create-user \
--user-pool-id "$POOL_ID" \
--username "testuser" \
--temporary-password "Temp123!" \
--user-attributes Name=email,Value=test@example.com
assert_success
username=$(json_get "$output" '.User.Username')
[ "$username" = "testuser" ]
}
@test "Cognito: admin set user password" {
out=$(aws_cmd cognito-idp create-user-pool --pool-name "bats-test-pool-$(unique_name)")
POOL_ID=$(json_get "$out" '.UserPool.Id')
aws_cmd cognito-idp admin-create-user \
--user-pool-id "$POOL_ID" \
--username "testuser" \
--temporary-password "Temp123!" \
--user-attributes Name=email,Value=test@example.com >/dev/null
run aws_cmd cognito-idp admin-set-user-password \
--user-pool-id "$POOL_ID" \
--username "testuser" \
--password "Perm456!" \
--permanent
assert_success
}
@test "Cognito: admin get user" {
out=$(aws_cmd cognito-idp create-user-pool --pool-name "bats-test-pool-$(unique_name)")
POOL_ID=$(json_get "$out" '.UserPool.Id')
aws_cmd cognito-idp admin-create-user \
--user-pool-id "$POOL_ID" \
--username "testuser" \
--temporary-password "Temp123!" \
--user-attributes Name=email,Value=test@example.com >/dev/null
aws_cmd cognito-idp admin-set-user-password \
--user-pool-id "$POOL_ID" \
--username "testuser" \
--password "Perm456!" \
--permanent >/dev/null
run aws_cmd cognito-idp admin-get-user \
--user-pool-id "$POOL_ID" \
--username "testuser"
assert_success
status=$(json_get "$output" '.UserStatus')
[ "$status" = "CONFIRMED" ]
}
@test "Cognito: list users" {
out=$(aws_cmd cognito-idp create-user-pool --pool-name "bats-test-pool-$(unique_name)")
POOL_ID=$(json_get "$out" '.UserPool.Id')
aws_cmd cognito-idp admin-create-user \
--user-pool-id "$POOL_ID" \
--username "testuser" \
--temporary-password "Temp123!" >/dev/null
run aws_cmd cognito-idp list-users --user-pool-id "$POOL_ID"
assert_success
found=$(echo "$output" | jq '.Users | any(.Username == "testuser")')
[ "$found" = "true" ]
}
@test "Cognito: JWKS endpoint" {
out=$(aws_cmd cognito-idp create-user-pool --pool-name "bats-test-pool-$(unique_name)")
POOL_ID=$(json_get "$out" '.UserPool.Id')
run curl -sS "$FLOCI_ENDPOINT/$POOL_ID/.well-known/jwks.json"
assert_success
keys_count=$(echo "$output" | jq '.keys | length')
[ "$keys_count" -gt 0 ]
key_type=$(echo "$output" | jq -r '.keys[0].kty')
[ "$key_type" = "RSA" ]
alg=$(echo "$output" | jq -r '.keys[0].alg')
[ "$alg" = "RS256" ]
}
@test "Cognito: delete user pool" {
out=$(aws_cmd cognito-idp create-user-pool --pool-name "bats-test-pool-$(unique_name)")
POOL_ID=$(json_get "$out" '.UserPool.Id')
run aws_cmd cognito-idp delete-user-pool --user-pool-id "$POOL_ID"
assert_success
POOL_ID=""
}
@test "Cognito: create user pool with reserved override tag uses pinned ID and strips reserved tag" {
run aws_cmd cognito-idp create-user-pool \
--pool-name "bats-test-pool-$(unique_name)" \
--user-pool-tags floci:override-id=us-east-1_batspool1,env=test
assert_success
POOL_ID=$(json_get "$output" '.UserPool.Id')
[ "$POOL_ID" = "us-east-1_batspool1" ]
has_reserved=$(echo "$output" | jq '.UserPool.UserPoolTags | has("floci:override-id")')
[ "$has_reserved" = "false" ]
run aws_cmd cognito-idp describe-user-pool --user-pool-id "$POOL_ID"
assert_success
has_reserved=$(echo "$output" | jq '.UserPool.UserPoolTags | has("floci:override-id")')
[ "$has_reserved" = "false" ]
env_value=$(json_get "$output" '.UserPool.UserPoolTags.env')
[ "$env_value" = "test" ]
}
@test "Cognito: duplicate reserved override tag fails with ResourceConflictException" {
out=$(aws_cmd cognito-idp create-user-pool \
--pool-name "bats-test-pool-$(unique_name)" \
--user-pool-tags floci:override-id=us-east-1_batsdup01)
POOL_ID=$(json_get "$out" '.UserPool.Id')
run aws_cmd cognito-idp create-user-pool \
--pool-name "bats-test-pool-$(unique_name)" \
--user-pool-tags floci:override-id=us-east-1_batsdup01
assert_failure
[[ "$output" == *"ResourceConflictException"* ]]
}
@test "Cognito: tag-resource list-tags-for-resource and untag-resource manage user pool tags" {
out=$(aws_cmd cognito-idp create-user-pool --pool-name "bats-test-pool-$(unique_name)")
POOL_ID=$(json_get "$out" '.UserPool.Id')
RESOURCE_ARN=$(json_get "$out" '.UserPool.Arn')
run aws_cmd cognito-idp tag-resource \
--resource-arn "$RESOURCE_ARN" \
--tags env=test,team=platform
assert_success
run aws_cmd cognito-idp list-tags-for-resource --resource-arn "$RESOURCE_ARN"
assert_success
env_value=$(json_get "$output" '.Tags.env')
[ "$env_value" = "test" ]
team_value=$(json_get "$output" '.Tags.team')
[ "$team_value" = "platform" ]
run aws_cmd cognito-idp untag-resource \
--resource-arn "$RESOURCE_ARN" \
--tag-keys team
assert_success
run aws_cmd cognito-idp list-tags-for-resource --resource-arn "$RESOURCE_ARN"
assert_success
env_value=$(json_get "$output" '.Tags.env')
[ "$env_value" = "test" ]
has_team=$(echo "$output" | jq '.Tags | has("team")')
[ "$has_team" = "false" ]
}
@test "Cognito: standalone tag-resource rejects reserved floci tags" {
out=$(aws_cmd cognito-idp create-user-pool --pool-name "bats-test-pool-$(unique_name)")
POOL_ID=$(json_get "$out" '.UserPool.Id')
RESOURCE_ARN=$(json_get "$out" '.UserPool.Arn')
run aws_cmd cognito-idp tag-resource \
--resource-arn "$RESOURCE_ARN" \
--tags floci:override-id=late-id
assert_failure
[[ "$output" == *"ValidationException"* ]]
}
@test "Cognito: describe-user-pool returns all 20 standard SchemaAttributes" {
out=$(aws_cmd cognito-idp create-user-pool --pool-name "bats-test-pool-$(unique_name)")
POOL_ID=$(json_get "$out" '.UserPool.Id')
run aws_cmd cognito-idp describe-user-pool --user-pool-id "$POOL_ID"
assert_success
count=$(echo "$output" | jq '.UserPool.SchemaAttributes | length')
[ "$count" -eq 20 ]
for attr in 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; do
found=$(echo "$output" | jq --arg n "$attr" '[.UserPool.SchemaAttributes[] | select(.Name == $n)] | length')
[ "$found" -eq 1 ] || { echo "missing standard attribute: $attr"; return 1; }
done
sub_required=$(echo "$output" | jq '[.UserPool.SchemaAttributes[] | select(.Name == "sub")] | .[0].Required')
[ "$sub_required" = "true" ]
sub_mutable=$(echo "$output" | jq '[.UserPool.SchemaAttributes[] | select(.Name == "sub")] | .[0].Mutable')
[ "$sub_mutable" = "false" ]
}
@@ -0,0 +1,854 @@
#!/usr/bin/env bats
# DynamoDB tests
setup() {
load 'test_helper/common-setup'
TABLE_NAME="bats-test-table-$(unique_name)"
}
teardown() {
aws_cmd dynamodb delete-table --table-name "$TABLE_NAME" >/dev/null 2>&1 || true
}
@test "DynamoDB: create table" {
run aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S AttributeName=sk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST
assert_success
status=$(json_get "$output" '.TableDescription.TableStatus')
[ "$status" = "ACTIVE" ] || [ "$status" = "CREATING" ]
}
@test "DynamoDB: describe table" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
run aws_cmd dynamodb describe-table --table-name "$TABLE_NAME"
assert_success
name=$(json_get "$output" '.Table.TableName')
[ "$name" = "$TABLE_NAME" ]
}
@test "DynamoDB: describe table by ARN" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
table_arn="arn:aws:dynamodb:${AWS_REGION:-us-east-1}:000000000000:table/$TABLE_NAME"
run aws_cmd dynamodb describe-table --table-name "$table_arn"
assert_success
name=$(json_get "$output" '.Table.TableName')
[ "$name" = "$TABLE_NAME" ]
}
@test "DynamoDB: list tables" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
run aws_cmd dynamodb list-tables
assert_success
found=$(echo "$output" | jq --arg name "$TABLE_NAME" '.TableNames | any(. == $name)')
[ "$found" = "true" ]
}
@test "DynamoDB: put and get item" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
run aws_cmd dynamodb put-item \
--table-name "$TABLE_NAME" \
--item '{"pk":{"S":"user#1"},"name":{"S":"Alice"}}'
assert_success
run aws_cmd dynamodb get-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"user#1"}}'
assert_success
name=$(json_get "$output" '.Item.name.S')
[ "$name" = "Alice" ]
}
@test "DynamoDB: update item" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
aws_cmd dynamodb put-item \
--table-name "$TABLE_NAME" \
--item '{"pk":{"S":"user#1"},"name":{"S":"Alice"}}' >/dev/null
run aws_cmd dynamodb update-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"user#1"}}' \
--update-expression 'SET #n = :v' \
--expression-attribute-names '{"#n":"name"}' \
--expression-attribute-values '{":v":{"S":"Bob"}}'
assert_success
run aws_cmd dynamodb get-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"user#1"}}'
assert_success
name=$(json_get "$output" '.Item.name.S')
[ "$name" = "Bob" ]
}
@test "DynamoDB: ADD resolves nested placeholder paths" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
aws_cmd dynamodb put-item \
--table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-1"},"functionInvocationSummaries":{"M":{"ISA_10136":{"M":{"eventsAvailable":{"N":"0"}}}}}}' >/dev/null
run aws_cmd dynamodb update-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"item-1"}}' \
--update-expression 'ADD #fis.#isa.#ea :one' \
--expression-attribute-names '{"#fis":"functionInvocationSummaries","#isa":"ISA_10136","#ea":"eventsAvailable"}' \
--expression-attribute-values '{":one":{"N":"1"}}'
assert_success
run aws_cmd dynamodb get-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"item-1"}}'
assert_success
nested_counter=$(json_get "$output" '.Item.functionInvocationSummaries.M.ISA_10136.M.eventsAvailable.N')
[ "$nested_counter" = "1" ]
phantom_attribute=$(echo "$output" | jq '.Item | has("#fis.#isa.#ea")')
[ "$phantom_attribute" = "false" ]
}
@test "DynamoDB: DELETE resolves nested placeholder paths" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
aws_cmd dynamodb put-item \
--table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-1"},"settings":{"M":{"labels":{"SS":["keep","drop"]}}}}' >/dev/null
run aws_cmd dynamodb update-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"item-1"}}' \
--update-expression 'DELETE #settings.#labels :labels' \
--expression-attribute-names '{"#settings":"settings","#labels":"labels"}' \
--expression-attribute-values '{":labels":{"SS":["drop"]}}'
assert_success
run aws_cmd dynamodb get-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"item-1"}}'
assert_success
labels=$(echo "$output" | jq -c '.Item.settings.M.labels.SS')
[ "$labels" = '["keep"]' ]
phantom_attribute=$(echo "$output" | jq '.Item | has("#settings.#labels")')
[ "$phantom_attribute" = "false" ]
run aws_cmd dynamodb update-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"item-1"}}' \
--update-expression 'DELETE #settings.#labels :labels' \
--expression-attribute-names '{"#settings":"settings","#labels":"labels"}' \
--expression-attribute-values '{":labels":{"SS":["keep"]}}'
assert_success
run aws_cmd dynamodb get-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"item-1"}}'
assert_success
nested_attribute=$(echo "$output" | jq '.Item.settings.M | has("labels")')
[ "$nested_attribute" = "false" ]
run aws_cmd dynamodb update-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"item-1"}}' \
--update-expression 'DELETE #settings.#labels :labels' \
--expression-attribute-names '{"#settings":"settings","#labels":"labels"}' \
--expression-attribute-values '{":labels":{"SS":["missing"]}}'
assert_success
}
@test "DynamoDB: scan table" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" --item '{"pk":{"S":"user#1"},"name":{"S":"Alice"}}' >/dev/null
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" --item '{"pk":{"S":"user#2"},"name":{"S":"Bob"}}' >/dev/null
run aws_cmd dynamodb scan --table-name "$TABLE_NAME"
assert_success
count=$(json_get "$output" '.Count')
[ "$count" -ge 2 ]
}
@test "DynamoDB: query table" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S AttributeName=sk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" --item '{"pk":{"S":"user#1"},"sk":{"S":"profile"},"name":{"S":"Alice"}}' >/dev/null
run aws_cmd dynamodb query \
--table-name "$TABLE_NAME" \
--key-condition-expression 'pk = :pk' \
--expression-attribute-values '{":pk":{"S":"user#1"}}'
assert_success
count=$(json_get "$output" '.Count')
[ "$count" -ge 1 ]
}
@test "DynamoDB: delete item" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" --item '{"pk":{"S":"user#1"}}' >/dev/null
run aws_cmd dynamodb delete-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"user#1"}}'
assert_success
}
@test "DynamoDB: delete table" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
run aws_cmd dynamodb delete-table --table-name "$TABLE_NAME"
assert_success
}
@test "DynamoDB: update and describe continuous backups" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
run aws_cmd dynamodb describe-continuous-backups --table-name "$TABLE_NAME"
assert_success
status=$(json_get "$output" '.ContinuousBackupsDescription.ContinuousBackupsStatus')
[ "$status" = "ENABLED" ]
pitr_status=$(json_get "$output" '.ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus')
[ "$pitr_status" = "DISABLED" ]
missing_period=$(echo "$output" | jq '.ContinuousBackupsDescription.PointInTimeRecoveryDescription | has("RecoveryPeriodInDays")')
[ "$missing_period" = "false" ]
run aws_cmd dynamodb update-continuous-backups \
--table-name "$TABLE_NAME" \
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true
assert_success
updated_status=$(json_get "$output" '.ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus')
[ "$updated_status" = "ENABLED" ]
recovery_period=$(json_get "$output" '.ContinuousBackupsDescription.PointInTimeRecoveryDescription.RecoveryPeriodInDays')
[ "$recovery_period" = "35" ]
}
# --- DynamoDB GSI/LSI Tests ---
@test "DynamoDB: create table with GSI and LSI" {
run aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions \
AttributeName=pk,AttributeType=S \
AttributeName=sk,AttributeType=S \
AttributeName=gsiPk,AttributeType=S \
AttributeName=lsiSk,AttributeType=S \
--key-schema \
AttributeName=pk,KeyType=HASH \
AttributeName=sk,KeyType=RANGE \
--global-secondary-indexes \
'IndexName=gsi-1,KeySchema=[{AttributeName=gsiPk,KeyType=HASH},{AttributeName=sk,KeyType=RANGE}],Projection={ProjectionType=ALL}' \
--local-secondary-indexes \
'IndexName=lsi-1,KeySchema=[{AttributeName=pk,KeyType=HASH},{AttributeName=lsiSk,KeyType=RANGE}],Projection={ProjectionType=KEYS_ONLY}' \
--billing-mode PAY_PER_REQUEST
assert_success
}
@test "DynamoDB: GSI count is 1" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions \
AttributeName=pk,AttributeType=S \
AttributeName=sk,AttributeType=S \
AttributeName=gsiPk,AttributeType=S \
AttributeName=lsiSk,AttributeType=S \
--key-schema \
AttributeName=pk,KeyType=HASH \
AttributeName=sk,KeyType=RANGE \
--global-secondary-indexes \
'IndexName=gsi-1,KeySchema=[{AttributeName=gsiPk,KeyType=HASH},{AttributeName=sk,KeyType=RANGE}],Projection={ProjectionType=ALL}' \
--local-secondary-indexes \
'IndexName=lsi-1,KeySchema=[{AttributeName=pk,KeyType=HASH},{AttributeName=lsiSk,KeyType=RANGE}],Projection={ProjectionType=KEYS_ONLY}' \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
run aws_cmd dynamodb describe-table --table-name "$TABLE_NAME"
assert_success
count=$(echo "$output" | jq '.Table.GlobalSecondaryIndexes | length')
[ "$count" = "1" ]
}
@test "DynamoDB: GSI name is gsi-1" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions \
AttributeName=pk,AttributeType=S \
AttributeName=sk,AttributeType=S \
AttributeName=gsiPk,AttributeType=S \
AttributeName=lsiSk,AttributeType=S \
--key-schema \
AttributeName=pk,KeyType=HASH \
AttributeName=sk,KeyType=RANGE \
--global-secondary-indexes \
'IndexName=gsi-1,KeySchema=[{AttributeName=gsiPk,KeyType=HASH},{AttributeName=sk,KeyType=RANGE}],Projection={ProjectionType=ALL}' \
--local-secondary-indexes \
'IndexName=lsi-1,KeySchema=[{AttributeName=pk,KeyType=HASH},{AttributeName=lsiSk,KeyType=RANGE}],Projection={ProjectionType=KEYS_ONLY}' \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
run aws_cmd dynamodb describe-table --table-name "$TABLE_NAME"
assert_success
name=$(echo "$output" | jq -r '.Table.GlobalSecondaryIndexes[0].IndexName')
[ "$name" = "gsi-1" ]
}
@test "DynamoDB: GSI projection is ALL" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions \
AttributeName=pk,AttributeType=S \
AttributeName=sk,AttributeType=S \
AttributeName=gsiPk,AttributeType=S \
AttributeName=lsiSk,AttributeType=S \
--key-schema \
AttributeName=pk,KeyType=HASH \
AttributeName=sk,KeyType=RANGE \
--global-secondary-indexes \
'IndexName=gsi-1,KeySchema=[{AttributeName=gsiPk,KeyType=HASH},{AttributeName=sk,KeyType=RANGE}],Projection={ProjectionType=ALL}' \
--local-secondary-indexes \
'IndexName=lsi-1,KeySchema=[{AttributeName=pk,KeyType=HASH},{AttributeName=lsiSk,KeyType=RANGE}],Projection={ProjectionType=KEYS_ONLY}' \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
run aws_cmd dynamodb describe-table --table-name "$TABLE_NAME"
assert_success
proj=$(echo "$output" | jq -r '.Table.GlobalSecondaryIndexes[0].Projection.ProjectionType')
[ "$proj" = "ALL" ]
}
@test "DynamoDB: LSI count is 1" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions \
AttributeName=pk,AttributeType=S \
AttributeName=sk,AttributeType=S \
AttributeName=gsiPk,AttributeType=S \
AttributeName=lsiSk,AttributeType=S \
--key-schema \
AttributeName=pk,KeyType=HASH \
AttributeName=sk,KeyType=RANGE \
--global-secondary-indexes \
'IndexName=gsi-1,KeySchema=[{AttributeName=gsiPk,KeyType=HASH},{AttributeName=sk,KeyType=RANGE}],Projection={ProjectionType=ALL}' \
--local-secondary-indexes \
'IndexName=lsi-1,KeySchema=[{AttributeName=pk,KeyType=HASH},{AttributeName=lsiSk,KeyType=RANGE}],Projection={ProjectionType=KEYS_ONLY}' \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
run aws_cmd dynamodb describe-table --table-name "$TABLE_NAME"
assert_success
count=$(echo "$output" | jq '.Table.LocalSecondaryIndexes | length')
[ "$count" = "1" ]
}
@test "DynamoDB: LSI name is lsi-1" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions \
AttributeName=pk,AttributeType=S \
AttributeName=sk,AttributeType=S \
AttributeName=gsiPk,AttributeType=S \
AttributeName=lsiSk,AttributeType=S \
--key-schema \
AttributeName=pk,KeyType=HASH \
AttributeName=sk,KeyType=RANGE \
--global-secondary-indexes \
'IndexName=gsi-1,KeySchema=[{AttributeName=gsiPk,KeyType=HASH},{AttributeName=sk,KeyType=RANGE}],Projection={ProjectionType=ALL}' \
--local-secondary-indexes \
'IndexName=lsi-1,KeySchema=[{AttributeName=pk,KeyType=HASH},{AttributeName=lsiSk,KeyType=RANGE}],Projection={ProjectionType=KEYS_ONLY}' \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
run aws_cmd dynamodb describe-table --table-name "$TABLE_NAME"
assert_success
name=$(echo "$output" | jq -r '.Table.LocalSecondaryIndexes[0].IndexName')
[ "$name" = "lsi-1" ]
}
@test "DynamoDB: LSI projection is KEYS_ONLY" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions \
AttributeName=pk,AttributeType=S \
AttributeName=sk,AttributeType=S \
AttributeName=gsiPk,AttributeType=S \
AttributeName=lsiSk,AttributeType=S \
--key-schema \
AttributeName=pk,KeyType=HASH \
AttributeName=sk,KeyType=RANGE \
--global-secondary-indexes \
'IndexName=gsi-1,KeySchema=[{AttributeName=gsiPk,KeyType=HASH},{AttributeName=sk,KeyType=RANGE}],Projection={ProjectionType=ALL}' \
--local-secondary-indexes \
'IndexName=lsi-1,KeySchema=[{AttributeName=pk,KeyType=HASH},{AttributeName=lsiSk,KeyType=RANGE}],Projection={ProjectionType=KEYS_ONLY}' \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
run aws_cmd dynamodb describe-table --table-name "$TABLE_NAME"
assert_success
proj=$(echo "$output" | jq -r '.Table.LocalSecondaryIndexes[0].Projection.ProjectionType')
[ "$proj" = "KEYS_ONLY" ]
}
@test "DynamoDB: query GSI sparse index" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions \
AttributeName=pk,AttributeType=S \
AttributeName=sk,AttributeType=S \
AttributeName=gsiPk,AttributeType=S \
--key-schema \
AttributeName=pk,KeyType=HASH \
AttributeName=sk,KeyType=RANGE \
--global-secondary-indexes \
'IndexName=gsi-1,KeySchema=[{AttributeName=gsiPk,KeyType=HASH},{AttributeName=sk,KeyType=RANGE}],Projection={ProjectionType=ALL}' \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
# Put 2 items with gsiPk
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-1"},"sk":{"S":"rev-1"},"gsiPk":{"S":"group-A"}}' >/dev/null
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-2"},"sk":{"S":"rev-1"},"gsiPk":{"S":"group-A"}}' >/dev/null
# Put 1 item without gsiPk (sparse)
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-3"},"sk":{"S":"rev-1"},"data":{"S":"no-gsi"}}' >/dev/null
# Query GSI - should return only the 2 items with gsiPk
run aws_cmd dynamodb query \
--table-name "$TABLE_NAME" \
--index-name gsi-1 \
--key-condition-expression 'gsiPk = :gpk' \
--expression-attribute-values '{":gpk":{"S":"group-A"}}'
assert_success
count=$(json_get "$output" '.Count')
[ "$count" = "2" ]
}
@test "DynamoDB: query LSI" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions \
AttributeName=pk,AttributeType=S \
AttributeName=sk,AttributeType=S \
AttributeName=lsiSk,AttributeType=S \
--key-schema \
AttributeName=pk,KeyType=HASH \
AttributeName=sk,KeyType=RANGE \
--local-secondary-indexes \
'IndexName=lsi-1,KeySchema=[{AttributeName=pk,KeyType=HASH},{AttributeName=lsiSk,KeyType=RANGE}],Projection={ProjectionType=KEYS_ONLY}' \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-1"},"sk":{"S":"rev-1"},"lsiSk":{"S":"2024-01-01"}}' >/dev/null
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-1"},"sk":{"S":"rev-2"},"lsiSk":{"S":"2024-01-02"}}' >/dev/null
# Query LSI with range condition
run aws_cmd dynamodb query \
--table-name "$TABLE_NAME" \
--index-name lsi-1 \
--key-condition-expression 'pk = :pk AND lsiSk > :d' \
--expression-attribute-values '{":pk":{"S":"item-1"},":d":{"S":"2024-01-00"}}'
assert_success
count=$(json_get "$output" '.Count')
[ "$count" = "2" ]
}
# --- DynamoDB FilterExpression Tests ---
@test "DynamoDB: query with FilterExpression" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S AttributeName=sk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
# Put items with different status values
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"user#1"},"sk":{"S":"order#1"},"status":{"S":"pending"}}' >/dev/null
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"user#1"},"sk":{"S":"order#2"},"status":{"S":"complete"}}' >/dev/null
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"user#1"},"sk":{"S":"order#3"},"status":{"S":"pending"}}' >/dev/null
# Query with FilterExpression to get only pending
run aws_cmd dynamodb query \
--table-name "$TABLE_NAME" \
--key-condition-expression 'pk = :pk' \
--filter-expression '#s = :status' \
--expression-attribute-names '{"#s":"status"}' \
--expression-attribute-values '{":pk":{"S":"user#1"},":status":{"S":"pending"}}'
assert_success
count=$(json_get "$output" '.Count')
[ "$count" = "2" ]
}
@test "DynamoDB: query FilterExpression with limit" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S AttributeName=sk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
# Put multiple items
for i in 1 2 3 4 5; do
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item "{\"pk\":{\"S\":\"user#1\"},\"sk\":{\"S\":\"order#$i\"},\"status\":{\"S\":\"pending\"}}" >/dev/null
done
# Query with limit
run aws_cmd dynamodb query \
--table-name "$TABLE_NAME" \
--key-condition-expression 'pk = :pk' \
--expression-attribute-values '{":pk":{"S":"user#1"}}' \
--limit 2
assert_success
count=$(json_get "$output" '.Count')
[ "$count" = "2" ]
}
@test "DynamoDB: query FilterExpression pagination" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S AttributeName=sk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
# Put multiple items
for i in 1 2 3 4 5; do
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item "{\"pk\":{\"S\":\"user#1\"},\"sk\":{\"S\":\"order#$i\"},\"status\":{\"S\":\"pending\"}}" >/dev/null
done
# First page
run aws_cmd dynamodb query \
--table-name "$TABLE_NAME" \
--key-condition-expression 'pk = :pk' \
--expression-attribute-values '{":pk":{"S":"user#1"}}' \
--limit 2
assert_success
# Check that LastEvaluatedKey exists for pagination
lek=$(echo "$output" | jq '.LastEvaluatedKey')
[ "$lek" != "null" ]
# Get next page
run aws_cmd dynamodb query \
--table-name "$TABLE_NAME" \
--key-condition-expression 'pk = :pk' \
--expression-attribute-values '{":pk":{"S":"user#1"}}' \
--limit 2 \
--exclusive-start-key "$lek"
assert_success
count=$(json_get "$output" '.Count')
[ "$count" = "2" ]
}
# --- DynamoDB Advanced Filter Tests ---
@test "DynamoDB: scan with contains on List" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-1"},"tags":{"L":[{"S":"foo"},{"S":"bar"}]}}' >/dev/null
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-2"},"tags":{"L":[{"S":"baz"},{"S":"qux"}]}}' >/dev/null
run aws_cmd dynamodb scan \
--table-name "$TABLE_NAME" \
--filter-expression 'contains(tags, :tag)' \
--expression-attribute-values '{":tag":{"S":"foo"}}'
assert_success
count=$(json_get "$output" '.Count')
[ "$count" = "1" ]
}
@test "DynamoDB: scan with BOOL filter" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-1"},"active":{"BOOL":true}}' >/dev/null
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-2"},"active":{"BOOL":false}}' >/dev/null
run aws_cmd dynamodb scan \
--table-name "$TABLE_NAME" \
--filter-expression 'active = :val' \
--expression-attribute-values '{":val":{"BOOL":true}}'
assert_success
count=$(json_get "$output" '.Count')
[ "$count" = "1" ]
}
@test "DynamoDB: scan with attribute_exists on nested Map" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-1"},"meta":{"M":{"created":{"S":"2024-01-01"}}}}' >/dev/null
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-2"},"other":{"S":"data"}}' >/dev/null
run aws_cmd dynamodb scan \
--table-name "$TABLE_NAME" \
--filter-expression 'attribute_exists(meta.created)'
assert_success
count=$(json_get "$output" '.Count')
[ "$count" = "1" ]
}
# --- DynamoDB Kinesis Streaming Destination Tests ---
@test "DynamoDB: enable kinesis streaming destination" {
STREAM_NAME="bats-kinesis-$(unique_name)"
aws_cmd kinesis create-stream --stream-name "$STREAM_NAME" --shard-count 1 >/dev/null
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES >/dev/null
ddb_wait_table "$TABLE_NAME"
STREAM_ARN=$(aws_cmd kinesis describe-stream-summary --stream-name "$STREAM_NAME" | jq -r '.StreamDescriptionSummary.StreamARN')
run aws_cmd dynamodb enable-kinesis-streaming-destination \
--table-name "$TABLE_NAME" \
--stream-arn "$STREAM_ARN"
assert_success
status=$(json_get "$output" '.DestinationStatus')
[ "$status" = "ACTIVE" ]
aws_cmd kinesis delete-stream --stream-name "$STREAM_NAME" >/dev/null 2>&1 || true
}
@test "DynamoDB: describe kinesis streaming destination" {
STREAM_NAME="bats-kinesis-$(unique_name)"
aws_cmd kinesis create-stream --stream-name "$STREAM_NAME" --shard-count 1 >/dev/null
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES >/dev/null
ddb_wait_table "$TABLE_NAME"
STREAM_ARN=$(aws_cmd kinesis describe-stream-summary --stream-name "$STREAM_NAME" | jq -r '.StreamDescriptionSummary.StreamARN')
aws_cmd dynamodb enable-kinesis-streaming-destination \
--table-name "$TABLE_NAME" \
--stream-arn "$STREAM_ARN" >/dev/null
run aws_cmd dynamodb describe-kinesis-streaming-destination \
--table-name "$TABLE_NAME"
assert_success
count=$(echo "$output" | jq '.KinesisDataStreamDestinations | length')
[ "$count" = "1" ]
dest_status=$(echo "$output" | jq -r '.KinesisDataStreamDestinations[0].DestinationStatus')
[ "$dest_status" = "ACTIVE" ]
aws_cmd kinesis delete-stream --stream-name "$STREAM_NAME" >/dev/null 2>&1 || true
}
@test "DynamoDB: disable kinesis streaming destination" {
STREAM_NAME="bats-kinesis-$(unique_name)"
aws_cmd kinesis create-stream --stream-name "$STREAM_NAME" --shard-count 1 >/dev/null
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES >/dev/null
ddb_wait_table "$TABLE_NAME"
STREAM_ARN=$(aws_cmd kinesis describe-stream-summary --stream-name "$STREAM_NAME" | jq -r '.StreamDescriptionSummary.StreamARN')
aws_cmd dynamodb enable-kinesis-streaming-destination \
--table-name "$TABLE_NAME" \
--stream-arn "$STREAM_ARN" >/dev/null
run aws_cmd dynamodb disable-kinesis-streaming-destination \
--table-name "$TABLE_NAME" \
--stream-arn "$STREAM_ARN"
assert_success
status=$(json_get "$output" '.DestinationStatus')
[ "$status" = "DISABLED" ]
aws_cmd kinesis delete-stream --stream-name "$STREAM_NAME" >/dev/null 2>&1 || true
}
@test "DynamoDB: scan with contains on String Set" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-1"},"roles":{"SS":["admin","user"]}}' >/dev/null
aws_cmd dynamodb put-item --table-name "$TABLE_NAME" \
--item '{"pk":{"S":"item-2"},"roles":{"SS":["guest"]}}' >/dev/null
run aws_cmd dynamodb scan \
--table-name "$TABLE_NAME" \
--filter-expression 'contains(#r, :role)' \
--expression-attribute-names '{"#r":"roles"}' \
--expression-attribute-values '{":role":{"S":"admin"}}'
assert_success
count=$(json_get "$output" '.Count')
[ "$count" = "1" ]
}
# --- DynamoDB REMOVE nested map key tests (GH #402) ---
@test "DynamoDB: REMOVE key from nested map" {
aws_cmd dynamodb create-table \
--table-name "$TABLE_NAME" \
--attribute-definitions AttributeName=pk,AttributeType=S AttributeName=sk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST >/dev/null
ddb_wait_table "$TABLE_NAME"
# Set a map attribute with a key
aws_cmd dynamodb update-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"user1"},"sk":{"S":"sort1"}}' \
--update-expression 'SET ratings = :ratings' \
--expression-attribute-values '{":ratings":{"M":{"foo":{"S":"5"},"bar":{"S":"3"}}}}' >/dev/null
# REMOVE ratings.foo
aws_cmd dynamodb update-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"user1"},"sk":{"S":"sort1"}}' \
--update-expression 'REMOVE ratings.foo' >/dev/null
# Verify foo is removed but bar remains
run aws_cmd dynamodb get-item \
--table-name "$TABLE_NAME" \
--key '{"pk":{"S":"user1"},"sk":{"S":"sort1"}}'
assert_success
foo=$(echo "$output" | jq -r '.Item.ratings.M.foo // "null"')
bar=$(echo "$output" | jq -r '.Item.ratings.M.bar.S')
[ "$foo" = "null" ]
[ "$bar" = "3" ]
}
@@ -0,0 +1,661 @@
#!/usr/bin/env bats
# EC2 tests
setup() {
load 'test_helper/common-setup'
PREFIX_LIST_NAME="bats-prefix-list-$(unique_name)"
PREFIX_LIST_ID=""
SG_VPC_ID=""
SG_SOURCE_ID=""
SG_TARGET_ID=""
TRANSIT_GATEWAY_ID=""
TGW_ATTACHMENT_ID=""
TGW_VPC_ID=""
TGW_ROUTE_TABLE_ID=""
MONITOR_INSTANCE_ID=""
}
teardown() {
if [ -n "$MONITOR_INSTANCE_ID" ]; then
aws_cmd ec2 terminate-instances --instance-ids "$MONITOR_INSTANCE_ID" >/dev/null 2>&1 || true
fi
if [ -n "$PREFIX_LIST_ID" ]; then
aws_cmd ec2 delete-managed-prefix-list --prefix-list-id "$PREFIX_LIST_ID" >/dev/null 2>&1 || true
fi
if [ -n "$TGW_ROUTE_TABLE_ID" ]; then
aws_cmd ec2 delete-transit-gateway-route-table \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" >/dev/null 2>&1 || true
fi
if [ -n "$TGW_ATTACHMENT_ID" ]; then
aws_cmd ec2 delete-transit-gateway-vpc-attachment \
--transit-gateway-attachment-id "$TGW_ATTACHMENT_ID" >/dev/null 2>&1 || true
fi
if [ -n "$TRANSIT_GATEWAY_ID" ]; then
aws_cmd ec2 delete-transit-gateway --transit-gateway-id "$TRANSIT_GATEWAY_ID" >/dev/null 2>&1 || true
fi
if [ -n "$TGW_VPC_ID" ]; then
aws_cmd ec2 delete-vpc --vpc-id "$TGW_VPC_ID" >/dev/null 2>&1 || true
fi
for sg in "$SG_TARGET_ID" "$SG_SOURCE_ID"; do
if [ -n "$sg" ]; then
aws_cmd ec2 delete-security-group --group-id "$sg" >/dev/null 2>&1 || true
fi
done
if [ -n "$SG_VPC_ID" ]; then
aws_cmd ec2 delete-vpc --vpc-id "$SG_VPC_ID" >/dev/null 2>&1 || true
fi
}
# Creates a prefix list holding 10.0.0.0/8 and sets PREFIX_LIST_ID.
create_prefix_list() {
local out
out=$(aws_cmd ec2 create-managed-prefix-list \
--prefix-list-name "$PREFIX_LIST_NAME" \
--address-family IPv4 \
--max-entries 5 \
--entries 'Cidr=10.0.0.0/8,Description=corporate')
PREFIX_LIST_ID=$(json_get "$out" '.PrefixList.PrefixListId')
}
@test "EC2: create managed prefix list" {
run aws_cmd ec2 create-managed-prefix-list \
--prefix-list-name "$PREFIX_LIST_NAME" \
--address-family IPv4 \
--max-entries 5 \
--entries 'Cidr=10.0.0.0/8,Description=corporate'
assert_success
PREFIX_LIST_ID=$(json_get "$output" '.PrefixList.PrefixListId')
[ -n "$PREFIX_LIST_ID" ]
state=$(json_get "$output" '.PrefixList.State')
[ "$state" = "create-complete" ]
version=$(json_get "$output" '.PrefixList.Version')
[ "$version" = "1" ]
}
@test "EC2: describe managed prefix list by id" {
create_prefix_list
run aws_cmd ec2 describe-managed-prefix-lists --prefix-list-ids "$PREFIX_LIST_ID"
assert_success
name=$(json_get "$output" '.PrefixLists[0].PrefixListName')
[ "$name" = "$PREFIX_LIST_NAME" ]
}
@test "EC2: describe managed prefix lists exposes the AWS-managed S3 list" {
run aws_cmd ec2 describe-managed-prefix-lists \
--filters "Name=prefix-list-name,Values=com.amazonaws.${AWS_DEFAULT_REGION}.s3"
assert_success
id=$(json_get "$output" '.PrefixLists[0].PrefixListId')
[ "$id" = "pl-63a5400a" ]
owner=$(json_get "$output" '.PrefixLists[0].OwnerId')
[ "$owner" = "AWS" ]
}
@test "EC2: get managed prefix list entries" {
create_prefix_list
run aws_cmd ec2 get-managed-prefix-list-entries --prefix-list-id "$PREFIX_LIST_ID"
assert_success
cidr=$(json_get "$output" '.Entries[0].Cidr')
[ "$cidr" = "10.0.0.0/8" ]
desc=$(json_get "$output" '.Entries[0].Description')
[ "$desc" = "corporate" ]
}
@test "EC2: modify managed prefix list bumps version and keeps history" {
create_prefix_list
run aws_cmd ec2 modify-managed-prefix-list \
--prefix-list-id "$PREFIX_LIST_ID" \
--add-entries 'Cidr=192.168.0.0/16,Description=lab'
assert_success
version=$(json_get "$output" '.PrefixList.Version')
[ "$version" = "2" ]
run aws_cmd ec2 get-managed-prefix-list-entries --prefix-list-id "$PREFIX_LIST_ID"
assert_success
count=$(json_get "$output" '.Entries | length')
[ "$count" = "2" ]
run aws_cmd ec2 get-managed-prefix-list-entries --prefix-list-id "$PREFIX_LIST_ID" --target-version 1
assert_success
count=$(json_get "$output" '.Entries | length')
[ "$count" = "1" ]
}
@test "EC2: modify with a stale current version is rejected" {
create_prefix_list
aws_cmd ec2 modify-managed-prefix-list --prefix-list-id "$PREFIX_LIST_ID" \
--add-entries 'Cidr=192.168.0.0/16' >/dev/null
run aws_cmd ec2 modify-managed-prefix-list \
--prefix-list-id "$PREFIX_LIST_ID" \
--current-version 1 \
--add-entries 'Cidr=172.16.0.0/12'
assert_failure
assert_output --partial "PrefixListVersionMismatch"
}
@test "EC2: AWS-managed prefix list cannot be modified" {
run aws_cmd ec2 modify-managed-prefix-list \
--prefix-list-id pl-63a5400a \
--add-entries 'Cidr=10.1.0.0/16'
assert_failure
assert_output --partial "UnsupportedOperation"
}
@test "EC2: delete managed prefix list" {
create_prefix_list
local created_id="$PREFIX_LIST_ID"
run aws_cmd ec2 delete-managed-prefix-list --prefix-list-id "$created_id"
assert_success
state=$(json_get "$output" '.PrefixList.State')
[ "$state" = "delete-complete" ]
PREFIX_LIST_ID=""
run aws_cmd ec2 describe-managed-prefix-lists --prefix-list-ids "$created_id"
assert_failure
assert_output --partial "InvalidPrefixListID.NotFound"
}
@test "EC2: legacy describe-prefix-lists still serves the gateway lists" {
run aws_cmd ec2 describe-prefix-lists \
--filters "Name=prefix-list-name,Values=com.amazonaws.${AWS_DEFAULT_REGION}.s3"
assert_success
id=$(json_get "$output" '.PrefixLists[0].PrefixListId')
[ "$id" = "pl-63a5400a" ]
}
# ─── security group rules sourced from a prefix list ────────────────────────
@test "EC2: authorize a security group rule from a prefix list" {
create_prefix_list
local sg
sg=$(aws_cmd ec2 create-security-group --group-name "$(unique_name bats-sg)" \
--description "prefix list source" --query 'GroupId' --output text)
run aws_cmd ec2 authorize-security-group-ingress --group-id "$sg" \
--ip-permissions "IpProtocol=tcp,FromPort=5432,ToPort=5432,PrefixListIds=[{PrefixListId=$PREFIX_LIST_ID,Description=from-corp}]"
assert_success
pl=$(json_get "$output" '.SecurityGroupRules[0].PrefixListId')
[ "$pl" = "$PREFIX_LIST_ID" ]
run aws_cmd ec2 describe-security-groups --group-ids "$sg"
assert_success
nested=$(json_get "$output" '.SecurityGroups[0].IpPermissions[0].PrefixListIds[0].PrefixListId')
[ "$nested" = "$PREFIX_LIST_ID" ]
run aws_cmd ec2 describe-security-group-rules --filters "Name=group-id,Values=$sg"
assert_success
found=$(echo "$output" | jq --arg pl "$PREFIX_LIST_ID" '.SecurityGroupRules | any(.PrefixListId == $pl)')
[ "$found" = "true" ]
aws_cmd ec2 delete-security-group --group-id "$sg" >/dev/null 2>&1 || true
}
@test "EC2: authorizing from an unknown prefix list is rejected" {
local sg
sg=$(aws_cmd ec2 create-security-group --group-name "$(unique_name bats-sg)" \
--description "unknown prefix list" --query 'GroupId' --output text)
run aws_cmd ec2 authorize-security-group-ingress --group-id "$sg" \
--ip-permissions 'IpProtocol=tcp,FromPort=1,ToPort=1,PrefixListIds=[{PrefixListId=pl-doesnotexist}]'
assert_failure
assert_output --partial "InvalidPrefixListID.NotFound"
aws_cmd ec2 delete-security-group --group-id "$sg" >/dev/null 2>&1 || true
}
# Creates a VPC holding a source and a target security group, and sets SG_VPC_ID,
# SG_SOURCE_ID and SG_TARGET_ID.
create_sg_pair() {
local out
out=$(aws_cmd ec2 create-vpc --cidr-block 10.0.0.0/16)
SG_VPC_ID=$(json_get "$out" '.Vpc.VpcId')
out=$(aws_cmd ec2 create-security-group \
--group-name "$(unique_name bats-sg-source)" \
--description "traffic source" --vpc-id "$SG_VPC_ID")
SG_SOURCE_ID=$(json_get "$out" '.GroupId')
out=$(aws_cmd ec2 create-security-group \
--group-name "$(unique_name bats-sg-target)" \
--description "traffic target" --vpc-id "$SG_VPC_ID")
SG_TARGET_ID=$(json_get "$out" '.GroupId')
}
@test "EC2: ingress rule keeps its source security group" {
create_sg_pair
# The CLI serializes UserIdGroupPairs under the wire name "Groups", so this exercises the
# form the reporter of #2190 actually sent.
run aws_cmd ec2 authorize-security-group-ingress --group-id "$SG_TARGET_ID" \
--ip-permissions "IpProtocol=tcp,FromPort=443,ToPort=443,UserIdGroupPairs=[{GroupId=$SG_SOURCE_ID,Description=from-source-sg}]"
assert_success
ref=$(json_get "$output" '.SecurityGroupRules[0].ReferencedGroupInfo.GroupId')
[ "$ref" = "$SG_SOURCE_ID" ]
# Control: the CIDR form of the same rule.
aws_cmd ec2 authorize-security-group-ingress --group-id "$SG_TARGET_ID" \
--ip-permissions 'IpProtocol=tcp,FromPort=8443,ToPort=8443,IpRanges=[{CidrIp=10.0.0.0/8,Description=control}]' >/dev/null
run aws_cmd ec2 describe-security-groups --group-ids "$SG_TARGET_ID"
assert_success
gid=$(json_get "$output" '.SecurityGroups[0].IpPermissions[] | select(.FromPort==443) | .UserIdGroupPairs[0].GroupId')
[ "$gid" = "$SG_SOURCE_ID" ]
desc=$(json_get "$output" '.SecurityGroups[0].IpPermissions[] | select(.FromPort==443) | .UserIdGroupPairs[0].Description')
[ "$desc" = "from-source-sg" ]
cidr=$(json_get "$output" '.SecurityGroups[0].IpPermissions[] | select(.FromPort==8443) | .IpRanges[0].CidrIp')
[ "$cidr" = "10.0.0.0/8" ]
run aws_cmd ec2 describe-security-group-rules --filters "Name=group-id,Values=$SG_TARGET_ID"
assert_success
ref=$(json_get "$output" '.SecurityGroupRules[] | select(.FromPort==443) | .ReferencedGroupInfo.GroupId')
[ "$ref" = "$SG_SOURCE_ID" ]
desc=$(json_get "$output" '.SecurityGroupRules[] | select(.FromPort==443) | .Description')
[ "$desc" = "from-source-sg" ]
cidr=$(json_get "$output" '.SecurityGroupRules[] | select(.FromPort==8443) | .CidrIpv4')
[ "$cidr" = "10.0.0.0/8" ]
}
# ─── transit gateways ───────────────────────────────────────────────────────
@test "EC2: create a transit gateway and read it back" {
run aws_cmd ec2 create-transit-gateway \
--description "bats hub" \
--tag-specifications 'ResourceType=transit-gateway,Tags=[{Key=Name,Value=bats-tgw}]'
assert_success
TRANSIT_GATEWAY_ID=$(json_get "$output" '.TransitGateway.TransitGatewayId')
[ "$(json_get "$output" '.TransitGateway.State')" = "available" ]
[ "$(json_get "$output" '.TransitGateway.Options.AmazonSideAsn')" = "64512" ]
[ "$(json_get "$output" '.TransitGateway.Options.SecurityGroupReferencingSupport')" = "disable" ]
# The default route table is minted with the gateway, so both ids are already set and equal.
assoc=$(json_get "$output" '.TransitGateway.Options.AssociationDefaultRouteTableId')
prop=$(json_get "$output" '.TransitGateway.Options.PropagationDefaultRouteTableId')
[ "$assoc" = "$prop" ]
case "$assoc" in tgw-rtb-*) ;; *) return 1 ;; esac
run aws_cmd ec2 describe-transit-gateways --transit-gateway-ids "$TRANSIT_GATEWAY_ID"
assert_success
[ "$(json_get "$output" '.TransitGateways[0].TransitGatewayId')" = "$TRANSIT_GATEWAY_ID" ]
[ "$(json_get "$output" '.TransitGateways[0].Tags[0].Value')" = "bats-tgw" ]
}
@test "EC2: create a transit gateway with CIDR blocks" {
run aws_cmd ec2 create-transit-gateway \
--options 'TransitGatewayCidrBlocks=[10.99.0.0/16]'
assert_success
TRANSIT_GATEWAY_ID=$(json_get "$output" '.TransitGateway.TransitGatewayId')
[ "$(json_get "$output" '.TransitGateway.Options.TransitGatewayCidrBlocks[0]')" = "10.99.0.0/16" ]
run aws_cmd ec2 describe-transit-gateways --transit-gateway-ids "$TRANSIT_GATEWAY_ID"
assert_success
[ "$(json_get "$output" '.TransitGateways[0].Options.TransitGatewayCidrBlocks[0]')" = "10.99.0.0/16" ]
}
# Tags are changed after creation with create-tags rather than a tag specification, and read back
# from describe-transit-gateways, which is how a Terraform tag update converges.
@test "EC2: transit gateway tags changed after creation are visible on describe" {
local out
out=$(aws_cmd ec2 create-transit-gateway --tag-specifications \
'ResourceType=transit-gateway,Tags=[{Key=Name,Value=bats-tgw-tags}]')
TRANSIT_GATEWAY_ID=$(json_get "$out" '.TransitGateway.TransitGatewayId')
aws_cmd ec2 create-tags --resources "$TRANSIT_GATEWAY_ID" --tags 'Key=env,Value=prod' >/dev/null
run aws_cmd ec2 describe-transit-gateways --transit-gateway-ids "$TRANSIT_GATEWAY_ID"
assert_success
count=$(json_get "$output" '.TransitGateways[0].Tags | length')
[ "$count" = "2" ]
env_value=$(json_get "$output" '.TransitGateways[0].Tags[] | select(.Key=="env") | .Value')
[ "$env_value" = "prod" ]
aws_cmd ec2 delete-tags --resources "$TRANSIT_GATEWAY_ID" --tags 'Key=env' >/dev/null
run aws_cmd ec2 describe-transit-gateways --transit-gateway-ids "$TRANSIT_GATEWAY_ID"
assert_success
count=$(json_get "$output" '.TransitGateways[0].Tags | length')
[ "$count" = "1" ]
}
@test "EC2: modify a transit gateway" {
local out
out=$(aws_cmd ec2 create-transit-gateway --description "before")
TRANSIT_GATEWAY_ID=$(json_get "$out" '.TransitGateway.TransitGatewayId')
run aws_cmd ec2 modify-transit-gateway \
--transit-gateway-id "$TRANSIT_GATEWAY_ID" \
--description "after" \
--options 'DnsSupport=disable,AddTransitGatewayCidrBlocks=[10.100.0.0/16]'
assert_success
[ "$(json_get "$output" '.TransitGateway.Description')" = "after" ]
[ "$(json_get "$output" '.TransitGateway.Options.DnsSupport')" = "disable" ]
[ "$(json_get "$output" '.TransitGateway.Options.TransitGatewayCidrBlocks[0]')" = "10.100.0.0/16" ]
# Describe serializes the same list, so read it back through the CLI's own parser too.
run aws_cmd ec2 describe-transit-gateways --transit-gateway-ids "$TRANSIT_GATEWAY_ID"
assert_success
[ "$(json_get "$output" '.TransitGateways[0].Options.TransitGatewayCidrBlocks[0]')" = "10.100.0.0/16" ]
}
@test "EC2: delete a transit gateway" {
local out tgw
out=$(aws_cmd ec2 create-transit-gateway --description "short lived")
tgw=$(json_get "$out" '.TransitGateway.TransitGatewayId')
run aws_cmd ec2 delete-transit-gateway --transit-gateway-id "$tgw"
assert_success
run aws_cmd ec2 describe-transit-gateways --transit-gateway-ids "$tgw"
assert_failure
assert_output --partial "InvalidTransitGatewayID.NotFound"
}
@test "EC2: describing an unknown transit gateway is rejected" {
run aws_cmd ec2 describe-transit-gateways --transit-gateway-ids tgw-0123456789abcdef0
assert_failure
assert_output --partial "InvalidTransitGatewayID.NotFound"
}
# ─── transit gateway VPC attachments ────────────────────────────────────────
# Creates a gateway, a VPC and one subnet, setting TRANSIT_GATEWAY_ID, TGW_VPC_ID and TGW_SUBNET_ID.
create_attachment_fixture() {
local out
out=$(aws_cmd ec2 create-transit-gateway --description "bats attachment host")
TRANSIT_GATEWAY_ID=$(json_get "$out" '.TransitGateway.TransitGatewayId')
out=$(aws_cmd ec2 create-vpc --cidr-block 10.80.0.0/16)
TGW_VPC_ID=$(json_get "$out" '.Vpc.VpcId')
out=$(aws_cmd ec2 create-subnet --vpc-id "$TGW_VPC_ID" --cidr-block 10.80.1.0/24 \
--availability-zone "${AWS_DEFAULT_REGION}a")
TGW_SUBNET_ID=$(json_get "$out" '.Subnet.SubnetId')
}
@test "EC2: attach a VPC to a transit gateway and read it back both ways" {
create_attachment_fixture
run aws_cmd ec2 create-transit-gateway-vpc-attachment \
--transit-gateway-id "$TRANSIT_GATEWAY_ID" \
--vpc-id "$TGW_VPC_ID" \
--subnet-ids "$TGW_SUBNET_ID" \
--tag-specifications 'ResourceType=transit-gateway-attachment,Tags=[{Key=Name,Value=bats-attach}]'
assert_success
TGW_ATTACHMENT_ID=$(json_get "$output" '.TransitGatewayVpcAttachment.TransitGatewayAttachmentId')
[ "$(json_get "$output" '.TransitGatewayVpcAttachment.State')" = "available" ]
[ "$(json_get "$output" '.TransitGatewayVpcAttachment.SubnetIds[0]')" = "$TGW_SUBNET_ID" ]
# The attachment's own default, which differs from the gateway's.
[ "$(json_get "$output" '.TransitGatewayVpcAttachment.Options.SecurityGroupReferencingSupport')" = "enable" ]
run aws_cmd ec2 describe-transit-gateway-vpc-attachments \
--transit-gateway-attachment-ids "$TGW_ATTACHMENT_ID"
assert_success
[ "$(json_get "$output" '.TransitGatewayVpcAttachments[0].VpcId')" = "$TGW_VPC_ID" ]
[ "$(json_get "$output" '.TransitGatewayVpcAttachments[0].Tags[0].Value')" = "bats-attach" ]
# The resource-agnostic form is a different shape: typed resource plus the association.
run aws_cmd ec2 describe-transit-gateway-attachments \
--transit-gateway-attachment-ids "$TGW_ATTACHMENT_ID"
assert_success
[ "$(json_get "$output" '.TransitGatewayAttachments[0].ResourceType')" = "vpc" ]
[ "$(json_get "$output" '.TransitGatewayAttachments[0].ResourceId')" = "$TGW_VPC_ID" ]
[ "$(json_get "$output" '.TransitGatewayAttachments[0].Association.State')" = "associated" ]
rtb=$(json_get "$output" '.TransitGatewayAttachments[0].Association.TransitGatewayRouteTableId')
case "$rtb" in tgw-rtb-*) ;; *) return 1 ;; esac
}
@test "EC2: modify a transit gateway VPC attachment" {
create_attachment_fixture
local out subnet_b
out=$(aws_cmd ec2 create-transit-gateway-vpc-attachment --transit-gateway-id "$TRANSIT_GATEWAY_ID" \
--vpc-id "$TGW_VPC_ID" --subnet-ids "$TGW_SUBNET_ID")
TGW_ATTACHMENT_ID=$(json_get "$out" '.TransitGatewayVpcAttachment.TransitGatewayAttachmentId')
out=$(aws_cmd ec2 create-subnet --vpc-id "$TGW_VPC_ID" --cidr-block 10.80.2.0/24 \
--availability-zone "${AWS_DEFAULT_REGION}b")
subnet_b=$(json_get "$out" '.Subnet.SubnetId')
run aws_cmd ec2 modify-transit-gateway-vpc-attachment \
--transit-gateway-attachment-id "$TGW_ATTACHMENT_ID" \
--add-subnet-ids "$subnet_b" \
--options 'DnsSupport=disable'
assert_success
[ "$(json_get "$output" '.TransitGatewayVpcAttachment.Options.DnsSupport')" = "disable" ]
count=$(json_get "$output" '.TransitGatewayVpcAttachment.SubnetIds | length')
[ "$count" = "2" ]
run aws_cmd ec2 modify-transit-gateway-vpc-attachment \
--transit-gateway-attachment-id "$TGW_ATTACHMENT_ID" \
--remove-subnet-ids "$TGW_SUBNET_ID" "$subnet_b"
assert_failure
assert_output --partial "InsufficientSubnetsException"
}
@test "EC2: a transit gateway with an attachment cannot be deleted" {
create_attachment_fixture
local out
out=$(aws_cmd ec2 create-transit-gateway-vpc-attachment --transit-gateway-id "$TRANSIT_GATEWAY_ID" \
--vpc-id "$TGW_VPC_ID" --subnet-ids "$TGW_SUBNET_ID")
TGW_ATTACHMENT_ID=$(json_get "$out" '.TransitGatewayVpcAttachment.TransitGatewayAttachmentId')
run aws_cmd ec2 delete-transit-gateway --transit-gateway-id "$TRANSIT_GATEWAY_ID"
assert_failure
assert_output --partial "IncorrectState"
run aws_cmd ec2 delete-transit-gateway-vpc-attachment \
--transit-gateway-attachment-id "$TGW_ATTACHMENT_ID"
assert_success
TGW_ATTACHMENT_ID=""
run aws_cmd ec2 delete-transit-gateway --transit-gateway-id "$TRANSIT_GATEWAY_ID"
assert_success
TRANSIT_GATEWAY_ID=""
}
# ─── transit gateway route tables, associations, propagations and routes ────
@test "EC2: route table associations move between tables" {
create_attachment_fixture
local out
out=$(aws_cmd ec2 create-transit-gateway-vpc-attachment --transit-gateway-id "$TRANSIT_GATEWAY_ID" \
--vpc-id "$TGW_VPC_ID" --subnet-ids "$TGW_SUBNET_ID")
TGW_ATTACHMENT_ID=$(json_get "$out" '.TransitGatewayVpcAttachment.TransitGatewayAttachmentId')
out=$(aws_cmd ec2 describe-transit-gateways --transit-gateway-ids "$TRANSIT_GATEWAY_ID")
local default_rtb
default_rtb=$(json_get "$out" '.TransitGateways[0].Options.AssociationDefaultRouteTableId')
run aws_cmd ec2 create-transit-gateway-route-table --transit-gateway-id "$TRANSIT_GATEWAY_ID" \
--tag-specifications 'ResourceType=transit-gateway-route-table,Tags=[{Key=Name,Value=bats-rtb}]'
assert_success
TGW_ROUTE_TABLE_ID=$(json_get "$output" '.TransitGatewayRouteTable.TransitGatewayRouteTableId')
[ "$(json_get "$output" '.TransitGatewayRouteTable.State')" = "available" ]
[ "$(json_get "$output" '.TransitGatewayRouteTable.DefaultAssociationRouteTable')" = "false" ]
# The attachment starts on the gateway's default table, so a second association is refused.
run aws_cmd ec2 associate-transit-gateway-route-table \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--transit-gateway-attachment-id "$TGW_ATTACHMENT_ID"
assert_failure
assert_output --partial "Resource.AlreadyAssociated"
run aws_cmd ec2 disassociate-transit-gateway-route-table \
--transit-gateway-route-table-id "$default_rtb" \
--transit-gateway-attachment-id "$TGW_ATTACHMENT_ID"
assert_success
[ "$(json_get "$output" '.Association.State')" = "disassociating" ]
run aws_cmd ec2 associate-transit-gateway-route-table \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--transit-gateway-attachment-id "$TGW_ATTACHMENT_ID"
assert_success
[ "$(json_get "$output" '.Association.State')" = "associating" ]
run aws_cmd ec2 get-transit-gateway-route-table-associations \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID"
assert_success
[ "$(json_get "$output" '.Associations[0].TransitGatewayAttachmentId')" = "$TGW_ATTACHMENT_ID" ]
[ "$(json_get "$output" '.Associations[0].ResourceType')" = "vpc" ]
}
@test "EC2: propagation produces a route for the attached VPC" {
create_attachment_fixture
local out
out=$(aws_cmd ec2 create-transit-gateway-vpc-attachment --transit-gateway-id "$TRANSIT_GATEWAY_ID" \
--vpc-id "$TGW_VPC_ID" --subnet-ids "$TGW_SUBNET_ID")
TGW_ATTACHMENT_ID=$(json_get "$out" '.TransitGatewayVpcAttachment.TransitGatewayAttachmentId')
out=$(aws_cmd ec2 create-transit-gateway-route-table --transit-gateway-id "$TRANSIT_GATEWAY_ID")
TGW_ROUTE_TABLE_ID=$(json_get "$out" '.TransitGatewayRouteTable.TransitGatewayRouteTableId')
run aws_cmd ec2 enable-transit-gateway-route-table-propagation \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--transit-gateway-attachment-id "$TGW_ATTACHMENT_ID"
assert_success
[ "$(json_get "$output" '.Propagation.State')" = "enabled" ]
run aws_cmd ec2 get-transit-gateway-route-table-propagations \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID"
assert_success
[ "$(json_get "$output" '.TransitGatewayRouteTablePropagations[0].State')" = "enabled" ]
run aws_cmd ec2 search-transit-gateway-routes \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--filters 'Name=type,Values=propagated'
assert_success
[ "$(json_get "$output" '.Routes[0].DestinationCidrBlock')" = "10.80.0.0/16" ]
[ "$(json_get "$output" '.Routes[0].Type')" = "propagated" ]
[ "$(json_get "$output" '.Routes[0].State')" = "active" ]
run aws_cmd ec2 enable-transit-gateway-route-table-propagation \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--transit-gateway-attachment-id "$TGW_ATTACHMENT_ID"
assert_failure
assert_output --partial "TransitGatewayRouteTablePropagation.Duplicate"
}
@test "EC2: static and blackhole transit gateway routes" {
create_attachment_fixture
local out
out=$(aws_cmd ec2 create-transit-gateway-vpc-attachment --transit-gateway-id "$TRANSIT_GATEWAY_ID" \
--vpc-id "$TGW_VPC_ID" --subnet-ids "$TGW_SUBNET_ID")
TGW_ATTACHMENT_ID=$(json_get "$out" '.TransitGatewayVpcAttachment.TransitGatewayAttachmentId')
out=$(aws_cmd ec2 create-transit-gateway-route-table --transit-gateway-id "$TRANSIT_GATEWAY_ID")
TGW_ROUTE_TABLE_ID=$(json_get "$out" '.TransitGatewayRouteTable.TransitGatewayRouteTableId')
run aws_cmd ec2 create-transit-gateway-route \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--destination-cidr-block 10.60.0.0/16 \
--transit-gateway-attachment-id "$TGW_ATTACHMENT_ID"
assert_success
[ "$(json_get "$output" '.Route.Type')" = "static" ]
[ "$(json_get "$output" '.Route.State')" = "active" ]
[ "$(json_get "$output" '.Route.TransitGatewayAttachments[0].TransitGatewayAttachmentId')" = "$TGW_ATTACHMENT_ID" ]
run aws_cmd ec2 create-transit-gateway-route \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--destination-cidr-block 10.61.0.0/16 --blackhole
assert_success
[ "$(json_get "$output" '.Route.State')" = "blackhole" ]
# A blackhole carries no attachment, so the key is absent rather than empty.
count=$(json_get "$output" '.Route.TransitGatewayAttachments // [] | length')
[ "$count" = "0" ]
run aws_cmd ec2 search-transit-gateway-routes \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--filters 'Name=type,Values=static'
assert_success
count=$(json_get "$output" '.Routes | length')
[ "$count" = "2" ]
run aws_cmd ec2 delete-transit-gateway-route \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--destination-cidr-block 10.61.0.0/16
assert_success
[ "$(json_get "$output" '.Route.State')" = "deleted" ]
}
@test "EC2: replacing a transit gateway route moves its target and upserts" {
create_attachment_fixture
local out
out=$(aws_cmd ec2 create-transit-gateway-vpc-attachment --transit-gateway-id "$TRANSIT_GATEWAY_ID" \
--vpc-id "$TGW_VPC_ID" --subnet-ids "$TGW_SUBNET_ID")
TGW_ATTACHMENT_ID=$(json_get "$out" '.TransitGatewayVpcAttachment.TransitGatewayAttachmentId')
out=$(aws_cmd ec2 create-transit-gateway-route-table --transit-gateway-id "$TRANSIT_GATEWAY_ID")
TGW_ROUTE_TABLE_ID=$(json_get "$out" '.TransitGatewayRouteTable.TransitGatewayRouteTableId')
aws_cmd ec2 create-transit-gateway-route --transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--destination-cidr-block 10.88.0.0/16 --transit-gateway-attachment-id "$TGW_ATTACHMENT_ID" >/dev/null
run aws_cmd ec2 replace-transit-gateway-route \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--destination-cidr-block 10.88.0.0/16 --blackhole
assert_success
[ "$(json_get "$output" '.Route.State')" = "blackhole" ]
count=$(json_get "$output" '.Route.TransitGatewayAttachments // [] | length')
[ "$count" = "0" ]
run aws_cmd ec2 replace-transit-gateway-route \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--destination-cidr-block 10.88.0.0/16 \
--transit-gateway-attachment-id "$TGW_ATTACHMENT_ID"
assert_success
[ "$(json_get "$output" '.Route.State')" = "active" ]
# Replacing a destination the table never held writes it.
run aws_cmd ec2 replace-transit-gateway-route \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--destination-cidr-block 10.89.0.0/16 --blackhole
assert_success
run aws_cmd ec2 search-transit-gateway-routes \
--transit-gateway-route-table-id "$TGW_ROUTE_TABLE_ID" \
--filters 'Name=type,Values=static'
assert_success
count=$(json_get "$output" '.Routes | length')
[ "$count" = "2" ]
}
# Monitoring goes through the SDK, not just the raw Query wire, because that is the
# path aws_instance takes: `monitoring = true` calls MonitorInstances, and the SDK
# parses the response rather than reading the XML we happen to emit. AGENTS.md asks
# for management APIs to be validated with SDK clients and not only handcrafted HTTP.
launch_monitor_instance() {
run aws_cmd ec2 run-instances \
--image-id ami-0abcdef1234567890 --instance-type t3.micro \
--count 1
assert_success
MONITOR_INSTANCE_ID=$(json_get "$output" '.Instances[0].InstanceId')
[ -n "$MONITOR_INSTANCE_ID" ]
}
@test "EC2: monitor-instances reports enabled and the state survives to describe" {
launch_monitor_instance
run aws_cmd ec2 monitor-instances --instance-ids "$MONITOR_INSTANCE_ID"
assert_success
[ "$(json_get "$output" '.InstanceMonitorings[0].InstanceId')" = "$MONITOR_INSTANCE_ID" ]
[ "$(json_get "$output" '.InstanceMonitorings[0].Monitoring.State')" = "enabled" ]
# Read it back rather than trusting the mutating call's own response: the bug this
# guards against was answering "enabled" while every later read still said disabled.
run aws_cmd ec2 describe-instances --instance-ids "$MONITOR_INSTANCE_ID"
assert_success
[ "$(json_get "$output" '.Reservations[0].Instances[0].Monitoring.State')" = "enabled" ]
}
@test "EC2: unmonitor-instances reports disabled and the state survives to describe" {
launch_monitor_instance
run aws_cmd ec2 monitor-instances --instance-ids "$MONITOR_INSTANCE_ID"
assert_success
run aws_cmd ec2 unmonitor-instances --instance-ids "$MONITOR_INSTANCE_ID"
assert_success
[ "$(json_get "$output" '.InstanceMonitorings[0].Monitoring.State')" = "disabled" ]
run aws_cmd ec2 describe-instances --instance-ids "$MONITOR_INSTANCE_ID"
assert_success
[ "$(json_get "$output" '.Reservations[0].Instances[0].Monitoring.State')" = "disabled" ]
}
@test "EC2: monitor-instances rejects an instance that does not exist" {
run aws_cmd ec2 monitor-instances --instance-ids i-1234567890abcdef0
assert_failure
[[ "$output" == *"InvalidInstanceID.NotFound"* ]]
}
@@ -0,0 +1,105 @@
#!/usr/bin/env bats
# ECR control-plane integration tests.
#
# Test-first: this file is committed before the server-side ECR implementation
# lands. With ECR unimplemented, every test below should fail.
setup() {
load 'test_helper/common-setup'
REPO_NAME="floci-it/app-cli-$(unique_name)"
}
teardown() {
aws_cmd ecr delete-repository --repository-name "$REPO_NAME" --force >/dev/null 2>&1 || true
}
@test "ECR: create-repository returns loopback URI" {
run aws_cmd ecr create-repository --repository-name "$REPO_NAME"
assert_success
arn=$(json_get "$output" '.repository.repositoryArn')
uri=$(json_get "$output" '.repository.repositoryUri')
name=$(json_get "$output" '.repository.repositoryName')
[ "$name" = "$REPO_NAME" ]
[[ "$arn" =~ ^arn:aws:ecr: ]]
[[ "$arn" == *":repository/$REPO_NAME" ]]
[[ "$uri" == *"localhost:"* ]]
[[ "$uri" == *"/$REPO_NAME" ]]
}
@test "ECR: create-repository duplicate fails with RepositoryAlreadyExistsException" {
aws_cmd ecr create-repository --repository-name "$REPO_NAME" >/dev/null
run aws_cmd ecr create-repository --repository-name "$REPO_NAME"
assert_failure
[[ "$output" == *"RepositoryAlreadyExistsException"* ]]
}
@test "ECR: describe-repositories returns the created repo" {
aws_cmd ecr create-repository --repository-name "$REPO_NAME" >/dev/null
run aws_cmd ecr describe-repositories --repository-names "$REPO_NAME"
assert_success
name=$(json_get "$output" '.repositories[0].repositoryName')
[ "$name" = "$REPO_NAME" ]
}
@test "ECR: get-authorization-token returns AWS:<password>" {
aws_cmd ecr create-repository --repository-name "$REPO_NAME" >/dev/null
run aws_cmd ecr get-authorization-token
assert_success
token=$(json_get "$output" '.authorizationData[0].authorizationToken')
proxy=$(json_get "$output" '.authorizationData[0].proxyEndpoint')
[ -n "$token" ]
[[ "$proxy" =~ ^https?:// ]]
decoded=$(echo "$token" | base64 -d)
[[ "$decoded" == AWS:* ]]
}
@test "ECR: list-images on empty repo returns []" {
aws_cmd ecr create-repository --repository-name "$REPO_NAME" >/dev/null
run aws_cmd ecr list-images --repository-name "$REPO_NAME"
assert_success
count=$(echo "$output" | jq '.imageIds | length')
[ "$count" = "0" ]
}
@test "ECR: put-image-tag-mutability round-trips IMMUTABLE" {
aws_cmd ecr create-repository --repository-name "$REPO_NAME" >/dev/null
run aws_cmd ecr put-image-tag-mutability --repository-name "$REPO_NAME" --image-tag-mutability IMMUTABLE
assert_success
mut=$(json_get "$output" '.imageTagMutability')
[ "$mut" = "IMMUTABLE" ]
}
@test "ECR: put-lifecycle-policy round-trips" {
aws_cmd ecr create-repository --repository-name "$REPO_NAME" >/dev/null
policy='{"rules":[{"rulePriority":1,"selection":{"tagStatus":"untagged","countType":"imageCountMoreThan","countNumber":5},"action":{"type":"expire"}}]}'
aws_cmd ecr put-lifecycle-policy --repository-name "$REPO_NAME" --lifecycle-policy-text "$policy" >/dev/null
run aws_cmd ecr get-lifecycle-policy --repository-name "$REPO_NAME"
assert_success
got=$(json_get "$output" '.lifecyclePolicyText')
[ "$got" = "$policy" ]
}
@test "ECR: set-repository-policy round-trips" {
aws_cmd ecr create-repository --repository-name "$REPO_NAME" >/dev/null
policy='{"Version":"2012-10-17","Statement":[{"Sid":"AllowAll","Effect":"Allow","Principal":"*","Action":"ecr:*"}]}'
aws_cmd ecr set-repository-policy --repository-name "$REPO_NAME" --policy-text "$policy" >/dev/null
run aws_cmd ecr get-repository-policy --repository-name "$REPO_NAME"
assert_success
got=$(json_get "$output" '.policyText')
[ "$got" = "$policy" ]
}
@test "ECR: delete-repository force=true removes the repo" {
aws_cmd ecr create-repository --repository-name "$REPO_NAME" >/dev/null
run aws_cmd ecr delete-repository --repository-name "$REPO_NAME" --force
assert_success
run aws_cmd ecr describe-repositories --repository-names "$REPO_NAME"
assert_failure
[[ "$output" == *"RepositoryNotFoundException"* ]]
}
@test "ECR: describe-repositories on missing name fails" {
run aws_cmd ecr describe-repositories --repository-names "does-not-exist-cli"
assert_failure
[[ "$output" == *"RepositoryNotFoundException"* ]]
}
@@ -0,0 +1,177 @@
#!/usr/bin/env bats
# ElastiCache tests
setup() {
load 'test_helper/common-setup'
PG="bats-pg-$(unique_name)"
}
teardown() {
aws_cmd elasticache delete-cache-parameter-group --cache-parameter-group-name "$PG" >/dev/null 2>&1 || true
}
@test "ElastiCache: create, describe, modify and delete a cache parameter group" {
run aws_cmd elasticache create-cache-parameter-group \
--cache-parameter-group-name "$PG" \
--cache-parameter-group-family redis7 \
--description "bats parameter group"
assert_success
[ "$(json_get "$output" '.CacheParameterGroup.CacheParameterGroupName')" = "$PG" ]
[ "$(json_get "$output" '.CacheParameterGroup.CacheParameterGroupFamily')" = "redis7" ]
[ "$(json_get "$output" '.CacheParameterGroup.Description')" = "bats parameter group" ]
[ "$(json_get "$output" '.CacheParameterGroup.IsGlobal')" = "false" ]
arn=$(json_get "$output" '.CacheParameterGroup.ARN')
[[ "$arn" == *":parametergroup:$PG" ]]
run aws_cmd elasticache describe-cache-parameter-groups --cache-parameter-group-name "$PG"
assert_success
[ "$(json_get "$output" '.CacheParameterGroups | length')" = "1" ]
run aws_cmd elasticache modify-cache-parameter-group \
--cache-parameter-group-name "$PG" \
--parameter-name-values 'ParameterName=maxmemory-policy,ParameterValue=allkeys-lru'
assert_success
[ "$(json_get "$output" '.CacheParameterGroupName')" = "$PG" ]
run aws_cmd elasticache describe-cache-parameters --cache-parameter-group-name "$PG"
assert_success
[ "$(json_get "$output" '.Parameters[0].ParameterName')" = "maxmemory-policy" ]
[ "$(json_get "$output" '.Parameters[0].ParameterValue')" = "allkeys-lru" ]
[ "$(json_get "$output" '.Parameters[0].Source')" = "user" ]
run aws_cmd elasticache delete-cache-parameter-group --cache-parameter-group-name "$PG"
assert_success
run aws_cmd elasticache describe-cache-parameter-groups --cache-parameter-group-name "$PG"
assert_failure
assert_output --partial "CacheParameterGroupNotFound"
}
@test "ElastiCache: the default parameter groups AWS publishes are listed" {
run aws_cmd elasticache describe-cache-parameter-groups
assert_success
names=$(json_get "$output" '.CacheParameterGroups[].CacheParameterGroupName')
echo "$names" | grep -q '^default\.redis7$'
echo "$names" | grep -q '^default\.redis7\.cluster\.on$'
echo "$names" | grep -q '^default\.valkey8$'
run aws_cmd elasticache describe-cache-parameter-groups --cache-parameter-group-name default.redis7
assert_success
[ "$(json_get "$output" '.CacheParameterGroups[0].CacheParameterGroupFamily')" = "redis7" ]
}
@test "ElastiCache: a duplicate parameter group is rejected" {
aws_cmd elasticache create-cache-parameter-group --cache-parameter-group-name "$PG" \
--cache-parameter-group-family redis7 --description first >/dev/null
run aws_cmd elasticache create-cache-parameter-group --cache-parameter-group-name "$PG" \
--cache-parameter-group-family redis7 --description second
assert_failure
assert_output --partial "CacheParameterGroupAlreadyExists"
}
@test "ElastiCache: an unknown family is rejected" {
run aws_cmd elasticache create-cache-parameter-group \
--cache-parameter-group-name "$PG" \
--cache-parameter-group-family redis99 --description x
assert_failure
assert_output --partial "not a valid parameter group family"
}
@test "ElastiCache: a default parameter group cannot be deleted" {
# AWS rejects it on the identifier rule, since a user-supplied name cannot contain dots.
run aws_cmd elasticache delete-cache-parameter-group --cache-parameter-group-name default.redis7
assert_failure
assert_output --partial "not a valid identifier"
}
@test "ElastiCache: cache subnet group takes its VPC and zones from the subnets" {
vpc=$(aws_cmd ec2 create-vpc --cidr-block 10.30.0.0/16 --query 'Vpc.VpcId' --output text)
a=$(aws_cmd ec2 create-subnet --vpc-id "$vpc" --cidr-block 10.30.1.0/24 \
--availability-zone us-east-1a --query 'Subnet.SubnetId' --output text)
b=$(aws_cmd ec2 create-subnet --vpc-id "$vpc" --cidr-block 10.30.2.0/24 \
--availability-zone us-east-1b --query 'Subnet.SubnetId' --output text)
SNG="bats-sng-$(unique_name)"
run aws_cmd elasticache create-cache-subnet-group \
--cache-subnet-group-name "$SNG" \
--cache-subnet-group-description "bats subnet group" \
--subnet-ids "$a" "$b"
assert_success
[ "$(json_get "$output" '.CacheSubnetGroup.VpcId')" = "$vpc" ]
[ "$(json_get "$output" '.CacheSubnetGroup.Subnets | length')" = "2" ]
[ "$(json_get "$output" '.CacheSubnetGroup.Subnets[0].SubnetAvailabilityZone.Name')" = "us-east-1a" ]
run aws_cmd elasticache describe-cache-subnet-groups --cache-subnet-group-name "$SNG"
assert_success
[ "$(json_get "$output" '.CacheSubnetGroups[0].CacheSubnetGroupName')" = "$SNG" ]
run aws_cmd elasticache create-cache-subnet-group \
--cache-subnet-group-name "bats-absent-$(unique_name)" \
--cache-subnet-group-description x --subnet-ids subnet-00000000000000000
assert_failure
assert_output --partial "are invalid"
run aws_cmd elasticache delete-cache-subnet-group --cache-subnet-group-name "$SNG"
assert_success
}
@test "ElastiCache: a cache subnet group is built from subnets in the caller's region" {
# Subnets exist in the region they were created in. Resolving them in the configured default
# region instead rejects a caller's own subnets as invalid.
export AWS_DEFAULT_REGION=eu-west-1
vpc=$(aws_cmd ec2 create-vpc --cidr-block 10.31.0.0/16 --query 'Vpc.VpcId' --output text)
s=$(aws_cmd ec2 create-subnet --vpc-id "$vpc" --cidr-block 10.31.1.0/24 \
--availability-zone eu-west-1a --query 'Subnet.SubnetId' --output text)
SNG_EU="bats-sng-eu-$(unique_name)"
run aws_cmd elasticache create-cache-subnet-group \
--cache-subnet-group-name "$SNG_EU" \
--cache-subnet-group-description "bats other region" \
--subnet-ids "$s"
assert_success
[ "$(json_get "$output" '.CacheSubnetGroup.VpcId')" = "$vpc" ]
[ "$(json_get "$output" '.CacheSubnetGroup.Subnets[0].SubnetAvailabilityZone.Name')" = "eu-west-1a" ]
[[ "$(json_get "$output" '.CacheSubnetGroup.ARN')" == arn:aws:elasticache:eu-west-1:* ]]
run aws_cmd elasticache delete-cache-subnet-group --cache-subnet-group-name "$SNG_EU"
assert_success
}
@test "ElastiCache: replication group keeps its encryption, snapshot settings and tags" {
RG="bats-rg-$(unique_name)"
run aws_cmd kms create-key --description "$RG"
assert_success
KEY_ARN=$(json_get "$output" '.KeyMetadata.Arn')
KEY_ID=$(json_get "$output" '.KeyMetadata.KeyId')
aws_cmd kms create-alias --alias-name "alias/$RG" --target-key-id "$KEY_ID" >/dev/null
run aws_cmd elasticache create-replication-group --replication-group-id "$RG" --replication-group-description d \
--engine redis --cache-node-type cache.t3.micro --num-cache-clusters 1 \
--at-rest-encryption-enabled --kms-key-id "alias/$RG" \
--snapshot-retention-limit 7 --snapshot-window 06:30-07:30 --tags "Key=Name,Value=$RG"
assert_success
run aws_cmd elasticache describe-replication-groups --replication-group-id "$RG" \
--query 'ReplicationGroups[0].[KmsKeyId,AtRestEncryptionEnabled,SnapshotRetentionLimit,SnapshotWindow,ARN]'
assert_success
assert_output --partial "$KEY_ARN"
assert_output --partial 'true'
assert_output --partial '7'
assert_output --partial '"06:30-07:30"'
assert_output --partial "replicationgroup:$RG"
ARN=$(aws_cmd elasticache describe-replication-groups --replication-group-id "$RG" --query 'ReplicationGroups[0].ARN' --output text)
run aws_cmd elasticache list-tags-for-resource --resource-name "$ARN"
assert_success
assert_output --partial "\"Value\": \"$RG\""
run aws_cmd elasticache modify-replication-group --replication-group-id "$RG" --snapshot-retention-limit 3 --snapshot-window 01:00-02:00 --apply-immediately
assert_success
run aws_cmd elasticache describe-replication-groups --replication-group-id "$RG" --query 'ReplicationGroups[0].[SnapshotRetentionLimit,SnapshotWindow]'
assert_success
assert_output --partial '3'
assert_output --partial '"01:00-02:00"'
aws_cmd elasticache delete-replication-group --replication-group-id "$RG" >/dev/null 2>&1 || true
}
@@ -0,0 +1,240 @@
#!/usr/bin/env bats
# AWS Fault Injection Service compatibility tests
setup() {
load 'test_helper/common-setup'
TEMPLATE_ID=""
TEMPLATE_ARN=""
EXPERIMENT_ID=""
TARGET_ACCOUNT_CONFIGURATION_EXISTS="false"
}
teardown() {
# The safety lever is account-global, so always restore its safe default even
# when an assertion aborts the lifecycle halfway through.
aws_cmd fis update-safety-lever-state \
--id default \
--state '{"status":"disengaged","reason":"AWS CLI compatibility cleanup"}' \
>/dev/null 2>&1 || true
if [ -n "$EXPERIMENT_ID" ]; then
aws_cmd fis stop-experiment --id "$EXPERIMENT_ID" >/dev/null 2>&1 || true
fi
if [ -n "$TEMPLATE_ID" ] && [ "$TARGET_ACCOUNT_CONFIGURATION_EXISTS" = "true" ]; then
aws_cmd fis delete-target-account-configuration \
--experiment-template-id "$TEMPLATE_ID" \
--account-id 111122223333 >/dev/null 2>&1 || true
fi
if [ -n "$TEMPLATE_ID" ]; then
aws_cmd fis delete-experiment-template --id "$TEMPLATE_ID" >/dev/null 2>&1 || true
fi
}
# This safe lifecycle invokes every AWS FIS management operation. It uses only
# the built-in wait action and starts the experiment with actionsMode=skip-all.
@test "FIS: all management operations use AWS CLI v2 wire shapes" {
local action_id target_resource_type client_token target_account_token
local next_token state template_listed account_listed owner experiment_status
local experiment_listed account_snapshot_listed
action_id="aws:fis:wait"
target_resource_type="aws:ec2:instance"
run aws_cmd fis get-action --id "$action_id"
assert_success
[ "$(json_get "$output" '.action.id')" = "$action_id" ]
[[ "$(json_get "$output" '.action.arn')" =~ :action/aws:fis:wait$ ]]
run aws_cmd fis list-actions --max-results 1
assert_success
[ "$(json_get "$output" '.actions | length')" = "1" ]
next_token=$(json_get "$output" '.nextToken')
[ -n "$next_token" ]
[ "$next_token" != "null" ]
run aws_cmd fis list-actions --max-results 1 --next-token "$next_token"
assert_success
[ "$(json_get "$output" '.actions | length')" = "1" ]
run aws_cmd fis get-target-resource-type --resource-type "$target_resource_type"
assert_success
[ "$(json_get "$output" '.targetResourceType.resourceType')" = "$target_resource_type" ]
run aws_cmd fis list-target-resource-types --max-results 1
assert_success
[ "$(json_get "$output" '.targetResourceTypes | length')" = "1" ]
next_token=$(json_get "$output" '.nextToken')
[ -n "$next_token" ]
[ "$next_token" != "null" ]
run aws_cmd fis list-target-resource-types --max-results 1 --next-token "$next_token"
assert_success
[ "$(json_get "$output" '.targetResourceTypes | length')" = "1" ]
run aws_cmd fis get-safety-lever --id default
assert_success
[ "$(json_get "$output" '.safetyLever.id')" = "default" ]
run aws_cmd fis update-safety-lever-state \
--id default \
--state '{"status":"engaged","reason":"AWS CLI compatibility test"}'
assert_success
[ "$(json_get "$output" '.safetyLever.state.status')" = "engaged" ]
run aws_cmd fis update-safety-lever-state \
--id default \
--state '{"status":"disengaged","reason":"AWS CLI compatibility test ready"}'
assert_success
[ "$(json_get "$output" '.safetyLever.state.status')" = "disengaged" ]
client_token=$(unique_name fis-cli-template)
run aws_cmd fis create-experiment-template \
--client-token "$client_token" \
--description "AWS CLI compatibility template" \
--stop-conditions '[{"source":"none"}]' \
--actions '{"wait":{"actionId":"aws:fis:wait","parameters":{"duration":"PT1M"}}}' \
--role-arn "arn:aws:iam::000000000000:role/fis-cli-role" \
--tags '{"suite":"awscli"}' \
--experiment-options '{"accountTargeting":"multi-account","emptyTargetResolutionMode":"skip"}'
assert_success
TEMPLATE_ID=$(json_get "$output" '.experimentTemplate.id')
TEMPLATE_ARN=$(json_get "$output" '.experimentTemplate.arn')
[ -n "$TEMPLATE_ID" ]
[[ "$TEMPLATE_ARN" =~ :experiment-template/${TEMPLATE_ID}$ ]]
[ "$(json_get "$output" '.experimentTemplate.tags.suite')" = "awscli" ]
run aws_cmd fis get-experiment-template --id "$TEMPLATE_ID"
assert_success
[ "$(json_get "$output" '.experimentTemplate.id')" = "$TEMPLATE_ID" ]
run aws_cmd fis update-experiment-template \
--id "$TEMPLATE_ID" \
--description "Updated through AWS CLI v2"
assert_success
[ "$(json_get "$output" '.experimentTemplate.description')" = "Updated through AWS CLI v2" ]
run aws_cmd fis list-experiment-templates --max-results 100
assert_success
template_listed=$(echo "$output" | jq --arg id "$TEMPLATE_ID" \
'.experimentTemplates | any(.id == $id)')
[ "$template_listed" = "true" ]
target_account_token=$(unique_name fis-cli-account)
run aws_cmd fis create-target-account-configuration \
--client-token "$target_account_token" \
--experiment-template-id "$TEMPLATE_ID" \
--account-id 111122223333 \
--role-arn "arn:aws:iam::111122223333:role/fis-cli-target-role" \
--description "AWS CLI target account"
assert_success
TARGET_ACCOUNT_CONFIGURATION_EXISTS="true"
[ "$(json_get "$output" '.targetAccountConfiguration.accountId')" = "111122223333" ]
run aws_cmd fis get-target-account-configuration \
--experiment-template-id "$TEMPLATE_ID" \
--account-id 111122223333
assert_success
[ "$(json_get "$output" '.targetAccountConfiguration.description')" = "AWS CLI target account" ]
run aws_cmd fis update-target-account-configuration \
--experiment-template-id "$TEMPLATE_ID" \
--account-id 111122223333 \
--role-arn "arn:aws:iam::111122223333:role/fis-cli-target-role" \
--description "Updated AWS CLI target account"
assert_success
[ "$(json_get "$output" '.targetAccountConfiguration.description')" = \
"Updated AWS CLI target account" ]
run aws_cmd fis list-target-account-configurations \
--experiment-template-id "$TEMPLATE_ID" \
--max-results 100
assert_success
account_listed=$(echo "$output" | jq \
'.targetAccountConfigurations | any(.accountId == "111122223333")')
[ "$account_listed" = "true" ]
run aws_cmd fis tag-resource \
--resource-arn "$TEMPLATE_ARN" \
--tags '{"owner":"compatibility"}'
assert_success
run aws_cmd fis list-tags-for-resource --resource-arn "$TEMPLATE_ARN"
assert_success
[ "$(json_get "$output" '.tags.suite')" = "awscli" ]
[ "$(json_get "$output" '.tags.owner')" = "compatibility" ]
run aws_cmd fis untag-resource \
--resource-arn "$TEMPLATE_ARN" \
--tag-keys owner
assert_success
run aws_cmd fis list-tags-for-resource --resource-arn "$TEMPLATE_ARN"
assert_success
owner=$(json_get "$output" '.tags.owner // empty')
[ -z "$owner" ]
[ "$(json_get "$output" '.tags.suite')" = "awscli" ]
run aws_cmd fis start-experiment \
--client-token "$(unique_name fis-cli-experiment)" \
--experiment-template-id "$TEMPLATE_ID" \
--experiment-options '{"actionsMode":"skip-all"}' \
--tags '{"mode":"safe-preview"}'
assert_success
EXPERIMENT_ID=$(json_get "$output" '.experiment.id')
[ -n "$EXPERIMENT_ID" ]
[ "$(json_get "$output" '.experiment.experimentTemplateId')" = "$TEMPLATE_ID" ]
[ "$(json_get "$output" '.experiment.experimentOptions.actionsMode')" = "skip-all" ]
experiment_status=$(json_get "$output" '.experiment.state.status')
[[ "$experiment_status" = "running" || "$experiment_status" = "completed" ]]
run aws_cmd fis get-experiment --id "$EXPERIMENT_ID"
assert_success
[ "$(json_get "$output" '.experiment.id')" = "$EXPERIMENT_ID" ]
[ "$(json_get "$output" '.experiment.tags.mode')" = "safe-preview" ]
run aws_cmd fis list-experiments \
--experiment-template-id "$TEMPLATE_ID" \
--max-results 100
assert_success
experiment_listed=$(echo "$output" | jq --arg id "$EXPERIMENT_ID" \
'.experiments | any(.id == $id)')
[ "$experiment_listed" = "true" ]
run aws_cmd fis list-experiment-resolved-targets \
--experiment-id "$EXPERIMENT_ID" \
--max-results 100
assert_success
[ "$(json_get "$output" '.resolvedTargets | length')" = "0" ]
run aws_cmd fis get-experiment-target-account-configuration \
--experiment-id "$EXPERIMENT_ID" \
--account-id 111122223333
assert_success
[ "$(json_get "$output" '.targetAccountConfiguration.accountId')" = "111122223333" ]
run aws_cmd fis list-experiment-target-account-configurations \
--experiment-id "$EXPERIMENT_ID"
assert_success
account_snapshot_listed=$(echo "$output" | jq \
'.targetAccountConfigurations | any(.accountId == "111122223333")')
[ "$account_snapshot_listed" = "true" ]
run aws_cmd fis stop-experiment --id "$EXPERIMENT_ID"
assert_success
state=$(json_get "$output" '.experiment.state.status')
[[ "$state" = "stopped" || "$state" = "completed" ]]
EXPERIMENT_ID=""
run aws_cmd fis delete-target-account-configuration \
--experiment-template-id "$TEMPLATE_ID" \
--account-id 111122223333
assert_success
[ "$(json_get "$output" '.targetAccountConfiguration.accountId')" = "111122223333" ]
TARGET_ACCOUNT_CONFIGURATION_EXISTS="false"
run aws_cmd fis delete-experiment-template --id "$TEMPLATE_ID"
assert_success
[ "$(json_get "$output" '.experimentTemplate.id')" = "$TEMPLATE_ID" ]
TEMPLATE_ID=""
TEMPLATE_ARN=""
}
@@ -0,0 +1,425 @@
#!/usr/bin/env bats
setup() {
load 'test_helper/common-setup'
DB_NAME="$(unique_name glue-catalog-db)"
TABLE_NAME="catalog_table"
SECOND_TABLE_NAME="catalog_table_second"
FUNCTION_NAME="catalog_function"
}
teardown() {
aws_cmd glue delete-user-defined-function \
--database-name "$DB_NAME" \
--function-name "$FUNCTION_NAME" >/dev/null 2>&1 || true
aws_cmd glue delete-table \
--database-name "$DB_NAME" \
--name "$SECOND_TABLE_NAME" >/dev/null 2>&1 || true
aws_cmd glue delete-table \
--database-name "$DB_NAME" \
--name "$TABLE_NAME" >/dev/null 2>&1 || true
aws_cmd glue delete-database \
--name "$DB_NAME" >/dev/null 2>&1 || true
}
create_database() {
aws_cmd glue create-database \
--database-input "{\"Name\":\"$DB_NAME\",\"Description\":\"catalog compatibility database\"}" >/dev/null
}
table_input() {
local name="${1:-$TABLE_NAME}"
local description="$2"
jq -n \
--arg name "$name" \
--arg description "$description" \
--arg location "s3://floci-glue-catalog/$DB_NAME/$name/" \
'{
Name: $name,
Description: $description,
Parameters: {
classification: "json"
},
StorageDescriptor: {
Location: $location,
InputFormat: "org.apache.hadoop.mapred.TextInputFormat",
OutputFormat: "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat",
SerdeInfo: {
SerializationLibrary: "org.openx.data.jsonserde.JsonSerDe",
Parameters: {
"serialization.format": "1"
}
},
Columns: [
{
Name: "id",
Type: "int",
Parameters: {
comment: "identifier"
}
}
]
},
PartitionKeys: [
{
Name: "year",
Type: "int"
}
]
}'
}
create_table() {
local name="${1:-$TABLE_NAME}"
local description="${2:-created}"
aws_cmd glue create-table \
--database-name "$DB_NAME" \
--table-input "$(table_input "$name" "$description")" >/dev/null
}
column_statistics_input() {
jq -n '[
{
ColumnName: "id",
ColumnType: "int",
AnalyzedTime: "2026-06-08T00:00:00Z",
StatisticsData: {
Type: "LONG",
LongColumnStatisticsData: {
MinimumValue: 1,
MaximumValue: 10,
NumberOfNulls: 0,
NumberOfDistinctValues: 10
}
}
}
]'
}
function_input() {
local owner="$1"
jq -n \
--arg name "$FUNCTION_NAME" \
--arg owner "$owner" \
'{
FunctionName: $name,
ClassName: "CatalogFunction",
OwnerName: $owner,
OwnerType: "USER",
ResourceUris: [
{
ResourceType: "FILE",
Uri: "s3://floci-glue-catalog/function.jar"
}
]
}'
}
@test "Glue catalog: database lifecycle" {
run create_database
assert_success
run aws_cmd glue get-database --name "$DB_NAME"
assert_success
name=$(json_get "$output" '.Database.Name')
[ "$name" = "$DB_NAME" ]
run aws_cmd glue get-databases
assert_success
found=$(echo "$output" | jq --arg name "$DB_NAME" '.DatabaseList | any(.Name == $name)')
[ "$found" = "true" ]
run aws_cmd glue update-database \
--name "$DB_NAME" \
--database-input "{\"Name\":\"$DB_NAME\",\"Description\":\"updated database\",\"LocationUri\":\"s3://floci-glue-catalog/$DB_NAME/\"}"
assert_success
run aws_cmd glue get-database --name "$DB_NAME"
assert_success
description=$(json_get "$output" '.Database.Description')
location=$(json_get "$output" '.Database.LocationUri')
[ "$description" = "updated database" ]
[ "$location" = "s3://floci-glue-catalog/$DB_NAME/" ]
run aws_cmd glue update-database \
--name "$DB_NAME" \
--database-input "{\"Name\":\"${DB_NAME}-renamed\",\"Description\":\"renamed database\"}"
assert_failure
[[ "$output" == *"InvalidInputException"* ]]
[[ "$output" == *"Database cannot be renamed"* ]]
run aws_cmd glue delete-database --name "$DB_NAME"
assert_success
run aws_cmd glue get-database --name "$DB_NAME"
assert_failure
}
@test "Glue catalog: table and partition lifecycle" {
run create_database
assert_success
run create_table
assert_success
run aws_cmd glue get-table \
--database-name "$DB_NAME" \
--name "$TABLE_NAME"
assert_success
name=$(json_get "$output" '.Table.Name')
version_id=$(json_get "$output" '.Table.VersionId')
column_comment=$(json_get "$output" '.Table.StorageDescriptor.Columns[0].Parameters.comment')
[ "$name" = "$TABLE_NAME" ]
[ "$version_id" = "0" ]
[ "$column_comment" = "identifier" ]
run aws_cmd glue get-tables --database-name "$DB_NAME"
assert_success
found=$(echo "$output" | jq --arg name "$TABLE_NAME" '.TableList | any(.Name == $name)')
[ "$found" = "true" ]
run aws_cmd glue update-table \
--database-name "$DB_NAME" \
--version-id "$version_id" \
--table-input "$(table_input "$TABLE_NAME" updated)"
assert_success
run aws_cmd glue get-table \
--database-name "$DB_NAME" \
--name "$TABLE_NAME"
assert_success
description=$(json_get "$output" '.Table.Description')
version_id=$(json_get "$output" '.Table.VersionId')
[ "$description" = "updated" ]
[ "$version_id" = "1" ]
run aws_cmd glue get-table-versions \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME"
assert_success
version_count=$(echo "$output" | jq '.TableVersions | length')
current_version=$(json_get "$output" '.TableVersions[0].VersionId')
current_description=$(json_get "$output" '.TableVersions[0].Table.Description')
archived_version=$(json_get "$output" '.TableVersions[1].VersionId')
archived_description=$(json_get "$output" '.TableVersions[1].Table.Description')
[ "$version_count" = "2" ]
[ "$current_version" = "1" ]
[ "$current_description" = "updated" ]
[ "$archived_version" = "0" ]
[ "$archived_description" = "created" ]
run aws_cmd glue update-column-statistics-for-table \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--column-statistics-list "$(column_statistics_input)"
assert_success
run aws_cmd glue get-column-statistics-for-table \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--column-names id missing
assert_success
column_name=$(json_get "$output" '.ColumnStatisticsList[0].ColumnName')
statistics_type=$(json_get "$output" '.ColumnStatisticsList[0].StatisticsData.Type')
minimum_value=$(json_get "$output" '.ColumnStatisticsList[0].StatisticsData.LongColumnStatisticsData.MinimumValue')
missing_column_name=$(json_get "$output" '.Errors[0].ColumnName')
error_code=$(json_get "$output" '.Errors[0].Error.ErrorCode')
[ "$column_name" = "id" ]
[ "$statistics_type" = "LONG" ]
[ "$minimum_value" = "1" ]
[ "$missing_column_name" = "missing" ]
[ "$error_code" = "EntityNotFoundException" ]
run aws_cmd glue delete-column-statistics-for-table \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--column-name id
assert_success
run aws_cmd glue delete-column-statistics-for-table \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--column-name id
assert_success
run aws_cmd glue get-column-statistics-for-table \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--column-names id
assert_success
deleted_statistics_count=$(json_get "$output" '.ColumnStatisticsList | length')
deleted_error_code=$(json_get "$output" '.Errors[0].Error.ErrorCode')
[ "$deleted_statistics_count" = "0" ]
[ "$deleted_error_code" = "EntityNotFoundException" ]
run aws_cmd glue create-partition \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--partition-input '{"Values":["2026"]}'
assert_success
run aws_cmd glue batch-create-partition \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--partition-input-list '[{"Values":["2027"]},{"Values":["2028"]}]'
assert_success
batch_create_error_count=$(json_get "$output" '.Errors | length')
[ "$batch_create_error_count" = "0" ]
run aws_cmd glue batch-create-partition \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--partition-input-list '[{"Values":["2027"]}]'
assert_success
duplicate_partition_value=$(json_get "$output" '.Errors[0].PartitionValues[0]')
duplicate_error_code=$(json_get "$output" '.Errors[0].ErrorDetail.ErrorCode')
[ "$duplicate_partition_value" = "2027" ]
[ "$duplicate_error_code" = "AlreadyExistsException" ]
batch_update_entries='[
{"PartitionValueList":["2026"],"PartitionInput":{"Values":["2026"],"Parameters":{"after":"yes"}}},
{"PartitionValueList":["missing"],"PartitionInput":{"Values":["missing"]}}
]'
run aws_cmd glue batch-update-partition \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--entries "$batch_update_entries"
assert_success
updated_missing_partition_value=$(json_get "$output" '.Errors[0].PartitionValueList[0]')
updated_missing_error_code=$(json_get "$output" '.Errors[0].ErrorDetail.ErrorCode')
[ "$updated_missing_partition_value" = "missing" ]
[ "$updated_missing_error_code" = "EntityNotFoundException" ]
run aws_cmd glue update-partition \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--partition-value-list 2027 \
--partition-input '{"Values":["2027"],"Parameters":{"updated":"yes"}}'
assert_success
run aws_cmd glue update-partition \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--partition-value-list missing \
--partition-input '{"Values":["missing"]}'
assert_failure
[[ "$output" == *"EntityNotFoundException"* ]]
run aws_cmd glue get-partitions \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME"
assert_success
value=$(json_get "$output" '.Partitions[0].Values[0]')
parameter=$(json_get "$output" '.Partitions[0].Parameters.after')
[ "$value" = "2026" ]
[ "$parameter" = "yes" ]
run aws_cmd glue get-partitions \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--expression "year >= 2026 AND year <= 2027"
assert_success
filtered_count=$(json_get "$output" '.Partitions | length')
[ "$filtered_count" = "2" ]
run aws_cmd glue get-partitions \
--database-name "$DB_NAME" \
--table-name "$TABLE_NAME" \
--expression "year in (2026, 2028)"
assert_success
filtered_count=$(json_get "$output" '.Partitions | length')
[ "$filtered_count" = "2" ]
run aws_cmd glue delete-table \
--database-name "$DB_NAME" \
--name "$TABLE_NAME"
assert_success
run aws_cmd glue get-table \
--database-name "$DB_NAME" \
--name "$TABLE_NAME"
assert_failure
run aws_cmd glue delete-table \
--database-name "$DB_NAME" \
--name "$TABLE_NAME"
assert_failure
[[ "$output" == *"EntityNotFoundException"* ]]
}
@test "Glue catalog: batch delete table" {
run create_database
assert_success
run create_table "$TABLE_NAME" "batch delete first table"
assert_success
run create_table "$SECOND_TABLE_NAME" "batch delete second table"
assert_success
run aws_cmd glue batch-delete-table \
--database-name "$DB_NAME" \
--tables-to-delete "$TABLE_NAME" "$SECOND_TABLE_NAME"
assert_success
error_count=$(echo "$output" | jq '.Errors | length')
[ "$error_count" = "0" ]
run aws_cmd glue get-table \
--database-name "$DB_NAME" \
--name "$TABLE_NAME"
assert_failure
run aws_cmd glue get-table \
--database-name "$DB_NAME" \
--name "$SECOND_TABLE_NAME"
assert_failure
}
@test "Glue catalog: user-defined function lifecycle" {
run create_database
assert_success
run aws_cmd glue create-user-defined-function \
--database-name "$DB_NAME" \
--function-input "$(function_input created-owner)"
assert_success
run aws_cmd glue get-user-defined-function \
--database-name "$DB_NAME" \
--function-name "$FUNCTION_NAME"
assert_success
name=$(json_get "$output" '.UserDefinedFunction.FunctionName')
owner=$(json_get "$output" '.UserDefinedFunction.OwnerName')
[ "$name" = "$FUNCTION_NAME" ]
[ "$owner" = "created-owner" ]
run aws_cmd glue get-user-defined-functions \
--database-name "$DB_NAME" \
--pattern "catalog_.*"
assert_success
found=$(echo "$output" | jq --arg name "$FUNCTION_NAME" '.UserDefinedFunctions | any(.FunctionName == $name)')
[ "$found" = "true" ]
run aws_cmd glue update-user-defined-function \
--database-name "$DB_NAME" \
--function-name "$FUNCTION_NAME" \
--function-input "$(function_input updated-owner)"
assert_success
run aws_cmd glue get-user-defined-function \
--database-name "$DB_NAME" \
--function-name "$FUNCTION_NAME"
assert_success
owner=$(json_get "$output" '.UserDefinedFunction.OwnerName')
[ "$owner" = "updated-owner" ]
run aws_cmd glue delete-user-defined-function \
--database-name "$DB_NAME" \
--function-name "$FUNCTION_NAME"
assert_success
run aws_cmd glue get-user-defined-function \
--database-name "$DB_NAME" \
--function-name "$FUNCTION_NAME"
assert_failure
}
@@ -0,0 +1,258 @@
#!/usr/bin/env bats
# IAM tests
setup() {
load 'test_helper/common-setup'
ROLE_NAME="bats-test-role-$(unique_name)"
POLICY_ARN=""
ACCOUNT_ALIAS=""
}
teardown() {
if [ -n "$POLICY_ARN" ]; then
aws_cmd iam detach-role-policy --role-name "$ROLE_NAME" --policy-arn "$POLICY_ARN" >/dev/null 2>&1 || true
aws_cmd iam delete-policy --policy-arn "$POLICY_ARN" >/dev/null 2>&1 || true
fi
aws_cmd iam delete-role --role-name "$ROLE_NAME" >/dev/null 2>&1 || true
# An account holds one alias, so a leaked one would fail every later create.
if [ -n "$ACCOUNT_ALIAS" ]; then
aws_cmd iam delete-account-alias --account-alias "$ACCOUNT_ALIAS" >/dev/null 2>&1 || true
fi
}
@test "IAM: create role" {
local policy_doc='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
run aws_cmd iam create-role \
--role-name "$ROLE_NAME" \
--assume-role-policy-document "$policy_doc"
assert_success
arn=$(json_get "$output" '.Role.Arn')
[ -n "$arn" ]
}
@test "IAM: get role" {
local policy_doc='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws_cmd iam create-role --role-name "$ROLE_NAME" --assume-role-policy-document "$policy_doc" >/dev/null
run aws_cmd iam get-role --role-name "$ROLE_NAME"
assert_success
name=$(json_get "$output" '.Role.RoleName')
[ "$name" = "$ROLE_NAME" ]
}
@test "IAM: list roles" {
local policy_doc='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws_cmd iam create-role --role-name "$ROLE_NAME" --assume-role-policy-document "$policy_doc" >/dev/null
run aws_cmd iam list-roles
assert_success
found=$(echo "$output" | jq --arg name "$ROLE_NAME" '.Roles | any(.RoleName == $name)')
[ "$found" = "true" ]
}
@test "IAM: create and delete policy" {
local policy_doc='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}'
run aws_cmd iam create-policy \
--policy-name "bats-test-policy-$(unique_name)" \
--policy-document "$policy_doc"
assert_success
POLICY_ARN=$(json_get "$output" '.Policy.Arn')
[ -n "$POLICY_ARN" ]
run aws_cmd iam delete-policy --policy-arn "$POLICY_ARN"
assert_success
POLICY_ARN=""
}
@test "IAM: attach and detach role policy" {
local role_policy_doc='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
local policy_doc='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}'
aws_cmd iam create-role --role-name "$ROLE_NAME" --assume-role-policy-document "$role_policy_doc" >/dev/null
out=$(aws_cmd iam create-policy --policy-name "bats-test-policy-$(unique_name)" --policy-document "$policy_doc")
POLICY_ARN=$(json_get "$out" '.Policy.Arn')
run aws_cmd iam attach-role-policy --role-name "$ROLE_NAME" --policy-arn "$POLICY_ARN"
assert_success
run aws_cmd iam detach-role-policy --role-name "$ROLE_NAME" --policy-arn "$POLICY_ARN"
assert_success
}
@test "IAM: delete role" {
local policy_doc='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws_cmd iam create-role --role-name "$ROLE_NAME" --assume-role-policy-document "$policy_doc" >/dev/null
run aws_cmd iam delete-role --role-name "$ROLE_NAME"
assert_success
}
# ─── OIDC identity providers ────────────────────────────────────────────────
# Creates a provider and sets OIDC_ARN. Each test uses a unique URL so the suite
# stays safe when bats runs test files in parallel.
create_oidc_provider() {
local url="https://oidc.bats.example.com/id/$(date +%s)-$$-${1:-0}"
local out
out=$(aws_cmd iam create-open-id-connect-provider \
--url "$url" \
--client-id-list sts.amazonaws.com \
--thumbprint-list 9e99a48a9960b14926bb7f3b02e22da2b0ab7280)
OIDC_ARN=$(json_get "$out" '.OpenIDConnectProviderArn')
OIDC_HOST="${url#https://}"
}
@test "IAM: create and get OIDC provider" {
create_oidc_provider 1
[ -n "$OIDC_ARN" ]
run aws_cmd iam get-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_ARN"
assert_success
# AWS reports the URL without its scheme.
url=$(json_get "$output" '.Url')
[ "$url" = "$OIDC_HOST" ]
client=$(json_get "$output" '.ClientIDList[0]')
[ "$client" = "sts.amazonaws.com" ]
aws_cmd iam delete-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_ARN" >/dev/null
}
@test "IAM: list OIDC providers includes the created provider" {
create_oidc_provider 2
run aws_cmd iam list-open-id-connect-providers
assert_success
found=$(echo "$output" | jq --arg arn "$OIDC_ARN" '.OpenIDConnectProviderList | any(.Arn == $arn)')
[ "$found" = "true" ]
aws_cmd iam delete-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_ARN" >/dev/null
}
@test "IAM: add and remove an OIDC client id" {
create_oidc_provider 3
run aws_cmd iam add-client-id-to-open-id-connect-provider \
--open-id-connect-provider-arn "$OIDC_ARN" --client-id extra.audience
assert_success
run aws_cmd iam get-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_ARN"
assert_success
count=$(json_get "$output" '.ClientIDList | length')
[ "$count" = "2" ]
run aws_cmd iam remove-client-id-from-open-id-connect-provider \
--open-id-connect-provider-arn "$OIDC_ARN" --client-id extra.audience
assert_success
aws_cmd iam delete-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_ARN" >/dev/null
}
@test "IAM: update OIDC provider thumbprint" {
create_oidc_provider 4
run aws_cmd iam update-open-id-connect-provider-thumbprint \
--open-id-connect-provider-arn "$OIDC_ARN" \
--thumbprint-list 1111111111111111111111111111111111111111
assert_success
run aws_cmd iam get-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_ARN"
assert_success
thumb=$(json_get "$output" '.ThumbprintList[0]')
[ "$thumb" = "1111111111111111111111111111111111111111" ]
aws_cmd iam delete-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_ARN" >/dev/null
}
@test "IAM: delete OIDC provider" {
create_oidc_provider 5
run aws_cmd iam delete-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_ARN"
assert_success
run aws_cmd iam get-open-id-connect-provider --open-id-connect-provider-arn "$OIDC_ARN"
assert_failure
assert_output --partial "NoSuchEntity"
}
@test "IAM: OIDC provider url must be https" {
run aws_cmd iam create-open-id-connect-provider \
--url http://oidc.bats.example.com/id/insecure \
--thumbprint-list 9e99a48a9960b14926bb7f3b02e22da2b0ab7280
assert_failure
assert_output --partial "ValidationError"
}
@test "IAM: list account aliases is empty by default" {
run aws_cmd iam list-account-aliases
assert_success
count=$(json_get "$output" '.AccountAliases | length')
[ "$count" = "0" ]
}
@test "IAM: create and list account alias" {
ACCOUNT_ALIAS="$(unique_name bats-alias)"
run aws_cmd iam create-account-alias --account-alias "$ACCOUNT_ALIAS"
assert_success
run aws_cmd iam list-account-aliases
assert_success
alias=$(json_get "$output" '.AccountAliases[0]')
[ "$alias" = "$ACCOUNT_ALIAS" ]
}
@test "IAM: creating another account alias replaces the current one" {
ACCOUNT_ALIAS="$(unique_name bats-alias)"
aws_cmd iam create-account-alias --account-alias "$ACCOUNT_ALIAS" >/dev/null
local replacement
replacement="$(unique_name bats-alias-two)"
run aws_cmd iam create-account-alias --account-alias "$replacement"
assert_success
ACCOUNT_ALIAS="$replacement"
run aws_cmd iam list-account-aliases
assert_success
alias=$(json_get "$output" '.AccountAliases[0]')
[ "$alias" = "$replacement" ]
}
@test "IAM: re-creating the alias the account already holds fails" {
ACCOUNT_ALIAS="$(unique_name bats-alias)"
aws_cmd iam create-account-alias --account-alias "$ACCOUNT_ALIAS" >/dev/null
run aws_cmd iam create-account-alias --account-alias "$ACCOUNT_ALIAS"
assert_failure
assert_output --partial "EntityAlreadyExists"
}
@test "IAM: deleting a mismatched account alias fails" {
ACCOUNT_ALIAS="$(unique_name bats-alias)"
aws_cmd iam create-account-alias --account-alias "$ACCOUNT_ALIAS" >/dev/null
run aws_cmd iam delete-account-alias --account-alias "bats-alias-not-set-$$"
assert_failure
assert_output --partial "NoSuchEntity"
}
@test "IAM: delete account alias" {
ACCOUNT_ALIAS="$(unique_name bats-alias)"
aws_cmd iam create-account-alias --account-alias "$ACCOUNT_ALIAS" >/dev/null
run aws_cmd iam delete-account-alias --account-alias "$ACCOUNT_ALIAS"
assert_success
run aws_cmd iam list-account-aliases
assert_success
count=$(json_get "$output" '.AccountAliases | length')
[ "$count" = "0" ]
}
@test "IAM: malformed account alias is rejected" {
run aws_cmd iam create-account-alias --account-alias "Upper-Case"
assert_failure
assert_output --partial "ValidationError"
}
@@ -0,0 +1,417 @@
#!/usr/bin/env bats
# KMS tests
setup() {
load 'test_helper/common-setup'
KEY_ID=""
}
teardown() {
if [ -n "$KEY_ID" ]; then
aws_cmd kms schedule-key-deletion --key-id "$KEY_ID" --pending-window-in-days 7 >/dev/null 2>&1 || true
fi
}
@test "KMS: create key" {
run aws_cmd kms create-key --description "bats-test-key"
assert_success
KEY_ID=$(json_get "$output" '.KeyMetadata.KeyId')
[ -n "$KEY_ID" ]
}
@test "KMS: describe key" {
out=$(aws_cmd kms create-key --description "bats-test-key")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
run aws_cmd kms describe-key --key-id "$KEY_ID"
assert_success
enabled=$(json_get "$output" '.KeyMetadata.Enabled')
[ "$enabled" = "true" ]
}
@test "KMS: list keys" {
out=$(aws_cmd kms create-key --description "bats-test-key")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
run aws_cmd kms list-keys
assert_success
found=$(echo "$output" | jq --arg id "$KEY_ID" '.Keys | any(.KeyId == $id)')
[ "$found" = "true" ]
}
@test "KMS: list grants" {
out=$(aws_cmd kms create-key --description "bats-test-key")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
[ -n "$KEY_ID" ]
run aws_cmd kms list-grants --key-id "$KEY_ID"
assert_success
has_grants=$(echo "$output" | jq 'has("Grants") and (.Grants | type == "array")')
[ "$has_grants" = "true" ]
}
@test "KMS: encrypt and decrypt" {
out=$(aws_cmd kms create-key --description "bats-test-key")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
# Create a temp file with plaintext
local plaintext_file ciphertext_file decrypted_file
plaintext_file=$(mktemp)
ciphertext_file=$(mktemp)
decrypted_file=$(mktemp)
echo -n "hello-kms-bats" > "$plaintext_file"
run aws_cmd kms encrypt \
--key-id "$KEY_ID" \
--plaintext "fileb://$plaintext_file" \
--output text \
--query CiphertextBlob
assert_success
echo "$output" | base64 -d > "$ciphertext_file"
run aws_cmd kms decrypt \
--ciphertext-blob "fileb://$ciphertext_file" \
--output text \
--query Plaintext
assert_success
echo "$output" | base64 -d > "$decrypted_file"
decrypted=$(cat "$decrypted_file")
[ "$decrypted" = "hello-kms-bats" ]
rm -f "$plaintext_file" "$ciphertext_file" "$decrypted_file"
}
@test "KMS: generate data key" {
out=$(aws_cmd kms create-key --description "bats-test-key")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
run aws_cmd kms generate-data-key --key-id "$KEY_ID" --key-spec AES_256
assert_success
plaintext=$(json_get "$output" '.Plaintext')
[ -n "$plaintext" ]
ciphertext=$(json_get "$output" '.CiphertextBlob')
[ -n "$ciphertext" ]
}
# --- KMS Alias Tests ---
@test "KMS: create alias" {
out=$(aws_cmd kms create-key --description "bats-test-key")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
alias_name="alias/bats-test-$(unique_name)"
run aws_cmd kms create-alias --alias-name "$alias_name" --target-key-id "$KEY_ID"
assert_success
# Cleanup alias
aws_cmd kms delete-alias --alias-name "$alias_name" >/dev/null 2>&1 || true
}
@test "KMS: list aliases" {
out=$(aws_cmd kms create-key --description "bats-test-key")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
alias_name="alias/bats-test-$(unique_name)"
aws_cmd kms create-alias --alias-name "$alias_name" --target-key-id "$KEY_ID" >/dev/null
run aws_cmd kms list-aliases
assert_success
found=$(echo "$output" | jq --arg name "$alias_name" '.Aliases | any(.AliasName == $name)')
[ "$found" = "true" ]
# Cleanup alias
aws_cmd kms delete-alias --alias-name "$alias_name" >/dev/null 2>&1 || true
}
@test "KMS: create HMAC key and describe returns MacAlgorithms" {
out=$(aws_cmd kms create-key \
--description "bats-hmac-$(unique_name)" \
--key-spec HMAC_256 \
--key-usage GENERATE_VERIFY_MAC)
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
[ -n "$KEY_ID" ]
spec=$(json_get "$out" '.KeyMetadata.KeySpec')
[ "$spec" = "HMAC_256" ]
run aws_cmd kms describe-key --key-id "$KEY_ID"
assert_success
macs=$(echo "$output" | jq -r '.KeyMetadata.MacAlgorithms[0]')
[ "$macs" = "HMAC_SHA_256" ]
}
@test "KMS: delete alias" {
out=$(aws_cmd kms create-key --description "bats-test-key")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
alias_name="alias/bats-test-$(unique_name)"
aws_cmd kms create-alias --alias-name "$alias_name" --target-key-id "$KEY_ID" >/dev/null
run aws_cmd kms delete-alias --alias-name "$alias_name"
assert_success
# Verify alias is deleted
run aws_cmd kms list-aliases
found=$(echo "$output" | jq --arg name "$alias_name" '.Aliases | any(.AliasName == $name)')
[ "$found" = "false" ]
}
@test "KMS: create key with reserved override tag uses pinned ID and strips reserved tag" {
run aws_cmd kms create-key \
--description "override-key" \
--tags TagKey=floci:override-id,TagValue=bats-pinned-key TagKey=env,TagValue=test
assert_success
KEY_ID=$(json_get "$output" '.KeyMetadata.KeyId')
[ "$KEY_ID" = "bats-pinned-key" ]
run aws_cmd kms list-resource-tags --key-id "$KEY_ID"
assert_success
has_reserved=$(echo "$output" | jq 'any(.Tags[]?; .TagKey == "floci:override-id")')
[ "$has_reserved" = "false" ]
env_value=$(echo "$output" | jq -r '.Tags[] | select(.TagKey == "env") | .TagValue')
[ "$env_value" = "test" ]
}
@test "KMS: duplicate reserved override tag fails with AlreadyExistsException" {
out=$(aws_cmd kms create-key \
--description "override-key" \
--tags TagKey=floci:override-id,TagValue=bats-duplicate-key)
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
run aws_cmd kms create-key \
--description "override-key-2" \
--tags TagKey=floci:override-id,TagValue=bats-duplicate-key
assert_failure
[[ "$output" == *"AlreadyExistsException"* ]]
}
@test "KMS: tag-resource rejects reserved override tag after creation" {
out=$(aws_cmd kms create-key --description "bats-test-key")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
run aws_cmd kms tag-resource \
--key-id "$KEY_ID" \
--tags TagKey=floci:override-id,TagValue=late-id
assert_failure
[[ "$output" == *"ValidationException"* ]]
}
# Issue #1844: Decrypt must enforce KeyId against wrapping key
@test "KMS: decrypt with matching KeyId returns plaintext" {
out=$(aws_cmd kms create-key --description "bats-1844-match")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
plaintext_file=$(mktemp)
ciphertext_file=$(mktemp)
echo -n "secret data" > "$plaintext_file"
run aws_cmd kms encrypt \
--key-id "$KEY_ID" \
--plaintext "fileb://$plaintext_file" \
--output text \
--query CiphertextBlob
assert_success
echo "$output" | base64 -d > "$ciphertext_file"
run aws_cmd kms decrypt \
--ciphertext-blob "fileb://$ciphertext_file" \
--key-id "$KEY_ID" \
--output text \
--query Plaintext
assert_success
[ "$(echo "$output" | base64 -d)" = "secret data" ]
rm -f "$plaintext_file" "$ciphertext_file"
}
@test "KMS: decrypt with mismatched KeyId returns IncorrectKeyException" {
out_a=$(aws_cmd kms create-key --description "bats-1844-a")
key_a=$(json_get "$out_a" '.KeyMetadata.KeyId')
out_b=$(aws_cmd kms create-key --description "bats-1844-b")
key_b=$(json_get "$out_b" '.KeyMetadata.KeyId')
plaintext_file=$(mktemp)
ciphertext_file=$(mktemp)
echo -n "secret data" > "$plaintext_file"
run aws_cmd kms encrypt \
--key-id "$key_a" \
--plaintext "fileb://$plaintext_file" \
--output text \
--query CiphertextBlob
assert_success
echo "$output" | base64 -d > "$ciphertext_file"
run aws_cmd kms decrypt \
--ciphertext-blob "fileb://$ciphertext_file" \
--key-id "$key_b"
assert_failure
[[ "$output" == *"IncorrectKeyException"* ]]
rm -f "$plaintext_file" "$ciphertext_file"
aws_cmd kms schedule-key-deletion --key-id "$key_a" --pending-window-in-days 7 >/dev/null 2>&1 || true
aws_cmd kms schedule-key-deletion --key-id "$key_b" --pending-window-in-days 7 >/dev/null 2>&1 || true
}
@test "KMS: reencrypt with matching SourceKeyId re-wraps ciphertext" {
out_a=$(aws_cmd kms create-key --description "bats-reencrypt-match-a")
key_a=$(json_get "$out_a" '.KeyMetadata.KeyId')
out_b=$(aws_cmd kms create-key --description "bats-reencrypt-match-b")
key_b=$(json_get "$out_b" '.KeyMetadata.KeyId')
plaintext_file=$(mktemp)
ciphertext_file=$(mktemp)
reencrypted_file=$(mktemp)
decrypted_file=$(mktemp)
echo -n "reencrypt test data" > "$plaintext_file"
run aws_cmd kms encrypt \
--key-id "$key_a" \
--plaintext "fileb://$plaintext_file" \
--output text \
--query CiphertextBlob
assert_success
echo "$output" | base64 -d > "$ciphertext_file"
run aws_cmd kms re-encrypt \
--ciphertext-blob "fileb://$ciphertext_file" \
--source-key-id "$key_a" \
--destination-key-id "$key_b" \
--output text \
--query CiphertextBlob
assert_success
echo "$output" | base64 -d > "$reencrypted_file"
run aws_cmd kms decrypt \
--ciphertext-blob "fileb://$reencrypted_file" \
--output text \
--query Plaintext
assert_success
echo "$output" | base64 -d > "$decrypted_file"
[ "$(cat "$decrypted_file")" = "reencrypt test data" ]
rm -f "$plaintext_file" "$ciphertext_file" "$reencrypted_file" "$decrypted_file"
aws_cmd kms schedule-key-deletion --key-id "$key_a" --pending-window-in-days 7 >/dev/null 2>&1 || true
aws_cmd kms schedule-key-deletion --key-id "$key_b" --pending-window-in-days 7 >/dev/null 2>&1 || true
}
@test "KMS: reencrypt with mismatched SourceKeyId returns IncorrectKeyException" {
out_a=$(aws_cmd kms create-key --description "bats-reencrypt-mismatch-a")
key_a=$(json_get "$out_a" '.KeyMetadata.KeyId')
out_b=$(aws_cmd kms create-key --description "bats-reencrypt-mismatch-b")
key_b=$(json_get "$out_b" '.KeyMetadata.KeyId')
plaintext_file=$(mktemp)
ciphertext_file=$(mktemp)
echo -n "reencrypt test data" > "$plaintext_file"
run aws_cmd kms encrypt \
--key-id "$key_a" \
--plaintext "fileb://$plaintext_file" \
--output text \
--query CiphertextBlob
assert_success
echo "$output" | base64 -d > "$ciphertext_file"
run aws_cmd kms re-encrypt \
--ciphertext-blob "fileb://$ciphertext_file" \
--source-key-id "$key_b" \
--destination-key-id "$key_a"
assert_failure
[[ "$output" == *"IncorrectKeyException"* ]]
rm -f "$plaintext_file" "$ciphertext_file"
aws_cmd kms schedule-key-deletion --key-id "$key_a" --pending-window-in-days 7 >/dev/null 2>&1 || true
aws_cmd kms schedule-key-deletion --key-id "$key_b" --pending-window-in-days 7 >/dev/null 2>&1 || true
}
@test "KMS: decrypt with disabled key returns DisabledException" {
out=$(aws_cmd kms create-key --description "bats-disabled-decrypt")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
plaintext_file=$(mktemp)
ciphertext_file=$(mktemp)
echo -n "secret data" > "$plaintext_file"
run aws_cmd kms encrypt \
--key-id "$KEY_ID" \
--plaintext "fileb://$plaintext_file" \
--output text \
--query CiphertextBlob
assert_success
echo "$output" | base64 -d > "$ciphertext_file"
run aws_cmd kms disable-key --key-id "$KEY_ID"
assert_success
run aws_cmd kms decrypt \
--ciphertext-blob "fileb://$ciphertext_file" \
--key-id "$KEY_ID"
assert_failure
[[ "$output" == *"DisabledException"* ]]
rm -f "$plaintext_file" "$ciphertext_file"
}
@test "KMS: encrypt with disabled key returns DisabledException" {
out=$(aws_cmd kms create-key --description "bats-disabled-encrypt")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
run aws_cmd kms disable-key --key-id "$KEY_ID"
assert_success
plaintext_file=$(mktemp)
echo -n "secret data" > "$plaintext_file"
run aws_cmd kms encrypt --key-id "$KEY_ID" --plaintext "fileb://$plaintext_file"
assert_failure
[[ "$output" == *"DisabledException"* ]]
rm -f "$plaintext_file"
}
@test "KMS: decrypt with pending deletion key returns KmsInvalidStateException" {
out=$(aws_cmd kms create-key --description "bats-pending-decrypt")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
plaintext_file=$(mktemp)
ciphertext_file=$(mktemp)
echo -n "secret data" > "$plaintext_file"
run aws_cmd kms encrypt \
--key-id "$KEY_ID" \
--plaintext "fileb://$plaintext_file" \
--output text \
--query CiphertextBlob
assert_success
echo "$output" | base64 -d > "$ciphertext_file"
run aws_cmd kms schedule-key-deletion --key-id "$KEY_ID" --pending-window-in-days 7
assert_success
run aws_cmd kms decrypt \
--ciphertext-blob "fileb://$ciphertext_file" \
--key-id "$KEY_ID"
assert_failure
[[ "$output" == *"KMSInvalidStateException"* ]]
rm -f "$plaintext_file" "$ciphertext_file"
}
@test "KMS: encrypt with pending deletion key returns KmsInvalidStateException" {
out=$(aws_cmd kms create-key --description "bats-pending-encrypt")
KEY_ID=$(json_get "$out" '.KeyMetadata.KeyId')
run aws_cmd kms schedule-key-deletion --key-id "$KEY_ID" --pending-window-in-days 7
assert_success
plaintext_file=$(mktemp)
echo -n "secret data" > "$plaintext_file"
run aws_cmd kms encrypt --key-id "$KEY_ID" --plaintext "fileb://$plaintext_file"
assert_failure
[[ "$output" == *"KMSInvalidStateException"* ]]
rm -f "$plaintext_file"
}
@@ -0,0 +1,256 @@
#!/usr/bin/env bats
# Lambda MicroVMs integration tests
#
# Two AWS service models sit behind these commands: `lambda-microvms` (images
# and MicroVMs) and `lambda-core` (network connectors). Both sign as lambda.
setup() {
load 'test_helper/common-setup'
IMAGE_NAME=""
MICROVM_ID=""
CONNECTOR_ID=""
BASE_IMAGE_ARN="arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
BUILD_ROLE_ARN="arn:aws:iam::000000000000:role/microvm-build"
}
teardown() {
if [ -n "$MICROVM_ID" ]; then
aws_cmd lambda-microvms terminate-microvm --microvm-identifier "$MICROVM_ID" >/dev/null 2>&1 || true
fi
if [ -n "$IMAGE_NAME" ]; then
aws_cmd lambda-microvms delete-microvm-image --image-identifier "$IMAGE_NAME" >/dev/null 2>&1 || true
fi
if [ -n "$CONNECTOR_ID" ]; then
aws_cmd lambda-core delete-network-connector --id "$CONNECTOR_ID" >/dev/null 2>&1 || true
fi
}
create_image() {
local name="$1"
aws_cmd lambda-microvms create-microvm-image \
--name "$name" \
--base-image-arn "$BASE_IMAGE_ARN" \
--build-role-arn "$BUILD_ROLE_ARN" \
--code-artifact "uri=s3://bucket/code.zip"
}
# ============================================
# Image lifecycle
# ============================================
@test "MicroVMs: create image" {
IMAGE_NAME=$(unique_name "cli-img")
run create_image "$IMAGE_NAME"
assert_success
name=$(json_get "$output" '.name')
state=$(json_get "$output" '.state')
version=$(json_get "$output" '.imageVersion')
arn=$(json_get "$output" '.imageArn')
[ "$name" = "$IMAGE_NAME" ]
[ "$state" = "CREATING" ]
[ "$version" = "1.0" ]
[[ "$arn" =~ ^arn:aws:lambda: ]]
}
@test "MicroVMs: image settles to CREATED with an active version" {
IMAGE_NAME=$(unique_name "cli-img")
create_image "$IMAGE_NAME" >/dev/null
run aws_cmd lambda-microvms get-microvm-image --image-identifier "$IMAGE_NAME"
assert_success
state=$(json_get "$output" '.state')
latest=$(json_get "$output" '.latestActiveImageVersion')
[ "$state" = "CREATED" ]
[ "$latest" = "1.0" ]
}
@test "MicroVMs: list images includes the created image" {
IMAGE_NAME=$(unique_name "cli-img")
create_image "$IMAGE_NAME" >/dev/null
run aws_cmd lambda-microvms list-microvm-images
assert_success
assert_output --partial "$IMAGE_NAME"
}
@test "MicroVMs: version and build converge to SUCCESSFUL" {
IMAGE_NAME=$(unique_name "cli-img")
create_image "$IMAGE_NAME" >/dev/null
run aws_cmd lambda-microvms list-microvm-image-builds \
--image-identifier "$IMAGE_NAME" --image-version 1.0
assert_success
build_id=$(json_get "$output" '.items[0].buildId')
build_state=$(json_get "$output" '.items[0].buildState')
[ "$build_state" = "SUCCESSFUL" ]
[ -n "$build_id" ]
run aws_cmd lambda-microvms get-microvm-image-build \
--image-identifier "$IMAGE_NAME" --image-version 1.0 --build-id "$build_id"
assert_success
arch=$(json_get "$output" '.architecture')
[ "$arch" = "ARM_64" ]
run aws_cmd lambda-microvms get-microvm-image-version \
--image-identifier "$IMAGE_NAME" --image-version 1.0
assert_success
status=$(json_get "$output" '.status')
[ "$status" = "ACTIVE" ]
}
@test "MicroVMs: invalid image name is rejected" {
run create_image "bad name!"
assert_failure
assert_output --partial "ValidationException"
}
@test "MicroVMs: get a nonexistent image returns not found" {
run aws_cmd lambda-microvms get-microvm-image --image-identifier "cli-no-such-image"
assert_failure
assert_output --partial "ResourceNotFound"
}
@test "MicroVMs: managed catalog lists a base image" {
run aws_cmd lambda-microvms list-managed-microvm-images
assert_success
arn=$(json_get "$output" '.items[0].imageArn')
[ -n "$arn" ]
}
# ============================================
# MicroVM lifecycle
# ============================================
@test "MicroVMs: run a MicroVM and read it back running" {
IMAGE_NAME=$(unique_name "cli-img")
create_image "$IMAGE_NAME" >/dev/null
run aws_cmd lambda-microvms run-microvm --image-identifier "$IMAGE_NAME"
assert_success
MICROVM_ID=$(json_get "$output" '.microvmId')
state=$(json_get "$output" '.state')
duration=$(json_get "$output" '.maximumDurationInSeconds')
[[ "$MICROVM_ID" =~ ^microvm- ]]
[ "$state" = "PENDING" ]
[ "$duration" = "28800" ]
run aws_cmd lambda-microvms get-microvm --microvm-identifier "$MICROVM_ID"
assert_success
state=$(json_get "$output" '.state')
[ "$state" = "RUNNING" ]
}
@test "MicroVMs: run against a missing image returns not found" {
run aws_cmd lambda-microvms run-microvm --image-identifier "cli-no-such-image"
assert_failure
assert_output --partial "ResourceNotFound"
}
@test "MicroVMs: an image with a running MicroVM refuses delete" {
IMAGE_NAME=$(unique_name "cli-img")
create_image "$IMAGE_NAME" >/dev/null
out=$(aws_cmd lambda-microvms run-microvm --image-identifier "$IMAGE_NAME")
MICROVM_ID=$(json_get "$out" '.microvmId')
run aws_cmd lambda-microvms delete-microvm-image --image-identifier "$IMAGE_NAME"
assert_failure
assert_output --partial "running microvms"
}
@test "MicroVMs: terminate is idempotent only once" {
IMAGE_NAME=$(unique_name "cli-img")
create_image "$IMAGE_NAME" >/dev/null
out=$(aws_cmd lambda-microvms run-microvm --image-identifier "$IMAGE_NAME")
microvm_id=$(json_get "$out" '.microvmId')
run aws_cmd lambda-microvms terminate-microvm --microvm-identifier "$microvm_id"
assert_success
run aws_cmd lambda-microvms get-microvm --microvm-identifier "$microvm_id"
assert_success
state=$(json_get "$output" '.state')
[ "$state" = "TERMINATED" ]
# Terminal-state mutations are a ValidationException, not a second success.
run aws_cmd lambda-microvms terminate-microvm --microvm-identifier "$microvm_id"
assert_failure
assert_output --partial "state cannot be changed"
}
# ============================================
# Tagging
# ============================================
@test "MicroVMs: tag, read and untag an image ARN" {
IMAGE_NAME=$(unique_name "cli-img")
out=$(create_image "$IMAGE_NAME")
image_arn=$(json_get "$out" '.imageArn')
run aws_cmd lambda-microvms tag-resource --resource "$image_arn" --tags team=conformance
assert_success
run aws_cmd lambda-microvms list-tags --resource "$image_arn"
assert_success
team=$(json_get "$output" '.Tags.team')
[ "$team" = "conformance" ]
run aws_cmd lambda-microvms untag-resource --resource "$image_arn" --tag-keys team
assert_success
run aws_cmd lambda-microvms list-tags --resource "$image_arn"
assert_success
team=$(json_get "$output" '.Tags.team')
[ "$team" = "null" ] || [ -z "$team" ]
}
# ============================================
# Network connectors (lambda-core)
# ============================================
@test "MicroVMs: network connector lifecycle" {
name=$(unique_name "cli-conn")
run aws_cmd lambda-core create-network-connector \
--name "$name" \
--operator-role "arn:aws:iam::000000000000:role/microvm-connector-operator" \
--configuration "VpcEgressConfiguration={AssociatedComputeResourceTypes=MicroVm,NetworkProtocol=IPv4,SubnetIds=subnet-0000000000000cli1,SecurityGroupIds=sg-0000000000000cli1}"
assert_success
CONNECTOR_ID=$(json_get "$output" '.Id')
state=$(json_get "$output" '.State')
[ -n "$CONNECTOR_ID" ]
[ "$state" = "PENDING" ]
run aws_cmd lambda-core get-network-connector --id "$CONNECTOR_ID"
assert_success
state=$(json_get "$output" '.State')
got_name=$(json_get "$output" '.Name')
[ "$state" = "ACTIVE" ]
[ "$got_name" = "$name" ]
run aws_cmd lambda-core list-network-connectors
assert_success
assert_output --partial "$CONNECTOR_ID"
run aws_cmd lambda-core delete-network-connector --id "$CONNECTOR_ID"
assert_success
CONNECTOR_ID=""
}
@test "MicroVMs: get a nonexistent connector returns not found" {
run aws_cmd lambda-core get-network-connector --id "nc-00000000000000cli"
assert_failure
assert_output --partial "ResourceNotFound"
}
# ============================================
# Region isolation
# ============================================
@test "MicroVMs: an image created in one region is not visible in another" {
IMAGE_NAME=$(unique_name "cli-img")
create_image "$IMAGE_NAME" >/dev/null
run aws --endpoint-url "$FLOCI_ENDPOINT" --region eu-west-1 --output json \
lambda-microvms get-microvm-image --image-identifier "$IMAGE_NAME" 2>&1
assert_failure
assert_output --partial "ResourceNotFound"
}
@@ -0,0 +1,67 @@
#!/usr/bin/env bats
# Lambda tests
setup() {
load 'test_helper/common-setup'
FUNC_NAME=""
}
teardown() {
if [ -n "$FUNC_NAME" ]; then
aws_cmd lambda delete-function --function-name "$FUNC_NAME" >/dev/null 2>&1 || true
fi
}
_role="arn:aws:iam::000000000000:role/lambda-role"
_image_uri="000000000000.dkr.ecr.us-east-1.amazonaws.com/fake-repo:latest"
@test "Lambda: ImageConfig.WorkingDirectory is returned in CreateFunction response" {
FUNC_NAME="bats-imgwd-create-$(unique_name)"
run aws_cmd lambda create-function \
--function-name "$FUNC_NAME" \
--package-type Image \
--role "$_role" \
--code "ImageUri=$_image_uri" \
--image-config 'WorkingDirectory=/app'
assert_success
wd=$(json_get "$output" '.ImageConfigResponse.ImageConfig.WorkingDirectory')
[ "$wd" = "/app" ] || { echo "expected WorkingDirectory=/app, got: $wd"; return 1; }
}
@test "Lambda: ImageConfig.WorkingDirectory is persisted and returned by get-function-configuration" {
FUNC_NAME="bats-imgwd-get-$(unique_name)"
aws_cmd lambda create-function \
--function-name "$FUNC_NAME" \
--package-type Image \
--role "$_role" \
--code "ImageUri=$_image_uri" \
--image-config 'WorkingDirectory=/workspace' >/dev/null
run aws_cmd lambda get-function-configuration --function-name "$FUNC_NAME"
assert_success
wd=$(json_get "$output" '.ImageConfigResponse.ImageConfig.WorkingDirectory')
[ "$wd" = "/workspace" ] || { echo "expected WorkingDirectory=/workspace, got: $wd"; return 1; }
}
@test "Lambda: ImageConfig.WorkingDirectory is updated by update-function-configuration" {
FUNC_NAME="bats-imgwd-upd-$(unique_name)"
aws_cmd lambda create-function \
--function-name "$FUNC_NAME" \
--package-type Image \
--role "$_role" \
--code "ImageUri=$_image_uri" \
--image-config 'WorkingDirectory=/initial' >/dev/null
run aws_cmd lambda update-function-configuration \
--function-name "$FUNC_NAME" \
--image-config 'WorkingDirectory=/updated'
assert_success
wd=$(json_get "$output" '.ImageConfigResponse.ImageConfig.WorkingDirectory')
[ "$wd" = "/updated" ] || { echo "expected WorkingDirectory=/updated, got: $wd"; return 1; }
}
@@ -0,0 +1,170 @@
#!/usr/bin/env bats
# Neptune cluster and instance management tests
setup() {
load 'test_helper/common-setup'
CLUSTER_ID="bats-neptune-$(unique_name)"
INSTANCE_ID="bats-neptune-inst-$(unique_name)"
CLUSTER_CREATED=false
INSTANCE_CREATED=false
}
teardown() {
if [ "$INSTANCE_CREATED" = "true" ]; then
aws_cmd neptune delete-db-instance \
--db-instance-identifier "$INSTANCE_ID" >/dev/null 2>&1 || true
fi
if [ "$CLUSTER_CREATED" = "true" ]; then
aws_cmd neptune delete-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--skip-final-snapshot >/dev/null 2>&1 || true
fi
}
@test "Neptune: create DB cluster" {
run aws_cmd neptune create-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--engine neptune
assert_success
CLUSTER_CREATED=true
id=$(json_get "$output" '.DBCluster.DBClusterIdentifier')
engine=$(json_get "$output" '.DBCluster.Engine')
status=$(json_get "$output" '.DBCluster.Status')
arn=$(json_get "$output" '.DBCluster.DBClusterArn')
port=$(json_get "$output" '.DBCluster.Port')
[ "$id" = "$CLUSTER_ID" ]
[ "$engine" = "neptune" ]
[ "$status" = "available" ]
[[ "$arn" == arn:aws:neptune:* ]]
[ "$port" -gt 0 ]
}
@test "Neptune: create duplicate cluster fails" {
aws_cmd neptune create-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--engine neptune >/dev/null
CLUSTER_CREATED=true
run aws_cmd neptune create-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--engine neptune
assert_failure
[[ "$output" == *"DBClusterAlreadyExistsFault"* ]]
}
@test "Neptune: describe DB cluster by ID" {
aws_cmd neptune create-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--engine neptune >/dev/null
CLUSTER_CREATED=true
run aws_cmd neptune describe-db-clusters \
--db-cluster-identifier "$CLUSTER_ID"
assert_success
id=$(json_get "$output" '.DBClusters[0].DBClusterIdentifier')
[ "$id" = "$CLUSTER_ID" ]
}
@test "Neptune: modify DB cluster IAM auth" {
aws_cmd neptune create-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--engine neptune >/dev/null
CLUSTER_CREATED=true
run aws_cmd neptune modify-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--enable-iam-database-authentication
assert_success
iam=$(json_get "$output" '.DBCluster.IAMDatabaseAuthenticationEnabled')
[ "$iam" = "true" ]
}
@test "Neptune: create DB instance in cluster" {
aws_cmd neptune create-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--engine neptune >/dev/null
CLUSTER_CREATED=true
run aws_cmd neptune create-db-instance \
--db-instance-identifier "$INSTANCE_ID" \
--db-cluster-identifier "$CLUSTER_ID" \
--db-instance-class db.r5.large \
--engine neptune
assert_success
INSTANCE_CREATED=true
id=$(json_get "$output" '.DBInstance.DBInstanceIdentifier')
cluster=$(json_get "$output" '.DBInstance.DBClusterIdentifier')
status=$(json_get "$output" '.DBInstance.DBInstanceStatus')
[ "$id" = "$INSTANCE_ID" ]
[ "$cluster" = "$CLUSTER_ID" ]
[ "$status" = "available" ]
}
@test "Neptune: describe DB instance by ID" {
aws_cmd neptune create-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--engine neptune >/dev/null
CLUSTER_CREATED=true
aws_cmd neptune create-db-instance \
--db-instance-identifier "$INSTANCE_ID" \
--db-cluster-identifier "$CLUSTER_ID" \
--db-instance-class db.r5.large \
--engine neptune >/dev/null
INSTANCE_CREATED=true
run aws_cmd neptune describe-db-instances \
--db-instance-identifier "$INSTANCE_ID"
assert_success
id=$(json_get "$output" '.DBInstances[0].DBInstanceIdentifier')
[ "$id" = "$INSTANCE_ID" ]
}
@test "Neptune: delete cluster with instance fails" {
aws_cmd neptune create-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--engine neptune >/dev/null
CLUSTER_CREATED=true
aws_cmd neptune create-db-instance \
--db-instance-identifier "$INSTANCE_ID" \
--db-cluster-identifier "$CLUSTER_ID" \
--db-instance-class db.r5.large \
--engine neptune >/dev/null
INSTANCE_CREATED=true
run aws_cmd neptune delete-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--skip-final-snapshot
assert_failure
[[ "$output" == *"InvalidDBClusterStateFault"* ]]
}
@test "Neptune: delete DB instance then cluster" {
aws_cmd neptune create-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--engine neptune >/dev/null
CLUSTER_CREATED=true
aws_cmd neptune create-db-instance \
--db-instance-identifier "$INSTANCE_ID" \
--db-cluster-identifier "$CLUSTER_ID" \
--db-instance-class db.r5.large \
--engine neptune >/dev/null
INSTANCE_CREATED=true
run aws_cmd neptune delete-db-instance \
--db-instance-identifier "$INSTANCE_ID"
assert_success
INSTANCE_CREATED=false
run aws_cmd neptune delete-db-cluster \
--db-cluster-identifier "$CLUSTER_ID" \
--skip-final-snapshot
assert_success
CLUSTER_CREATED=false
}
@@ -0,0 +1,306 @@
#!/usr/bin/env bats
# EventBridge Pipes tests
setup() {
load 'test_helper/common-setup'
PIPE_NAME="bats-pipe-$(unique_name)"
SOURCE_QUEUE="bats-pipe-src-$(unique_name)"
TARGET_QUEUE="bats-pipe-tgt-$(unique_name)"
SOURCE_QUEUE_URL=""
TARGET_QUEUE_URL=""
ROLE_ARN="arn:aws:iam::000000000000:role/pipe-role"
}
teardown() {
aws_cmd pipes delete-pipe --name "$PIPE_NAME" >/dev/null 2>&1 || true
if [ -n "$SOURCE_QUEUE_URL" ]; then
aws_cmd sqs delete-queue --queue-url "$SOURCE_QUEUE_URL" >/dev/null 2>&1 || true
fi
if [ -n "$TARGET_QUEUE_URL" ]; then
aws_cmd sqs delete-queue --queue-url "$TARGET_QUEUE_URL" >/dev/null 2>&1 || true
fi
}
create_queues() {
out=$(aws_cmd sqs create-queue --queue-name "$SOURCE_QUEUE")
SOURCE_QUEUE_URL=$(json_get "$out" '.QueueUrl')
out=$(aws_cmd sqs create-queue --queue-name "$TARGET_QUEUE")
TARGET_QUEUE_URL=$(json_get "$out" '.QueueUrl')
}
source_arn() {
echo "arn:aws:sqs:${AWS_DEFAULT_REGION}:000000000000:${SOURCE_QUEUE}"
}
target_arn() {
echo "arn:aws:sqs:${AWS_DEFAULT_REGION}:000000000000:${TARGET_QUEUE}"
}
@test "Pipes: create pipe in STOPPED state" {
create_queues
run aws_cmd pipes create-pipe \
--name "$PIPE_NAME" \
--source "$(source_arn)" \
--target "$(target_arn)" \
--role-arn "$ROLE_ARN" \
--desired-state STOPPED
assert_success
state=$(json_get "$output" '.CurrentState')
[ "$state" = "STOPPED" ]
name=$(json_get "$output" '.Name')
[ "$name" = "$PIPE_NAME" ]
}
@test "Pipes: describe pipe" {
create_queues
aws_cmd pipes create-pipe \
--name "$PIPE_NAME" \
--source "$(source_arn)" \
--target "$(target_arn)" \
--role-arn "$ROLE_ARN" \
--desired-state STOPPED >/dev/null
run aws_cmd pipes describe-pipe --name "$PIPE_NAME"
assert_success
name=$(json_get "$output" '.Name')
[ "$name" = "$PIPE_NAME" ]
source=$(json_get "$output" '.Source')
[ "$source" = "$(source_arn)" ]
target=$(json_get "$output" '.Target')
[ "$target" = "$(target_arn)" ]
}
@test "Pipes: list pipes" {
create_queues
aws_cmd pipes create-pipe \
--name "$PIPE_NAME" \
--source "$(source_arn)" \
--target "$(target_arn)" \
--role-arn "$ROLE_ARN" \
--desired-state STOPPED >/dev/null
run aws_cmd pipes list-pipes
assert_success
found=$(echo "$output" | jq --arg name "$PIPE_NAME" '.Pipes | any(.Name == $name)')
[ "$found" = "true" ]
}
@test "Pipes: update pipe" {
create_queues
aws_cmd pipes create-pipe \
--name "$PIPE_NAME" \
--source "$(source_arn)" \
--target "$(target_arn)" \
--role-arn "$ROLE_ARN" \
--desired-state STOPPED >/dev/null
run aws_cmd pipes update-pipe \
--name "$PIPE_NAME" \
--role-arn "$ROLE_ARN" \
--description "updated description" \
--desired-state STOPPED
assert_success
run aws_cmd pipes describe-pipe --name "$PIPE_NAME"
assert_success
desc=$(json_get "$output" '.Description')
[ "$desc" = "updated description" ]
}
@test "Pipes: start and stop pipe" {
create_queues
aws_cmd pipes create-pipe \
--name "$PIPE_NAME" \
--source "$(source_arn)" \
--target "$(target_arn)" \
--role-arn "$ROLE_ARN" \
--desired-state STOPPED >/dev/null
run aws_cmd pipes start-pipe --name "$PIPE_NAME"
assert_success
state=$(json_get "$output" '.CurrentState')
[ "$state" = "RUNNING" ]
run aws_cmd pipes stop-pipe --name "$PIPE_NAME"
assert_success
state=$(json_get "$output" '.CurrentState')
[ "$state" = "STOPPED" ]
}
@test "Pipes: delete pipe" {
create_queues
aws_cmd pipes create-pipe \
--name "$PIPE_NAME" \
--source "$(source_arn)" \
--target "$(target_arn)" \
--role-arn "$ROLE_ARN" \
--desired-state STOPPED >/dev/null
run aws_cmd pipes delete-pipe --name "$PIPE_NAME"
assert_success
run aws_cmd pipes describe-pipe --name "$PIPE_NAME"
assert_failure
}
@test "Pipes: describe non-existent pipe returns error" {
run aws_cmd pipes describe-pipe --name "non-existent-pipe"
assert_failure
}
@test "Pipes: SQS to SQS forwarding" {
create_queues
aws_cmd pipes create-pipe \
--name "$PIPE_NAME" \
--source "$(source_arn)" \
--target "$(target_arn)" \
--role-arn "$ROLE_ARN" \
--desired-state RUNNING >/dev/null
aws_cmd sqs send-message \
--queue-url "$SOURCE_QUEUE_URL" \
--message-body "hello from pipes" >/dev/null
# Poll target queue for forwarded message
local found=false
for i in $(seq 1 15); do
sleep 1
out=$(aws_cmd sqs receive-message \
--queue-url "$TARGET_QUEUE_URL" \
--max-number-of-messages 1 \
--wait-time-seconds 1)
if echo "$out" | grep -q "hello from pipes"; then
found=true
break
fi
done
[ "$found" = "true" ]
}
@test "Pipes: FilterCriteria filters messages" {
create_queues
aws_cmd pipes create-pipe \
--name "$PIPE_NAME" \
--source "$(source_arn)" \
--target "$(target_arn)" \
--role-arn "$ROLE_ARN" \
--desired-state RUNNING \
--source-parameters '{"FilterCriteria":{"Filters":[{"Pattern":"{\"body\": {\"status\": [\"active\"]}}"}]}}' >/dev/null
aws_cmd sqs send-message \
--queue-url "$SOURCE_QUEUE_URL" \
--message-body '{"status": "active", "id": "match-1"}' >/dev/null
aws_cmd sqs send-message \
--queue-url "$SOURCE_QUEUE_URL" \
--message-body '{"status": "inactive", "id": "no-match"}' >/dev/null
# Poll target queue for matching message
local found=false
for i in $(seq 1 15); do
sleep 1
out=$(aws_cmd sqs receive-message \
--queue-url "$TARGET_QUEUE_URL" \
--max-number-of-messages 10 \
--wait-time-seconds 1)
if echo "$out" | grep -q "match-1"; then
# Verify no non-matching message arrived
if echo "$out" | grep -q "no-match"; then
fail "non-matching message should not be forwarded"
fi
found=true
break
fi
done
[ "$found" = "true" ]
# Source queue should be drained (non-matching messages deleted per AWS behavior)
out=$(aws_cmd sqs get-queue-attributes \
--queue-url "$SOURCE_QUEUE_URL" \
--attribute-names ApproximateNumberOfMessages)
count=$(json_get "$out" '.Attributes.ApproximateNumberOfMessages')
[ "$count" = "0" ]
}
@test "Pipes: BatchSize in SourceParameters" {
create_queues
aws_cmd pipes create-pipe \
--name "$PIPE_NAME" \
--source "$(source_arn)" \
--target "$(target_arn)" \
--role-arn "$ROLE_ARN" \
--desired-state RUNNING \
--source-parameters '{"SqsQueueParameters":{"BatchSize":1}}' >/dev/null
for i in 1 2 3; do
aws_cmd sqs send-message \
--queue-url "$SOURCE_QUEUE_URL" \
--message-body "batch-msg-$i" >/dev/null
done
# Poll target queue until all 3 messages arrive
local found_count=0
local found1=false found2=false found3=false
for i in $(seq 1 20); do
sleep 1
out=$(aws_cmd sqs receive-message \
--queue-url "$TARGET_QUEUE_URL" \
--max-number-of-messages 10 \
--wait-time-seconds 1)
if [ "$found1" = "false" ] && echo "$out" | grep -q "batch-msg-1"; then
found1=true
fi
if [ "$found2" = "false" ] && echo "$out" | grep -q "batch-msg-2"; then
found2=true
fi
if [ "$found3" = "false" ] && echo "$out" | grep -q "batch-msg-3"; then
found3=true
fi
if [ "$found1" = "true" ] && [ "$found2" = "true" ] && [ "$found3" = "true" ]; then
break
fi
done
[ "$found1" = "true" ]
[ "$found2" = "true" ]
[ "$found3" = "true" ]
}
@test "Pipes: stopped pipe does not forward messages" {
create_queues
aws_cmd pipes create-pipe \
--name "$PIPE_NAME" \
--source "$(source_arn)" \
--target "$(target_arn)" \
--role-arn "$ROLE_ARN" \
--desired-state STOPPED >/dev/null
aws_cmd sqs send-message \
--queue-url "$SOURCE_QUEUE_URL" \
--message-body "should not forward" >/dev/null
sleep 3
# Source should still have the message
out=$(aws_cmd sqs get-queue-attributes \
--queue-url "$SOURCE_QUEUE_URL" \
--attribute-names ApproximateNumberOfMessages)
count=$(json_get "$out" '.Attributes.ApproximateNumberOfMessages')
[ "$count" = "1" ]
# Target should be empty
out=$(aws_cmd sqs receive-message \
--queue-url "$TARGET_QUEUE_URL" \
--max-number-of-messages 1)
msgs=$(echo "${out:-"{}"}" | jq '.Messages // [] | length')
[ "$msgs" = "0" ]
}
@@ -0,0 +1,434 @@
#!/usr/bin/env bats
# RDS integration tests
setup() {
load 'test_helper/common-setup'
DB_ID="bats-rds-$(unique_name)"
DB_ID_2="bats-rds-2-$(unique_name)"
RDS_CLUSTER_ID="bats-rds-cluster-$(unique_name)"
}
teardown() {
aws_cmd rds delete-db-instance --db-instance-identifier "$DB_ID" --skip-final-snapshot >/dev/null 2>&1 || true
aws_cmd rds delete-db-instance --db-instance-identifier "$DB_ID_2" --skip-final-snapshot >/dev/null 2>&1 || true
aws_cmd rds delete-db-cluster --db-cluster-identifier "$RDS_CLUSTER_ID" --skip-final-snapshot >/dev/null 2>&1 || true
if [ -n "${MANAGED_SECRET_ARN:-}" ]; then
aws_cmd secretsmanager delete-secret --secret-id "$MANAGED_SECRET_ARN" \
--force-delete-without-recovery >/dev/null 2>&1 || true
fi
# EC2 fixtures of the docdb security-group case, whether or not its assertions passed
if [ -n "${SG_ID:-}" ]; then
aws_cmd docdb delete-db-cluster --db-cluster-identifier "$CLUSTER_ID" --skip-final-snapshot >/dev/null 2>&1 || true
aws_cmd ec2 delete-security-group --group-id "$SG_ID" >/dev/null 2>&1 || true
fi
if [ -n "${VPC_ID:-}" ]; then
aws_cmd ec2 delete-vpc --vpc-id "$VPC_ID" >/dev/null 2>&1 || true
fi
}
@test "RDS: create db instance returns resource identifiers" {
run aws_cmd rds create-db-instance \
--db-instance-identifier "$DB_ID" \
--engine postgres \
--db-instance-class db.t3.micro \
--allocated-storage 10
assert_success
dbi_resource_id=$(json_get "$output" '.DBInstance.DbiResourceId')
db_instance_arn=$(json_get "$output" '.DBInstance.DBInstanceArn')
[ -n "$dbi_resource_id" ]
[[ "$dbi_resource_id" =~ ^db- ]]
[ -n "$db_instance_arn" ]
[[ "$db_instance_arn" == *":db:$DB_ID" ]]
}
@test "RDS: describe db instances filters by identifier" {
aws_cmd rds create-db-instance \
--db-instance-identifier "$DB_ID" \
--engine postgres \
--db-instance-class db.t3.micro \
--allocated-storage 10
run aws_cmd rds describe-db-instances --db-instance-identifier "$DB_ID"
assert_success
count=$(echo "$output" | jq '.DBInstances | length')
[ "$count" -eq 1 ]
id=$(json_get "$output" '.DBInstances[0].DBInstanceIdentifier')
[ "$id" = "$DB_ID" ]
}
@test "RDS: describe db instances is case-insensitive" {
aws_cmd rds create-db-instance \
--db-instance-identifier "$DB_ID" \
--engine postgres \
--db-instance-class db.t3.micro \
--allocated-storage 10
# shellcheck disable=SC2155
local upper_id=$(echo "$DB_ID" | tr '[:lower:]' '[:upper:]')
run aws_cmd rds describe-db-instances --db-instance-identifier "$upper_id"
assert_success
count=$(echo "$output" | jq '.DBInstances | length')
[ "$count" -eq 1 ]
id=$(json_get "$output" '.DBInstances[0].DBInstanceIdentifier')
[ "$id" = "$DB_ID" ]
}
@test "RDS: describe db instances returns all when no filter" {
aws_cmd rds create-db-instance \
--db-instance-identifier "$DB_ID" \
--engine postgres \
--db-instance-class db.t3.micro \
--allocated-storage 10
aws_cmd rds create-db-instance \
--db-instance-identifier "$DB_ID_2" \
--engine postgres \
--db-instance-class db.t3.micro \
--allocated-storage 10
run aws_cmd rds describe-db-instances
assert_success
# Might have more from other tests, but at least 2
count=$(echo "$output" | jq '.DBInstances | length')
[ "$count" -ge 2 ]
}
@test "RDS: managed master user secret is owned by rds and rotates without a Lambda" {
run aws_cmd rds create-db-instance \
--db-instance-identifier "$DB_ID" \
--engine postgres \
--db-instance-class db.t3.micro \
--allocated-storage 10 \
--master-username admin \
--manage-master-user-password
assert_success
MANAGED_SECRET_ARN=$(json_get "$output" '.DBInstance.MasterUserSecret.SecretArn')
[ -n "$MANAGED_SECRET_ARN" ]
run aws_cmd secretsmanager describe-secret --secret-id "$MANAGED_SECRET_ARN"
assert_success
[ "$(json_get "$output" '.OwningService')" = "rds" ]
# RDS rotates this secret itself, so a rotation Lambda is not accepted for it.
run aws_cmd secretsmanager rotate-secret \
--secret-id "$MANAGED_SECRET_ARN" \
--rotation-lambda-arn "arn:aws:lambda:$AWS_DEFAULT_REGION:000000000000:function:absent"
assert_failure
assert_output --partial "not supported for a service-managed secret"
# The call terraform's aws_secretsmanager_secret_rotation makes: rules, no Lambda ARN.
run aws_cmd secretsmanager rotate-secret \
--secret-id "$MANAGED_SECRET_ARN" \
--rotation-rules 'AutomaticallyAfterDays=7'
assert_success
run aws_cmd secretsmanager describe-secret --secret-id "$MANAGED_SECRET_ARN"
assert_success
[ "$(json_get "$output" '.RotationEnabled')" = "true" ]
[ "$(json_get "$output" '.RotationRules.AutomaticallyAfterDays')" = "7" ]
}
@test "RDS: Aurora cluster with managed master user password populates MasterUserSecret" {
run aws_cmd rds create-db-cluster \
--db-cluster-identifier "$RDS_CLUSTER_ID" \
--engine aurora-postgresql \
--master-username omni_admin \
--manage-master-user-password
assert_success
MANAGED_SECRET_ARN=$(json_get "$output" '.DBCluster.MasterUserSecret.SecretArn')
[ -n "$MANAGED_SECRET_ARN" ]
[ "$(json_get "$output" '.DBCluster.MasterUserSecret.SecretStatus')" = "active" ]
run aws_cmd rds describe-db-clusters --db-cluster-identifier "$RDS_CLUSTER_ID"
assert_success
[ "$(json_get "$output" '.DBClusters[0].MasterUserSecret.SecretArn')" = "$MANAGED_SECRET_ARN" ]
run aws_cmd secretsmanager describe-secret --secret-id "$MANAGED_SECRET_ARN"
assert_success
[ "$(json_get "$output" '.OwningService')" = "rds" ]
}
@test "RDS: describe global clusters returns an empty list" {
run aws_cmd rds describe-global-clusters
assert_success
[ "$(json_get "$output" '.GlobalClusters | length')" = "0" ]
}
@test "DocDB: describe global clusters answers the read every cluster read makes" {
# DocumentDB signs with the rds scope, so this is the same handler the CLI reaches
# for either service. Without an answer here a created cluster cannot be read back.
run aws_cmd docdb describe-global-clusters
assert_success
[ "$(json_get "$output" '.GlobalClusters | length')" = "0" ]
run aws_cmd docdb describe-global-clusters --global-cluster-identifier "bats-absent-gc"
assert_failure
assert_output --partial "GlobalClusterNotFoundFault"
}
@test "RDS: cluster parameter group reports its ARN and carries tags" {
CPG="bats-cpg-$(unique_name)"
run aws_cmd rds create-db-cluster-parameter-group --db-cluster-parameter-group-name "$CPG" \
--db-parameter-group-family aurora-postgresql15 --description "bats" \
--tags Key=team,Value=data
assert_success
arn=$(json_get "$output" '.DBClusterParameterGroup.DBClusterParameterGroupArn')
[ -n "$arn" ]
[[ "$arn" == *":cluster-pg:$CPG" ]]
run aws_cmd rds list-tags-for-resource --resource-name "$arn"
assert_success
assert_output --partial '"Key": "team"'
aws_cmd rds delete-db-cluster-parameter-group --db-cluster-parameter-group-name "$CPG" >/dev/null 2>&1 || true
}
@test "DocDB: cluster tags survive create and can be added and removed" {
# The tag actions carry only the resource ARN, so this is also the check that they reach
# DocumentDB at all rather than the RDS handler, which does not hold its records.
CLUSTER_ID="bats-docdb-$(unique_name)"
run aws_cmd docdb create-db-cluster --db-cluster-identifier "$CLUSTER_ID" \
--engine docdb --master-username docdbadmin --master-user-password "secret99password" \
--tags Key=env,Value=bats
assert_success
arn=$(json_get "$output" '.DBCluster.DBClusterArn')
run aws_cmd docdb list-tags-for-resource --resource-name "$arn"
assert_success
assert_output --partial '"Key": "env"'
run aws_cmd docdb add-tags-to-resource --resource-name "$arn" --tags Key=env,Value=changed Key=extra,Value=yes
assert_success
run aws_cmd docdb list-tags-for-resource --resource-name "$arn"
assert_success
assert_output --partial '"Value": "changed"'
# Removing a key that is not there is not an error on a live account.
run aws_cmd docdb remove-tags-from-resource --resource-name "$arn" --tag-keys extra absent
assert_success
run aws_cmd docdb list-tags-for-resource --resource-name "$arn"
assert_success
refute_output --partial '"Key": "extra"'
aws_cmd docdb delete-db-cluster --db-cluster-identifier "$CLUSTER_ID" --skip-final-snapshot >/dev/null 2>&1 || true
}
@test "rds: tags given to create-db-subnet-group are readable back" {
VPC_ID=$(aws_cmd ec2 create-vpc --cidr-block 10.77.0.0/16 --query Vpc.VpcId --output text)
S1=$(aws_cmd ec2 create-subnet --vpc-id "$VPC_ID" --cidr-block 10.77.1.0/24 --availability-zone us-east-1a --query Subnet.SubnetId --output text)
S2=$(aws_cmd ec2 create-subnet --vpc-id "$VPC_ID" --cidr-block 10.77.2.0/24 --availability-zone us-east-1b --query Subnet.SubnetId --output text)
GROUP="bats-sng-$(unique_name)"
run aws_cmd rds create-db-subnet-group --db-subnet-group-name "$GROUP" --db-subnet-group-description d \
--subnet-ids "$S1" "$S2" --tags "Key=Name,Value=$GROUP" Key=env,Value=tst
assert_success
arn=$(json_get "$output" '.DBSubnetGroup.DBSubnetGroupArn')
run aws_cmd rds list-tags-for-resource --resource-name "$arn"
assert_success
assert_output --partial '"Key": "env"'
assert_output --partial "\"Value\": \"$GROUP\""
# the DocumentDB CLI reaches the same group through the same scope
run aws_cmd docdb list-tags-for-resource --resource-name "$arn"
assert_success
assert_output --partial '"Key": "env"'
aws_cmd rds delete-db-subnet-group --db-subnet-group-name "$GROUP" >/dev/null 2>&1 || true
aws_cmd ec2 delete-subnet --subnet-id "$S1" >/dev/null 2>&1 || true
aws_cmd ec2 delete-subnet --subnet-id "$S2" >/dev/null 2>&1 || true
aws_cmd ec2 delete-vpc --vpc-id "$VPC_ID" >/dev/null 2>&1 || true
}
@test "docdb: an engine version a live account does not list is refused" {
CLUSTER_ID="bats-docdb-version-$(unique_name)"
run aws_cmd docdb create-db-cluster --db-cluster-identifier "$CLUSTER_ID" --engine docdb \
--engine-version 9.9.9 --master-username u --master-user-password pw12345678
assert_failure
assert_output --partial 'InvalidParameterCombination'
assert_output --partial 'Cannot find version 9.9.9 for docdb'
run aws_cmd docdb describe-db-clusters --db-cluster-identifier "$CLUSTER_ID"
assert_failure
assert_output --partial 'DBClusterNotFoundFault'
}
@test "docdb: a security group that exists is accepted on create" {
CLUSTER_ID="bats-docdb-sg-$(unique_name)"
run aws_cmd ec2 create-vpc --cidr-block 10.42.0.0/16
assert_success
VPC_ID=$(json_get "$output" '.Vpc.VpcId')
run aws_cmd ec2 create-security-group --group-name "$CLUSTER_ID" --description d --vpc-id "$VPC_ID"
assert_success
SG_ID=$(json_get "$output" '.GroupId')
run aws_cmd docdb create-db-cluster --db-cluster-identifier "$CLUSTER_ID" --engine docdb \
--master-username docdbadmin --master-user-password "secret99password" \
--vpc-security-group-ids "$SG_ID"
assert_success
run aws_cmd docdb describe-db-clusters --db-cluster-identifier "$CLUSTER_ID"
assert_success
assert_output --partial "\"VpcSecurityGroupId\": \"$SG_ID\""
}
@test "docdb: cluster settings given on create are returned by describe" {
CLUSTER_ID="bats-docdb-settings-$(unique_name)"
run aws_cmd kms create-key --description "$CLUSTER_ID"
assert_success
KEY_ARN=$(json_get "$output" '.KeyMetadata.Arn')
run aws_cmd docdb create-db-cluster --db-cluster-identifier "$CLUSTER_ID" \
--engine docdb --master-username docdbadmin --master-user-password "secret99password" \
--storage-encrypted --kms-key-id "$KEY_ARN" --backup-retention-period 5 \
--preferred-backup-window 23:30-00:00 --preferred-maintenance-window sun:03:00-sun:04:00 \
--tags "Key=Name,Value=$CLUSTER_ID"
assert_success
run aws_cmd docdb describe-db-clusters --db-cluster-identifier "$CLUSTER_ID" \
--query 'DBClusters[0].[DBSubnetGroup,DBClusterParameterGroup,StorageEncrypted,KmsKeyId,BackupRetentionPeriod,PreferredBackupWindow,PreferredMaintenanceWindow,VpcSecurityGroups[0].VpcSecurityGroupId]'
assert_success
assert_output --partial '"default"'
assert_output --partial '"default.docdb5.0"'
assert_output --partial 'true'
assert_output --partial "$KEY_ARN"
assert_output --partial '5'
assert_output --partial '"23:30-00:00"'
assert_output --partial '"sun:03:00-sun:04:00"'
assert_output --partial '"sg-'
run aws_cmd docdb create-db-cluster --db-cluster-identifier "$CLUSTER_ID-x" \
--engine docdb --master-username docdbadmin --master-user-password "secret99password" \
--kms-key-id "$KEY_ARN"
assert_failure
assert_output --partial 'You cannot specify KMS key for unencrypted clusters.'
aws_cmd docdb delete-db-cluster --db-cluster-identifier "$CLUSTER_ID" --skip-final-snapshot >/dev/null 2>&1 || true
}
@test "docdb: a DocumentDB cluster is listed by describe-db-clusters on both endpoints" {
CLUSTER_ID="bats-docdb-list-$(unique_name)"
aws_cmd docdb create-db-cluster --db-cluster-identifier "$CLUSTER_ID" \
--engine docdb --master-username docdbadmin --master-user-password "secret99password" >/dev/null
run aws_cmd docdb describe-db-clusters --query 'DBClusters[].DBClusterIdentifier'
assert_success
assert_output --partial "$CLUSTER_ID"
run aws_cmd rds describe-db-clusters --query 'DBClusters[].DBClusterIdentifier'
assert_success
assert_output --partial "$CLUSTER_ID"
run aws_cmd docdb describe-db-clusters --filters Name=engine,Values=docdb --query 'DBClusters[].DBClusterIdentifier'
assert_success
assert_output --partial "$CLUSTER_ID"
run aws_cmd rds describe-db-clusters --filters Name=engine,Values=aurora-postgresql --query 'DBClusters[].DBClusterIdentifier'
assert_success
refute_output --partial "$CLUSTER_ID"
run aws_cmd rds describe-db-clusters --filters Name=engine,Values=nothing
assert_failure
assert_output --partial 'Unrecognized engine name: nothing'
aws_cmd docdb delete-db-cluster --db-cluster-identifier "$CLUSTER_ID" --skip-final-snapshot >/dev/null 2>&1 || true
}
@test "rds: storage and backup settings given on create are returned by describe and modify" {
run aws_cmd kms create-key --description "$DB_ID"
assert_success
KEY_ARN=$(json_get "$output" '.KeyMetadata.Arn')
KEY_ID=$(json_get "$output" '.KeyMetadata.KeyId')
aws_cmd kms create-alias --alias-name "alias/$DB_ID" --target-key-id "$KEY_ID" >/dev/null
# the key is given as an alias and must come back as the key ARN, as on AWS
run aws_cmd rds create-db-instance \
--db-instance-identifier "$DB_ID" \
--engine postgres \
--db-instance-class db.t3.micro \
--allocated-storage 10 \
--storage-encrypted --kms-key-id "alias/$DB_ID" \
--backup-retention-period 7 --copy-tags-to-snapshot \
--preferred-backup-window 23:30-00:00 --preferred-maintenance-window Sun:03:08-Sun:03:38
assert_success
run aws_cmd rds describe-db-instances --db-instance-identifier "$DB_ID" \
--query 'DBInstances[0].[StorageEncrypted,KmsKeyId,BackupRetentionPeriod,PreferredBackupWindow,CopyTagsToSnapshot,PreferredMaintenanceWindow]'
assert_success
assert_output --partial 'true'
assert_output --partial "$KEY_ARN"
assert_output --partial '7'
assert_output --partial '"23:30-00:00"'
assert_output --partial '"sun:03:08-sun:03:38"'
run aws_cmd rds modify-db-instance --db-instance-identifier "$DB_ID" \
--backup-retention-period 3 --preferred-backup-window 01:00-01:30 --apply-immediately
assert_success
run aws_cmd rds describe-db-instances --db-instance-identifier "$DB_ID" \
--query 'DBInstances[0].[BackupRetentionPeriod,PreferredBackupWindow,CopyTagsToSnapshot]'
assert_success
assert_output --partial '3'
assert_output --partial '"01:00-01:30"'
assert_output --partial 'true'
}
@test "rds: a KmsKeyId that names no key is refused as on AWS" {
run aws_cmd rds create-db-instance \
--db-instance-identifier "$DB_ID" \
--engine postgres \
--db-instance-class db.t3.micro \
--allocated-storage 10 \
--storage-encrypted --kms-key-id "alias/does-not-exist-$DB_ID"
assert_failure
assert_output --partial 'KMSKeyNotAccessibleFault'
}
@test "rds: KmsKeyId without StorageEncrypted is refused as on AWS" {
run aws_cmd rds create-db-instance \
--db-instance-identifier "$DB_ID" \
--engine postgres \
--db-instance-class db.t3.micro \
--allocated-storage 10 \
--no-storage-encrypted --kms-key-id "arn:aws:kms:us-east-1:000000000000:key/00000000-0000-0000-0000-000000000000"
assert_failure
assert_output --partial 'InvalidParameterCombination'
}
@test "rds: a master username longer than 16 characters is accepted on postgres, as on AWS" {
run aws_cmd rds create-db-instance \
--db-instance-identifier "$DB_ID" \
--engine postgres \
--db-instance-class db.t3.micro \
--allocated-storage 10 \
--master-username application_db_admin_user \
--master-user-password "secret99password"
assert_success
run aws_cmd rds describe-db-instances --db-instance-identifier "$DB_ID" \
--query 'DBInstances[0].MasterUsername'
assert_success
assert_output --partial 'application_db_admin_user'
# 64 characters is one past what PostgreSQL accepts on a live account
long_username="a$(printf 'b%.0s' $(seq 63))"
run aws_cmd rds create-db-instance \
--db-instance-identifier "$DB_ID_2" \
--engine postgres \
--db-instance-class db.t3.micro \
--allocated-storage 10 \
--master-username "$long_username" \
--master-user-password "secret99password"
assert_failure
assert_output --partial 'InvalidParameterValue'
}
@@ -0,0 +1,90 @@
#!/usr/bin/env bats
# S3 Notification Filter integration tests
load 'test_helper/common-setup'
setup_file() {
export TEST_BUCKET="$(unique_name s3-notif-filter-bucket)"
export TEST_QUEUE="$(unique_name s3-notif-filter-queue)"
export TEST_TOPIC="$(unique_name s3-notif-filter-topic)"
export ACCOUNT_ID="000000000000"
export QUEUE_ARN="arn:aws:sqs:us-east-1:${ACCOUNT_ID}:${TEST_QUEUE}"
# Create SQS queue
aws_cmd sqs create-queue --queue-name "$TEST_QUEUE" >/dev/null 2>&1
# Create SNS topic and capture ARN
local topic_out
topic_out=$(aws_cmd sns create-topic --name "$TEST_TOPIC")
export TOPIC_ARN=$(json_get "$topic_out" '.TopicArn')
# Create S3 bucket
aws_cmd s3api create-bucket --bucket "$TEST_BUCKET" >/dev/null 2>&1
}
teardown_file() {
# Cleanup resources
aws_cmd s3api delete-bucket --bucket "$TEST_BUCKET" >/dev/null 2>&1 || true
aws_cmd sqs delete-queue --queue-url "${FLOCI_ENDPOINT}/${ACCOUNT_ID}/${TEST_QUEUE}" >/dev/null 2>&1 || true
aws_cmd sns delete-topic --topic-arn "$TOPIC_ARN" >/dev/null 2>&1 || true
}
@test "S3 Notifications: put bucket notification configuration with filter" {
local notif_config
notif_config=$(cat <<EOF
{
"QueueConfigurations": [{
"Id": "sqs-filtered",
"QueueArn": "${QUEUE_ARN}",
"Events": ["s3:ObjectCreated:*"],
"Filter": {
"Key": {
"FilterRules": [
{"Name": "prefix", "Value": "incoming/"},
{"Name": "suffix", "Value": ".csv"}
]
}
}
}],
"TopicConfigurations": [{
"Id": "sns-filtered",
"TopicArn": "${TOPIC_ARN}",
"Events": ["s3:ObjectRemoved:*"],
"Filter": {
"Key": {
"FilterRules": [
{"Name": "prefix", "Value": ""},
{"Name": "suffix", "Value": ".txt"}
]
}
}
}]
}
EOF
)
run aws_cmd s3api put-bucket-notification-configuration \
--bucket "$TEST_BUCKET" \
--notification-configuration "$notif_config"
assert_success
}
@test "S3 Notifications: get bucket notification configuration - queue filter round-trip" {
run aws_cmd s3api get-bucket-notification-configuration --bucket "$TEST_BUCKET"
assert_success
# Verify queue configuration has 2 filter rules
local queue_filter_count
queue_filter_count=$(echo "$output" | jq "[.QueueConfigurations[] | select(.QueueArn == \"${QUEUE_ARN}\")][0].Filter.Key.FilterRules | length" 2>/dev/null || echo "0")
[ "$queue_filter_count" = "2" ]
}
@test "S3 Notifications: get bucket notification configuration - topic filter round-trip" {
run aws_cmd s3api get-bucket-notification-configuration --bucket "$TEST_BUCKET"
assert_success
# Verify topic configuration has 2 filter rules
local topic_filter_count
topic_filter_count=$(echo "$output" | jq "[.TopicConfigurations[] | select(.TopicArn == \"${TOPIC_ARN}\")][0].Filter.Key.FilterRules | length" 2>/dev/null || echo "0")
[ "$topic_filter_count" = "2" ]
}
@@ -0,0 +1,565 @@
#!/usr/bin/env bats
# S3 tests
setup() {
load 'test_helper/common-setup'
BUCKET="bats-test-bucket-$(unique_name)"
}
teardown() {
# Clean up all objects in bucket
aws_cmd s3 rm "s3://$BUCKET" --recursive >/dev/null 2>&1 || true
aws_cmd s3api delete-bucket --bucket "$BUCKET" >/dev/null 2>&1 || true
}
@test "S3: create bucket" {
run aws_cmd s3api create-bucket --bucket "$BUCKET"
assert_success
}
@test "S3: list buckets" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
run aws_cmd s3api list-buckets
assert_success
found=$(echo "$output" | jq --arg name "$BUCKET" '.Buckets | any(.Name == $name)')
[ "$found" = "true" ]
}
@test "S3: put object" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
local body_file
body_file=$(mktemp)
echo -n "hello-s3-bats" > "$body_file"
run aws_cmd s3api put-object --bucket "$BUCKET" --key "test.txt" --body "$body_file"
assert_success
rm -f "$body_file"
}
@test "S3: get object" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
local body_file get_file
body_file=$(mktemp)
get_file=$(mktemp)
echo -n "hello-s3-bats" > "$body_file"
aws_cmd s3api put-object --bucket "$BUCKET" --key "test.txt" --body "$body_file" >/dev/null
run aws_cmd s3api get-object --bucket "$BUCKET" --key "test.txt" "$get_file"
assert_success
content=$(cat "$get_file")
[ "$content" = "hello-s3-bats" ]
rm -f "$body_file" "$get_file"
}
@test "S3: head object" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
local body_file
body_file=$(mktemp)
echo -n "hello-s3-bats" > "$body_file"
aws_cmd s3api put-object --bucket "$BUCKET" --key "test.txt" --body "$body_file" >/dev/null
run aws_cmd s3api head-object --bucket "$BUCKET" --key "test.txt"
assert_success
length=$(json_get "$output" '.ContentLength')
[ "$length" = "13" ]
rm -f "$body_file"
}
@test "S3: list objects" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
local body_file
body_file=$(mktemp)
echo -n "hello" > "$body_file"
aws_cmd s3api put-object --bucket "$BUCKET" --key "test.txt" --body "$body_file" >/dev/null
run aws_cmd s3api list-objects-v2 --bucket "$BUCKET"
assert_success
found=$(echo "$output" | jq '.Contents | any(.Key == "test.txt")')
[ "$found" = "true" ]
rm -f "$body_file"
}
@test "S3: copy object" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
local body_file
body_file=$(mktemp)
echo -n "hello" > "$body_file"
aws_cmd s3api put-object --bucket "$BUCKET" --key "src.txt" --body "$body_file" >/dev/null
run aws_cmd s3api copy-object --bucket "$BUCKET" --copy-source "$BUCKET/src.txt" --key "dst.txt"
assert_success
rm -f "$body_file"
}
@test "S3: put and get object tagging" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
local body_file
body_file=$(mktemp)
echo -n "hello" > "$body_file"
aws_cmd s3api put-object --bucket "$BUCKET" --key "test.txt" --body "$body_file" >/dev/null
run aws_cmd s3api put-object-tagging \
--bucket "$BUCKET" \
--key "test.txt" \
--tagging 'TagSet=[{Key=env,Value=test}]'
assert_success
run aws_cmd s3api get-object-tagging --bucket "$BUCKET" --key "test.txt"
assert_success
found=$(echo "$output" | jq '.TagSet | any(.Key == "env" and .Value == "test")')
[ "$found" = "true" ]
rm -f "$body_file"
}
@test "S3: delete object" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
local body_file
body_file=$(mktemp)
echo -n "hello" > "$body_file"
aws_cmd s3api put-object --bucket "$BUCKET" --key "test.txt" --body "$body_file" >/dev/null
run aws_cmd s3api delete-object --bucket "$BUCKET" --key "test.txt"
assert_success
rm -f "$body_file"
}
@test "S3: delete bucket" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
run aws_cmd s3api delete-bucket --bucket "$BUCKET"
assert_success
}
# --- S3 Versioning Tests ---
@test "S3: put bucket versioning" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
run aws_cmd s3api put-bucket-versioning \
--bucket "$BUCKET" \
--versioning-configuration Status=Enabled
assert_success
}
@test "S3: versioned objects have version IDs" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
aws_cmd s3api put-bucket-versioning \
--bucket "$BUCKET" \
--versioning-configuration Status=Enabled >/dev/null
local body_file
body_file=$(mktemp)
echo -n "version-one" > "$body_file"
run aws_cmd s3api put-object --bucket "$BUCKET" --key "ver.txt" --body "$body_file"
assert_success
v1=$(json_get "$output" '.VersionId')
[ -n "$v1" ]
echo -n "version-two" > "$body_file"
run aws_cmd s3api put-object --bucket "$BUCKET" --key "ver.txt" --body "$body_file"
assert_success
v2=$(json_get "$output" '.VersionId')
[ -n "$v2" ]
[ "$v1" != "$v2" ]
rm -f "$body_file"
}
# --- S3 Multipart Upload Tests ---
@test "S3: create multipart upload" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
run aws_cmd s3api create-multipart-upload \
--bucket "$BUCKET" \
--key "multipart.bin"
assert_success
upload_id=$(json_get "$output" '.UploadId')
[ -n "$upload_id" ]
# Cleanup: abort the upload
aws_cmd s3api abort-multipart-upload \
--bucket "$BUCKET" \
--key "multipart.bin" \
--upload-id "$upload_id" >/dev/null 2>&1 || true
}
@test "S3: upload part" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
output=$(aws_cmd s3api create-multipart-upload \
--bucket "$BUCKET" \
--key "multipart.bin")
upload_id=$(json_get "$output" '.UploadId')
local part_file
part_file=$(mktemp)
echo -n "part-one-data" > "$part_file"
run aws_cmd s3api upload-part \
--bucket "$BUCKET" \
--key "multipart.bin" \
--upload-id "$upload_id" \
--part-number 1 \
--body "$part_file"
assert_success
etag=$(json_get "$output" '.ETag')
[ -n "$etag" ]
rm -f "$part_file"
aws_cmd s3api abort-multipart-upload \
--bucket "$BUCKET" \
--key "multipart.bin" \
--upload-id "$upload_id" >/dev/null 2>&1 || true
}
@test "S3: complete multipart upload" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
output=$(aws_cmd s3api create-multipart-upload \
--bucket "$BUCKET" \
--key "multipart.bin")
upload_id=$(json_get "$output" '.UploadId')
local part1_file part2_file
part1_file=$(mktemp)
part2_file=$(mktemp)
echo -n "part-one" > "$part1_file"
echo -n "part-two" > "$part2_file"
output=$(aws_cmd s3api upload-part \
--bucket "$BUCKET" \
--key "multipart.bin" \
--upload-id "$upload_id" \
--part-number 1 \
--body "$part1_file")
etag1=$(json_get "$output" '.ETag')
output=$(aws_cmd s3api upload-part \
--bucket "$BUCKET" \
--key "multipart.bin" \
--upload-id "$upload_id" \
--part-number 2 \
--body "$part2_file")
etag2=$(json_get "$output" '.ETag')
local mp_file
mp_file=$(mktemp)
cat > "$mp_file" <<EOF
{
"Parts": [
{ "ETag": $etag1, "PartNumber": 1 },
{ "ETag": $etag2, "PartNumber": 2 }
]
}
EOF
run aws_cmd s3api complete-multipart-upload \
--bucket "$BUCKET" \
--key "multipart.bin" \
--upload-id "$upload_id" \
--multipart-upload "file://$mp_file"
assert_success
rm -f "$part1_file" "$part2_file" "$mp_file"
}
# --- S3 Large File Test ---
@test "S3: put object 25 MB" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
local large_file
large_file=$(mktemp)
dd if=/dev/zero of="$large_file" bs=1048576 count=25 2>/dev/null
run aws_cmd s3api put-object \
--bucket "$BUCKET" \
--key "large-25mb.bin" \
--body "$large_file"
assert_success
run aws_cmd s3api head-object \
--bucket "$BUCKET" \
--key "large-25mb.bin"
assert_success
length=$(json_get "$output" '.ContentLength')
[ "$length" = "26214400" ]
rm -f "$large_file"
}
@test "S3: metrics configurations round-trip and never touch the bucket" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
run aws_cmd s3api put-bucket-metrics-configuration \
--bucket "$BUCKET" --id EntireBucket --metrics-configuration 'Id=EntireBucket'
assert_success
run aws_cmd s3api get-bucket-metrics-configuration --bucket "$BUCKET" --id EntireBucket
assert_success
[ "$(json_get "$output" '.MetricsConfiguration.Id')" = "EntireBucket" ]
run aws_cmd s3api put-bucket-metrics-configuration \
--bucket "$BUCKET" --id Filtered \
--metrics-configuration 'Id=Filtered,Filter={And={Prefix=logs/,Tags=[{Key=env,Value=prod}]}}'
assert_success
run aws_cmd s3api list-bucket-metrics-configurations --bucket "$BUCKET"
assert_success
[ "$(json_get "$output" '.MetricsConfigurationList | length')" = "2" ]
[ "$(json_get "$output" '.MetricsConfigurationList[1].Filter.And.Prefix')" = "logs/" ]
[ "$(json_get "$output" '.MetricsConfigurationList[1].Filter.And.Tags[0].Key')" = "env" ]
# A sub-resource DELETE removes only that configuration; the bucket must survive it.
run aws_cmd s3api delete-bucket-metrics-configuration --bucket "$BUCKET" --id EntireBucket
assert_success
run aws_cmd s3api head-bucket --bucket "$BUCKET"
assert_success
run aws_cmd s3api get-bucket-metrics-configuration --bucket "$BUCKET" --id EntireBucket
assert_failure
assert_output --partial "NoSuchConfiguration"
}
@test "S3: intelligent-tiering configurations round-trip and never touch the bucket" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
run aws_cmd s3api put-bucket-intelligent-tiering-configuration \
--bucket "$BUCKET" --id EntireBucket \
--intelligent-tiering-configuration 'Id=EntireBucket,Status=Enabled,Tierings=[{AccessTier=ARCHIVE_ACCESS,Days=90}]'
assert_success
run aws_cmd s3api get-bucket-intelligent-tiering-configuration --bucket "$BUCKET" --id EntireBucket
assert_success
[ "$(json_get "$output" '.IntelligentTieringConfiguration.Id')" = "EntireBucket" ]
[ "$(json_get "$output" '.IntelligentTieringConfiguration.Status')" = "Enabled" ]
[ "$(json_get "$output" '.IntelligentTieringConfiguration.Tierings[0].AccessTier')" = "ARCHIVE_ACCESS" ]
[ "$(json_get "$output" '.IntelligentTieringConfiguration.Tierings[0].Days')" = "90" ]
run aws_cmd s3api put-bucket-intelligent-tiering-configuration \
--bucket "$BUCKET" --id Filtered \
--intelligent-tiering-configuration 'Id=Filtered,Filter={And={Prefix=logs/,Tags=[{Key=env,Value=prod}]}},Status=Disabled,Tierings=[{AccessTier=ARCHIVE_ACCESS,Days=90},{AccessTier=DEEP_ARCHIVE_ACCESS,Days=180}]'
assert_success
run aws_cmd s3api list-bucket-intelligent-tiering-configurations --bucket "$BUCKET"
assert_success
[ "$(json_get "$output" '.IntelligentTieringConfigurationList | length')" = "2" ]
[ "$(json_get "$output" '.IntelligentTieringConfigurationList[1].Filter.And.Prefix')" = "logs/" ]
[ "$(json_get "$output" '.IntelligentTieringConfigurationList[1].Filter.And.Tags[0].Key')" = "env" ]
[ "$(json_get "$output" '.IntelligentTieringConfigurationList[1].Status')" = "Disabled" ]
[ "$(json_get "$output" '.IntelligentTieringConfigurationList[1].Tierings | length')" = "2" ]
# Putting the same id replaces rather than duplicates.
run aws_cmd s3api put-bucket-intelligent-tiering-configuration \
--bucket "$BUCKET" --id EntireBucket \
--intelligent-tiering-configuration 'Id=EntireBucket,Status=Disabled,Tierings=[{AccessTier=DEEP_ARCHIVE_ACCESS,Days=180}]'
assert_success
run aws_cmd s3api get-bucket-intelligent-tiering-configuration --bucket "$BUCKET" --id EntireBucket
assert_success
[ "$(json_get "$output" '.IntelligentTieringConfiguration.Status')" = "Disabled" ]
[ "$(json_get "$output" '.IntelligentTieringConfiguration.Tierings[0].AccessTier')" = "DEEP_ARCHIVE_ACCESS" ]
run aws_cmd s3api list-bucket-intelligent-tiering-configurations --bucket "$BUCKET"
assert_success
[ "$(json_get "$output" '.IntelligentTieringConfigurationList | length')" = "2" ]
# A sub-resource DELETE removes only that configuration; the bucket must survive it.
run aws_cmd s3api delete-bucket-intelligent-tiering-configuration --bucket "$BUCKET" --id EntireBucket
assert_success
run aws_cmd s3api head-bucket --bucket "$BUCKET"
assert_success
run aws_cmd s3api get-bucket-intelligent-tiering-configuration --bucket "$BUCKET" --id EntireBucket
assert_failure
assert_output --partial "NoSuchConfiguration"
}
# Checksum helpers: Base64 SHA256 of a file, and the composite S3 stores for a multipart upload
# (SHA256 over the concatenated binary part digests, suffixed with the part count).
sha256_b64() {
openssl dgst -sha256 -binary "$1" | base64 | tr -d '\n'
}
sha256_composite() {
local count=$#
for part in "$@"; do openssl dgst -sha256 -binary "$part"; done \
| openssl dgst -sha256 -binary | base64 | tr -d '\n'
echo "-$count"
}
@test "S3: multipart upload with SHA256 stores the composite checksum" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
local dir
dir=$(mktemp -d)
head -c $((5 * 1024 * 1024)) /dev/urandom > "$dir/part1"
head -c $((1024 * 1024)) /dev/urandom > "$dir/part2"
local sha1 sha2 composite
sha1=$(sha256_b64 "$dir/part1")
sha2=$(sha256_b64 "$dir/part2")
composite=$(sha256_composite "$dir/part1" "$dir/part2")
output=$(aws_cmd s3api create-multipart-upload --bucket "$BUCKET" --key "composite.bin" \
--checksum-algorithm SHA256)
upload_id=$(json_get "$output" '.UploadId')
[ "$(json_get "$output" '.ChecksumAlgorithm')" = "SHA256" ]
[ "$(json_get "$output" '.ChecksumType')" = "COMPOSITE" ]
output=$(aws_cmd s3api upload-part --bucket "$BUCKET" --key "composite.bin" --upload-id "$upload_id" \
--part-number 1 --body "$dir/part1" --checksum-sha256 "$sha1")
etag1=$(json_get "$output" '.ETag')
# UploadPart echoes the checksum of the part
[ "$(json_get "$output" '.ChecksumSHA256')" = "$sha1" ]
output=$(aws_cmd s3api upload-part --bucket "$BUCKET" --key "composite.bin" --upload-id "$upload_id" \
--part-number 2 --body "$dir/part2" --checksum-sha256 "$sha2")
etag2=$(json_get "$output" '.ETag')
local parts_json
parts_json=$(jq -n --arg e1 "$etag1" --arg s1 "$sha1" --arg e2 "$etag2" --arg s2 "$sha2" \
'{Parts: [{PartNumber: 1, ETag: $e1, ChecksumSHA256: $s1}, {PartNumber: 2, ETag: $e2, ChecksumSHA256: $s2}]}')
run aws_cmd s3api complete-multipart-upload --bucket "$BUCKET" --key "composite.bin" \
--upload-id "$upload_id" --multipart-upload "$parts_json"
assert_success
[ "$(json_get "$output" '.ChecksumSHA256')" = "$composite" ]
[ "$(json_get "$output" '.ChecksumType')" = "COMPOSITE" ]
run aws_cmd s3api head-object --bucket "$BUCKET" --key "composite.bin" --checksum-mode ENABLED
assert_success
[ "$(json_get "$output" '.ChecksumSHA256')" = "$composite" ]
[ "$(json_get "$output" '.ChecksumType')" = "COMPOSITE" ]
# GetObjectAttributes reports the composite without the "-2" suffix and the ETag without quotes
run aws_cmd s3api get-object-attributes --bucket "$BUCKET" --key "composite.bin" \
--object-attributes Checksum ObjectParts ETag
assert_success
[ "$(json_get "$output" '.Checksum.ChecksumSHA256')" = "${composite%-2}" ]
[ "$(json_get "$output" '.ObjectParts.TotalPartsCount')" = "2" ]
[ "$(json_get "$output" '.ObjectParts.Parts[0].ChecksumSHA256')" = "$sha1" ]
[[ "$(json_get "$output" '.ETag')" =~ ^[0-9a-f]{32}-2$ ]]
rm -rf "$dir"
}
@test "S3: complete-multipart-upload without part checksums is rejected on a SHA256 upload" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
local part_file
part_file=$(mktemp)
echo -n "part-one" > "$part_file"
output=$(aws_cmd s3api create-multipart-upload --bucket "$BUCKET" --key "strict.bin" --checksum-algorithm SHA256)
upload_id=$(json_get "$output" '.UploadId')
output=$(aws_cmd s3api upload-part --bucket "$BUCKET" --key "strict.bin" --upload-id "$upload_id" \
--part-number 1 --body "$part_file" --checksum-sha256 "$(sha256_b64 "$part_file")")
etag1=$(json_get "$output" '.ETag')
run aws_cmd s3api complete-multipart-upload --bucket "$BUCKET" --key "strict.bin" --upload-id "$upload_id" \
--multipart-upload "$(jq -n --arg e1 "$etag1" '{Parts: [{PartNumber: 1, ETag: $e1}]}')"
assert_failure
assert_output --partial "InvalidRequest"
assert_output --partial "must include the checksum for each part"
aws_cmd s3api abort-multipart-upload --bucket "$BUCKET" --key "strict.bin" --upload-id "$upload_id" >/dev/null 2>&1 || true
rm -f "$part_file"
}
@test "S3: aws s3 cp --checksum-algorithm uploads a large file with a composite checksum" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
local dir
dir=$(mktemp -d)
head -c $((12 * 1024 * 1024)) /dev/urandom > "$dir/large.bin"
# the CLI splits at its default 8 MiB part size, so the composite is over two parts
split -b 8m "$dir/large.bin" "$dir/piece."
local composite
composite=$(sha256_composite "$dir"/piece.*)
[[ "$composite" == *-2 ]]
run aws_cmd s3 cp "$dir/large.bin" "s3://$BUCKET/large.bin" --checksum-algorithm SHA256
assert_success
run aws_cmd s3api head-object --bucket "$BUCKET" --key "large.bin" --checksum-mode ENABLED
assert_success
[ "$(json_get "$output" '.ChecksumSHA256')" = "$composite" ]
[ "$(json_get "$output" '.ChecksumType')" = "COMPOSITE" ]
run aws_cmd s3 cp "s3://$BUCKET/large.bin" "$dir/roundtrip.bin"
assert_success
same_content "$dir/large.bin" "$dir/roundtrip.bin"
rm -rf "$dir"
}
# CRC32 (IEEE) checksums as S3 encodes them (Base64 of the big-endian value); python3 ships in the CLI image.
crc32_b64() {
python3 -c 'import sys, zlib, base64
print(base64.b64encode((zlib.crc32(open(sys.argv[1], "rb").read()) & 0xffffffff).to_bytes(4, "big")).decode())' "$1"
}
crc32_composite() {
python3 -c 'import sys, zlib, base64
def crc(data): return (zlib.crc32(data) & 0xffffffff).to_bytes(4, "big")
parts = [open(p, "rb").read() for p in sys.argv[1:]]
print(base64.b64encode(crc(b"".join(crc(p) for p in parts))).decode() + f"-{len(parts)}")' "$@"
}
same_content() {
[ "$(openssl dgst -sha256 -r "$1" | cut -d" " -f1)" = "$(openssl dgst -sha256 -r "$2" | cut -d" " -f1)" ]
}
@test "S3: plain aws s3 cp of a large file uploads in parts and stores the CLI's default checksum" {
aws_cmd s3api create-bucket --bucket "$BUCKET" >/dev/null
local dir
dir=$(mktemp -d)
head -c $((12 * 1024 * 1024)) /dev/urandom > "$dir/large.bin"
split -b 8m "$dir/large.bin" "$dir/piece."
# no --checksum-algorithm: the CLI multipart-uploads at 8 MiB parts with whatever its version declares
run aws_cmd s3 cp "$dir/large.bin" "s3://$BUCKET/large-default.bin"
assert_success
run aws_cmd s3api head-object --bucket "$BUCKET" --key "large-default.bin" --checksum-mode ENABLED
assert_success
local etag type crc32
etag=$(json_get "$output" '.ETag')
type=$(json_get "$output" '.ChecksumType')
crc32=$(json_get "$output" '.ChecksumCRC32')
[[ "$etag" =~ -2\"$ ]]
if [ -n "$crc32" ] && [ "$crc32" != "null" ]; then
# current CLIs declare CRC32, which S3 stores as COMPOSITE (or FULL_OBJECT when the CLI asks for it)
if [ "$type" = "COMPOSITE" ]; then
[ "$crc32" = "$(crc32_composite "$dir"/piece.*)" ]
else
[ "$type" = "FULL_OBJECT" ]
[ "$crc32" = "$(crc32_b64 "$dir/large.bin")" ]
fi
else
# a CLI that declares no algorithm gets the CRC64NVME full-object checksum S3 attaches itself
[ "$type" = "FULL_OBJECT" ]
[ -n "$(json_get "$output" '.ChecksumCRC64NVME')" ]
fi
run aws_cmd s3api get-object-attributes --bucket "$BUCKET" --key "large-default.bin" --object-attributes ObjectParts
assert_success
[ "$(json_get "$output" '.ObjectParts.TotalPartsCount')" = "2" ]
run aws_cmd s3 cp "s3://$BUCKET/large-default.bin" "$dir/roundtrip.bin"
assert_success
same_content "$dir/large.bin" "$dir/roundtrip.bin"
rm -rf "$dir"
}
@@ -0,0 +1,271 @@
#!/usr/bin/env bats
# Secrets Manager tests
setup() {
load 'test_helper/common-setup'
SECRET_NAME="bats/test/secret-$(unique_name)"
}
teardown() {
aws_cmd secretsmanager delete-secret \
--secret-id "$SECRET_NAME" \
--force-delete-without-recovery >/dev/null 2>&1 || true
}
@test "Secrets Manager: create secret" {
run aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"value"}'
assert_success
arn=$(json_get "$output" '.ARN')
[ -n "$arn" ]
}
@test "Secrets Manager: get secret value" {
aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"value"}' >/dev/null
run aws_cmd secretsmanager get-secret-value --secret-id "$SECRET_NAME"
assert_success
value=$(json_get "$output" '.SecretString')
[ "$value" = '{"key":"value"}' ]
}
@test "Secrets Manager: update secret" {
aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"value"}' >/dev/null
run aws_cmd secretsmanager update-secret \
--secret-id "$SECRET_NAME" \
--secret-string '{"key":"updated"}'
assert_success
run aws_cmd secretsmanager get-secret-value --secret-id "$SECRET_NAME"
assert_success
value=$(json_get "$output" '.SecretString')
[ "$value" = '{"key":"updated"}' ]
}
@test "Secrets Manager: list secrets" {
aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"value"}' >/dev/null
run aws_cmd secretsmanager list-secrets
assert_success
found=$(echo "$output" | jq --arg name "$SECRET_NAME" '.SecretList | any(.Name == $name)')
[ "$found" = "true" ]
}
@test "Secrets Manager: delete secret" {
aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"value"}' >/dev/null
run aws_cmd secretsmanager delete-secret \
--secret-id "$SECRET_NAME" \
--force-delete-without-recovery
assert_success
}
# --- Secrets Manager Tagging Tests ---
@test "Secrets Manager: tag resource" {
out=$(aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"value"}')
arn=$(json_get "$out" '.ARN')
run aws_cmd secretsmanager tag-resource \
--secret-id "$arn" \
--tags Key=Environment,Value=test Key=Project,Value=bats
assert_success
}
@test "Secrets Manager: describe secret with tags" {
out=$(aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"value"}')
arn=$(json_get "$out" '.ARN')
aws_cmd secretsmanager tag-resource \
--secret-id "$arn" \
--tags Key=Environment,Value=test >/dev/null
run aws_cmd secretsmanager describe-secret --secret-id "$SECRET_NAME"
assert_success
found=$(echo "$output" | jq '.Tags | any(.Key == "Environment" and .Value == "test")')
[ "$found" = "true" ]
}
@test "Secrets Manager: untag resource" {
out=$(aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"value"}')
arn=$(json_get "$out" '.ARN')
aws_cmd secretsmanager tag-resource \
--secret-id "$arn" \
--tags Key=Environment,Value=test >/dev/null
run aws_cmd secretsmanager untag-resource \
--secret-id "$arn" \
--tag-keys Environment
assert_success
# Verify tag is removed
run aws_cmd secretsmanager describe-secret --secret-id "$SECRET_NAME"
found=$(echo "$output" | jq '.Tags // [] | any(.Key == "Environment")')
[ "$found" = "false" ]
}
# --- Secrets Manager Version Stage Tests ---
@test "Secrets Manager: add custom stage to version" {
out=$(aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"value"}')
v1=$(json_get "$out" '.VersionId')
run aws_cmd secretsmanager update-secret-version-stage \
--secret-id "$SECRET_NAME" \
--version-stage MYSTAGE \
--move-to-version-id "$v1"
assert_success
run aws_cmd secretsmanager describe-secret --secret-id "$SECRET_NAME"
assert_success
has_mystage=$(echo "$output" | jq --arg vid "$v1" '.VersionIdsToStages[$vid] | any(. == "MYSTAGE")')
[ "$has_mystage" = "true" ]
has_current=$(echo "$output" | jq --arg vid "$v1" '.VersionIdsToStages[$vid] | any(. == "AWSCURRENT")')
[ "$has_current" = "true" ]
}
@test "Secrets Manager: move custom stage between versions" {
out=$(aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"v1"}')
v1=$(json_get "$out" '.VersionId')
out=$(aws_cmd secretsmanager update-secret \
--secret-id "$SECRET_NAME" \
--secret-string '{"key":"v2"}')
v2=$(json_get "$out" '.VersionId')
aws_cmd secretsmanager update-secret-version-stage \
--secret-id "$SECRET_NAME" \
--version-stage MYSTAGE \
--move-to-version-id "$v1" >/dev/null
run aws_cmd secretsmanager update-secret-version-stage \
--secret-id "$SECRET_NAME" \
--version-stage MYSTAGE \
--move-to-version-id "$v2" \
--remove-from-version-id "$v1"
assert_success
run aws_cmd secretsmanager describe-secret --secret-id "$SECRET_NAME"
assert_success
on_v2=$(echo "$output" | jq --arg vid "$v2" '.VersionIdsToStages[$vid] | any(. == "MYSTAGE")')
[ "$on_v2" = "true" ]
on_v1=$(echo "$output" | jq --arg vid "$v1" '(.VersionIdsToStages[$vid] // []) | any(. == "MYSTAGE")')
[ "$on_v1" = "false" ]
}
@test "Secrets Manager: move AWSCURRENT updates AWSPREVIOUS" {
out=$(aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"v1"}')
v1=$(json_get "$out" '.VersionId')
out=$(aws_cmd secretsmanager update-secret \
--secret-id "$SECRET_NAME" \
--secret-string '{"key":"v2"}')
v2=$(json_get "$out" '.VersionId')
run aws_cmd secretsmanager update-secret-version-stage \
--secret-id "$SECRET_NAME" \
--version-stage AWSCURRENT \
--move-to-version-id "$v1" \
--remove-from-version-id "$v2"
assert_success
run aws_cmd secretsmanager describe-secret --secret-id "$SECRET_NAME"
assert_success
v1_current=$(echo "$output" | jq --arg vid "$v1" '.VersionIdsToStages[$vid] | any(. == "AWSCURRENT")')
[ "$v1_current" = "true" ]
v2_previous=$(echo "$output" | jq --arg vid "$v2" '.VersionIdsToStages[$vid] | any(. == "AWSPREVIOUS")')
[ "$v2_previous" = "true" ]
}
@test "Secrets Manager: remove custom stage from version" {
out=$(aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"value"}')
v1=$(json_get "$out" '.VersionId')
aws_cmd secretsmanager update-secret-version-stage \
--secret-id "$SECRET_NAME" \
--version-stage MYSTAGE \
--move-to-version-id "$v1" >/dev/null
run aws_cmd secretsmanager update-secret-version-stage \
--secret-id "$SECRET_NAME" \
--version-stage MYSTAGE \
--remove-from-version-id "$v1"
assert_success
run aws_cmd secretsmanager describe-secret --secret-id "$SECRET_NAME"
assert_success
has_mystage=$(echo "$output" | jq --arg vid "$v1" '(.VersionIdsToStages[$vid] // []) | any(. == "MYSTAGE")')
[ "$has_mystage" = "false" ]
has_current=$(echo "$output" | jq --arg vid "$v1" '.VersionIdsToStages[$vid] | any(. == "AWSCURRENT")')
[ "$has_current" = "true" ]
}
# --- Batch Get Secret Value Tests ---
@test "Secrets Manager: batch-get-secret-value returns all found secrets" {
S1="${SECRET_NAME}-batch-a"
S2="${SECRET_NAME}-batch-b"
aws_cmd secretsmanager create-secret \
--name "$S1" \
--secret-string '{"key":"val-a"}' >/dev/null
aws_cmd secretsmanager create-secret \
--name "$S2" \
--secret-string '{"key":"val-b"}' >/dev/null
run aws_cmd secretsmanager batch-get-secret-value \
--secret-id-list "$S1" "$S2"
assert_success
count=$(echo "$output" | jq '.SecretValues | length')
[ "$count" = "2" ]
errors=$(echo "$output" | jq '.Errors | length')
[ "$errors" = "0" ]
aws_cmd secretsmanager delete-secret --secret-id "$S1" --force-delete-without-recovery >/dev/null 2>&1 || true
aws_cmd secretsmanager delete-secret --secret-id "$S2" --force-delete-without-recovery >/dev/null 2>&1 || true
}
@test "Secrets Manager: batch-get-secret-value with one missing returns partial Errors list" {
aws_cmd secretsmanager create-secret \
--name "$SECRET_NAME" \
--secret-string '{"key":"value"}' >/dev/null
run aws_cmd secretsmanager batch-get-secret-value \
--secret-id-list "$SECRET_NAME" "not-exists"
assert_success
values=$(echo "$output" | jq '.SecretValues | length')
[ "$values" = "1" ]
errors=$(echo "$output" | jq '.Errors | length')
[ "$errors" = "1" ]
error_code=$(echo "$output" | jq -r '.Errors[0].ErrorCode')
[ "$error_code" = "ResourceNotFoundException" ]
secret_id=$(echo "$output" | jq -r '.Errors[0].SecretId')
[ "$secret_id" = "not-exists" ]
}
@@ -0,0 +1,236 @@
#!/usr/bin/env bats
# SES integration tests
load 'test_helper/common-setup'
setup_file() {
export TEST_EMAIL="$(unique_name test)@example.com"
export TEST_DOMAIN="$(unique_name test).example.com"
}
teardown_file() {
# Cleanup identities
aws_cmd ses delete-identity --identity "$TEST_EMAIL" >/dev/null 2>&1 || true
aws_cmd ses delete-identity --identity "$TEST_DOMAIN" >/dev/null 2>&1 || true
}
@test "SES: verify email identity" {
run aws_cmd ses verify-email-identity --email-address "$TEST_EMAIL"
assert_success
}
@test "SES: verify domain identity" {
run aws_cmd ses verify-domain-identity --domain "$TEST_DOMAIN"
assert_success
token=$(json_get "$output" '.VerificationToken')
[ -n "$token" ]
}
@test "SES: list identities" {
run aws_cmd ses list-identities
assert_success
assert_output --partial "$TEST_EMAIL"
assert_output --partial "$TEST_DOMAIN"
}
@test "SES: list identities by type EmailAddress" {
run aws_cmd ses list-identities --identity-type EmailAddress
assert_success
assert_output --partial "$TEST_EMAIL"
refute_output --partial "$TEST_DOMAIN"
}
@test "SES: get identity verification attributes" {
run aws_cmd ses get-identity-verification-attributes --identities "$TEST_EMAIL"
assert_success
status=$(json_get "$output" ".VerificationAttributes.\"$TEST_EMAIL\".VerificationStatus")
[ "$status" = "Success" ]
}
@test "SES: send email" {
run aws_cmd ses send-email \
--from "$TEST_EMAIL" \
--destination "ToAddresses=recipient@example.com" \
--message "Subject={Data=Test Subject},Body={Text={Data=Hello from SES test}}"
assert_success
message_id=$(json_get "$output" '.MessageId')
[ -n "$message_id" ]
}
@test "SES: send raw email" {
local raw_file
raw_file=$(mktemp)
printf 'From: %s\r\nTo: recipient@example.com\r\nSubject: Raw Test\r\n\r\nRaw body' "$TEST_EMAIL" > "$raw_file"
local raw_b64
raw_b64=$(python3 -c "import base64,sys; print(base64.b64encode(open(sys.argv[1],'rb').read()).decode())" "$raw_file")
run aws_cmd ses send-raw-email \
--source "$TEST_EMAIL" \
--destinations "recipient@example.com" \
--raw-message "Data=$raw_b64"
rm -f "$raw_file"
assert_success
message_id=$(json_get "$output" '.MessageId')
[ -n "$message_id" ]
}
@test "SES: get send quota" {
run aws_cmd ses get-send-quota
assert_success
max_24h=$(json_get "$output" '.Max24HourSend')
max_rate=$(json_get "$output" '.MaxSendRate')
[ "$(echo "$max_24h > 0" | bc)" -eq 1 ]
[ "$(echo "$max_rate > 0" | bc)" -eq 1 ]
}
@test "SES: get send statistics" {
run aws_cmd ses get-send-statistics
assert_success
# Should have SendDataPoints array (may be empty)
run jq -e '.SendDataPoints' <<< "$output"
assert_success
}
@test "SES: get account sending enabled" {
run aws_cmd ses get-account-sending-enabled
assert_success
enabled=$(json_get "$output" '.Enabled')
[ "$enabled" = "true" ]
}
@test "SES: get identity dkim attributes" {
run aws_cmd ses get-identity-dkim-attributes --identities "$TEST_DOMAIN"
assert_success
# Should have DkimAttributes for our domain
run jq -e ".DkimAttributes.\"$TEST_DOMAIN\"" <<< "$output"
assert_success
}
@test "SES: set identity notification topic" {
# This test checks that the command is accepted (may have parser quirks)
run aws_cmd ses set-identity-notification-topic \
--identity "$TEST_EMAIL" \
--notification-type Bounce \
--sns-topic arn:aws:sns:us-east-1:000000000000:bounce-topic
# Accept success or known parser bug output
[[ $status -eq 0 ]] || [[ "$output" == *"SetIdentityNotificationTopicResult"* ]]
}
@test "SES: get identity notification attributes" {
run aws_cmd ses get-identity-notification-attributes --identities "$TEST_EMAIL"
assert_success
}
@test "SES: delete identity email" {
run aws_cmd ses delete-identity --identity "$TEST_EMAIL"
# Accept success or known parser bug output
[[ $status -eq 0 ]] || [[ "$output" == *"DeleteIdentityResult"* ]]
}
@test "SES: delete identity domain" {
run aws_cmd ses delete-identity --identity "$TEST_DOMAIN"
# Accept success or known parser bug output
[[ $status -eq 0 ]] || [[ "$output" == *"DeleteIdentityResult"* ]]
}
@test "SES: verify identities deleted" {
run aws_cmd ses list-identities
assert_success
refute_output --partial "$TEST_EMAIL"
refute_output --partial "$TEST_DOMAIN"
}
# ──────────────── Template CRUD ────────────────
@test "SES: create template" {
local tpl_json
tpl_json=$(cat <<TMPL
{"TemplateName":"cli-tpl-${BATS_ROOT_PID}","SubjectPart":"Hello {{name}}","TextPart":"Hi {{name}} from {{team}}","HtmlPart":"<p>Hi {{name}}</p>"}
TMPL
)
run aws_cmd ses create-template --template "$tpl_json"
assert_success
}
@test "SES: create template - duplicate rejected" {
local tpl_json
tpl_json=$(cat <<TMPL
{"TemplateName":"cli-tpl-${BATS_ROOT_PID}","SubjectPart":"Dup","TextPart":"Dup"}
TMPL
)
run aws_cmd ses create-template --template "$tpl_json"
assert_failure
assert_output --partial "AlreadyExists"
}
@test "SES: get template" {
run aws_cmd ses get-template --template-name "cli-tpl-${BATS_ROOT_PID}"
assert_success
name=$(json_get "$output" '.Template.TemplateName')
subject=$(json_get "$output" '.Template.SubjectPart')
[ "$name" = "cli-tpl-${BATS_ROOT_PID}" ]
[ "$subject" = "Hello {{name}}" ]
}
@test "SES: get template - not found" {
run aws_cmd ses get-template --template-name "nonexistent-tpl"
assert_failure
assert_output --partial "TemplateDoesNotExist"
}
@test "SES: update template" {
local tpl_json
tpl_json=$(cat <<TMPL
{"TemplateName":"cli-tpl-${BATS_ROOT_PID}","SubjectPart":"Updated {{name}}","TextPart":"Updated {{name}} {{team}}","HtmlPart":"<p>Updated {{name}}</p>"}
TMPL
)
run aws_cmd ses update-template --template "$tpl_json"
assert_success
run aws_cmd ses get-template --template-name "cli-tpl-${BATS_ROOT_PID}"
assert_success
subject=$(json_get "$output" '.Template.SubjectPart')
[ "$subject" = "Updated {{name}}" ]
}
@test "SES: list templates includes created" {
run aws_cmd ses list-templates
assert_success
assert_output --partial "cli-tpl-${BATS_ROOT_PID}"
}
@test "SES: send templated email" {
# Ensure an identity exists for sending
aws_cmd ses verify-email-identity --email-address "tpl-sender@example.com" >/dev/null 2>&1 || true
run aws_cmd ses send-templated-email \
--source "tpl-sender@example.com" \
--destination "ToAddresses=recipient@example.com" \
--template "cli-tpl-${BATS_ROOT_PID}" \
--template-data '{"name":"Alice","team":"floci"}'
assert_success
message_id=$(json_get "$output" '.MessageId')
[ -n "$message_id" ]
}
@test "SES: send templated email - unknown template" {
run aws_cmd ses send-templated-email \
--source "tpl-sender@example.com" \
--destination "ToAddresses=recipient@example.com" \
--template "nonexistent-tpl" \
--template-data '{}'
assert_failure
assert_output --partial "TemplateDoesNotExist"
}
@test "SES: delete template" {
run aws_cmd ses delete-template --template-name "cli-tpl-${BATS_ROOT_PID}"
assert_success
}
@test "SES: delete template - not found" {
run aws_cmd ses delete-template --template-name "nonexistent-tpl"
assert_failure
assert_output --partial "TemplateDoesNotExist"
}
@@ -0,0 +1,58 @@
#!/usr/bin/env bats
# SNS tests
setup() {
load 'test_helper/common-setup'
TOPIC_NAME="bats-test-topic-$(unique_name)"
TOPIC_ARN=""
}
teardown() {
if [ -n "$TOPIC_ARN" ]; then
aws_cmd sns delete-topic --topic-arn "$TOPIC_ARN" >/dev/null 2>&1 || true
fi
}
@test "SNS: create topic" {
run aws_cmd sns create-topic --name "$TOPIC_NAME"
assert_success
TOPIC_ARN=$(json_get "$output" '.TopicArn')
[ -n "$TOPIC_ARN" ]
}
@test "SNS: list topics" {
out=$(aws_cmd sns create-topic --name "$TOPIC_NAME")
TOPIC_ARN=$(json_get "$out" '.TopicArn')
run aws_cmd sns list-topics
assert_success
found=$(echo "$output" | jq --arg name "$TOPIC_NAME" '.Topics | any(.TopicArn | contains($name))')
[ "$found" = "true" ]
}
@test "SNS: get topic attributes" {
out=$(aws_cmd sns create-topic --name "$TOPIC_NAME")
TOPIC_ARN=$(json_get "$out" '.TopicArn')
run aws_cmd sns get-topic-attributes --topic-arn "$TOPIC_ARN"
assert_success
}
@test "SNS: publish message" {
out=$(aws_cmd sns create-topic --name "$TOPIC_NAME")
TOPIC_ARN=$(json_get "$out" '.TopicArn')
run aws_cmd sns publish --topic-arn "$TOPIC_ARN" --message "hello-bats"
assert_success
msg_id=$(json_get "$output" '.MessageId')
[ -n "$msg_id" ]
}
@test "SNS: delete topic" {
out=$(aws_cmd sns create-topic --name "$TOPIC_NAME")
TOPIC_ARN=$(json_get "$out" '.TopicArn')
run aws_cmd sns delete-topic --topic-arn "$TOPIC_ARN"
assert_success
TOPIC_ARN=""
}
@@ -0,0 +1,101 @@
#!/usr/bin/env bats
# SQS tests
setup() {
load 'test_helper/common-setup'
QUEUE_NAME="bats-test-queue-$(unique_name)"
QUEUE_URL=""
}
teardown() {
if [ -n "$QUEUE_URL" ]; then
aws_cmd sqs delete-queue --queue-url "$QUEUE_URL" >/dev/null 2>&1 || true
fi
}
@test "SQS: create queue" {
run aws_cmd sqs create-queue --queue-name "$QUEUE_NAME"
assert_success
QUEUE_URL=$(json_get "$output" '.QueueUrl')
[ -n "$QUEUE_URL" ]
}
@test "SQS: get queue URL" {
out=$(aws_cmd sqs create-queue --queue-name "$QUEUE_NAME")
QUEUE_URL=$(json_get "$out" '.QueueUrl')
run aws_cmd sqs get-queue-url --queue-name "$QUEUE_NAME"
assert_success
got_url=$(json_get "$output" '.QueueUrl')
[ "$got_url" = "$QUEUE_URL" ]
}
@test "SQS: list queues" {
out=$(aws_cmd sqs create-queue --queue-name "$QUEUE_NAME")
QUEUE_URL=$(json_get "$out" '.QueueUrl')
run aws_cmd sqs list-queues --queue-name-prefix "bats-test"
assert_success
found=$(echo "$output" | jq --arg name "$QUEUE_NAME" '.QueueUrls | any(contains($name))')
[ "$found" = "true" ]
}
@test "SQS: send and receive message" {
out=$(aws_cmd sqs create-queue --queue-name "$QUEUE_NAME")
QUEUE_URL=$(json_get "$out" '.QueueUrl')
run aws_cmd sqs send-message --queue-url "$QUEUE_URL" --message-body "hello-bats"
assert_success
msg_id=$(json_get "$output" '.MessageId')
[ -n "$msg_id" ]
run aws_cmd sqs receive-message --queue-url "$QUEUE_URL" --max-number-of-messages 1 --wait-time-seconds 1
assert_success
body=$(json_get "$output" '.Messages[0].Body')
[ "$body" = "hello-bats" ]
}
@test "SQS: delete message" {
out=$(aws_cmd sqs create-queue --queue-name "$QUEUE_NAME")
QUEUE_URL=$(json_get "$out" '.QueueUrl')
aws_cmd sqs send-message --queue-url "$QUEUE_URL" --message-body "hello-bats" >/dev/null
out=$(aws_cmd sqs receive-message --queue-url "$QUEUE_URL" --max-number-of-messages 1 --wait-time-seconds 1)
receipt=$(json_get "$out" '.Messages[0].ReceiptHandle')
run aws_cmd sqs delete-message --queue-url "$QUEUE_URL" --receipt-handle "$receipt"
assert_success
}
@test "SQS: get queue attributes" {
out=$(aws_cmd sqs create-queue --queue-name "$QUEUE_NAME")
QUEUE_URL=$(json_get "$out" '.QueueUrl')
run aws_cmd sqs get-queue-attributes --queue-url "$QUEUE_URL" --attribute-names ApproximateNumberOfMessages
assert_success
}
@test "SQS: delete queue" {
out=$(aws_cmd sqs create-queue --queue-name "$QUEUE_NAME")
QUEUE_URL=$(json_get "$out" '.QueueUrl')
run aws_cmd sqs delete-queue --queue-url "$QUEUE_URL"
assert_success
QUEUE_URL=""
}
@test "SQS: tags set at CreateQueue are returned by ListQueueTags" {
# Regression test for https://github.com/floci-io/floci/issues/699
run aws_cmd sqs create-queue --queue-name "$QUEUE_NAME" --tags "k1=v1,k2=v2"
assert_success
QUEUE_URL=$(json_get "$output" '.QueueUrl')
[ -n "$QUEUE_URL" ]
run aws_cmd sqs list-queue-tags --queue-url "$QUEUE_URL"
assert_success
v1=$(json_get "$output" '.Tags.k1')
v2=$(json_get "$output" '.Tags.k2')
[ "$v1" = "v1" ]
[ "$v2" = "v2" ]
}
@@ -0,0 +1,73 @@
#!/usr/bin/env bats
# SSM Parameter Store tests
setup() {
load 'test_helper/common-setup'
PARAM_NAME="/bats-test/param-$(unique_name)"
}
teardown() {
aws_cmd ssm delete-parameter --name "$PARAM_NAME" >/dev/null 2>&1 || true
}
@test "SSM: put parameter" {
run aws_cmd ssm put-parameter --name "$PARAM_NAME" --value "test-value" --type String
assert_success
version=$(json_get "$output" '.Version')
[ "$version" -gt 0 ]
}
@test "SSM: get parameter" {
aws_cmd ssm put-parameter --name "$PARAM_NAME" --value "test-value" --type String >/dev/null
run aws_cmd ssm get-parameter --name "$PARAM_NAME" --no-with-decryption
assert_success
value=$(json_get "$output" '.Parameter.Value')
[ "$value" = "test-value" ]
}
@test "SSM: get parameters by path" {
aws_cmd ssm put-parameter --name "$PARAM_NAME" --value "test-value" --type String >/dev/null
run aws_cmd ssm get-parameters-by-path --path "/bats-test"
assert_success
# Check that parameters array is not empty
count=$(echo "$output" | jq '.Parameters | length')
[ "$count" -gt 0 ]
}
@test "SSM: add and list tags" {
aws_cmd ssm put-parameter --name "$PARAM_NAME" --value "test-value" --type String >/dev/null
run aws_cmd ssm add-tags-to-resource \
--resource-type Parameter \
--resource-id "$PARAM_NAME" \
--tags Key=env,Value=test
assert_success
run aws_cmd ssm list-tags-for-resource \
--resource-type Parameter \
--resource-id "$PARAM_NAME"
assert_success
found=$(echo "$output" | jq '.TagList | any(.Key == "env" and .Value == "test")')
[ "$found" = "true" ]
}
@test "SSM: overwrite parameter" {
aws_cmd ssm put-parameter --name "$PARAM_NAME" --value "original" --type String >/dev/null
run aws_cmd ssm put-parameter --name "$PARAM_NAME" --value "updated" --type String --overwrite
assert_success
run aws_cmd ssm get-parameter --name "$PARAM_NAME" --no-with-decryption
assert_success
value=$(json_get "$output" '.Parameter.Value')
[ "$value" = "updated" ]
}
@test "SSM: delete parameter" {
aws_cmd ssm put-parameter --name "$PARAM_NAME" --value "test-value" --type String >/dev/null
run aws_cmd ssm delete-parameter --name "$PARAM_NAME"
assert_success
}
@@ -0,0 +1,26 @@
#!/usr/bin/env bats
# STS tests
setup() {
load 'test_helper/common-setup'
}
@test "STS: get caller identity" {
run aws_cmd sts get-caller-identity
assert_success
account=$(json_get "$output" '.Account')
[ -n "$account" ]
user_id=$(json_get "$output" '.UserId')
[ -n "$user_id" ]
}
@test "STS: assume role" {
local role_arn="arn:aws:iam::000000000000:role/test-role"
run aws_cmd sts assume-role \
--role-arn "$role_arn" \
--role-session-name "bats-test-session"
assert_success
access_key=$(json_get "$output" '.Credentials.AccessKeyId')
[ -n "$access_key" ]
}
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Common setup for bats tests
# Load bats-support and bats-assert
# Supports both local lib/ directory and BATS_LIB_PATH for Docker
_COMMON_SETUP_DIR="${BATS_TEST_DIRNAME}"
if [[ -d "${_COMMON_SETUP_DIR}/../../lib/bats-support" ]]; then
load "${_COMMON_SETUP_DIR}/../../lib/bats-support/load.bash"
load "${_COMMON_SETUP_DIR}/../../lib/bats-assert/load.bash"
elif [[ -n "${BATS_LIB_PATH}" ]]; then
load "${BATS_LIB_PATH}/bats-support/load.bash"
load "${BATS_LIB_PATH}/bats-assert/load.bash"
else
echo "Error: Cannot find bats-support/bats-assert libraries" >&2
exit 1
fi
# Environment configuration
export FLOCI_ENDPOINT="${FLOCI_ENDPOINT:-http://localhost:4566}"
export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-1}"
export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-test}"
export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-test}"
# Helper function to run AWS CLI commands with endpoint
aws_cmd() {
aws --endpoint-url "$FLOCI_ENDPOINT" --region "$AWS_DEFAULT_REGION" --output json "$@" 2>&1
}
# Generate unique name for test resources
unique_name() {
local prefix="${1:-test}"
echo "${prefix}-$(date +%s)-$$"
}
# Wait for DynamoDB table to exist
ddb_wait_table() {
local table_name="$1"
aws_cmd dynamodb wait table-exists --table-name "$table_name" >/dev/null 2>&1 || true
}
# Extract JSON value using jq
json_get() {
local json="$1"
local path="$2"
echo "$json" | jq -r "$path" 2>/dev/null || echo ""
}
# Check if operation is unsupported
is_unsupported_operation() {
local output="$1"
[[ "$output" == *"(UnsupportedOperation)"* ]] || [[ "$output" == *" is not supported."* ]]
}