From 51eae3b854d6a66eb917581165b3c33ea50a530b Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Fri, 5 Jun 2026 17:27:24 +0500 Subject: [PATCH 1/4] Add terraform test coverage for vpc + quilt modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan-only, provider-mocked tests — no AWS credentials, no infrastructure: - modules/vpc: characterize the new-vs-existing network input validation — valid new/existing configs plan cleanly; contradictory configs trip the configuration_error precondition. - modules/quilt: smoke-test the public module end-to-end (vpc + db + search + CloudFormation) through a wrapper root that re-exposes only the non-sensitive stack name. Run both in a new CI `test` job; bump CI Terraform 1.5.0 -> 1.10.0 (mock_provider requires >= 1.7). No module behavior changes. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 25 ++- .gitignore | 1 + modules/quilt/tests/smoke/fixtures/quilt.yaml | 8 + modules/quilt/tests/smoke/main.tf | 73 ++++++++ modules/quilt/tests/smoke/smoke.tftest.hcl | 48 +++++ modules/vpc/tests/validation.tftest.hcl | 169 ++++++++++++++++++ 6 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 modules/quilt/tests/smoke/fixtures/quilt.yaml create mode 100644 modules/quilt/tests/smoke/main.tf create mode 100644 modules/quilt/tests/smoke/smoke.tftest.hcl create mode 100644 modules/vpc/tests/validation.tftest.hcl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 626bfe3..1c60aad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v6 - uses: hashicorp/setup-terraform@v4 with: - terraform_version: "1.5.0" + terraform_version: "1.10.0" terraform_wrapper: false - run: terraform fmt -check -recursive -diff @@ -37,8 +37,29 @@ jobs: - uses: actions/checkout@v6 - uses: hashicorp/setup-terraform@v4 with: - terraform_version: "1.5.0" + terraform_version: "1.10.0" terraform_wrapper: false # -backend=false: validate needs init for provider/module schemas but no backend or cloud credentials. - run: terraform init -backend=false - run: terraform validate -no-color + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + dir: + - modules/vpc + - modules/quilt/tests/smoke + defaults: + run: + working-directory: ${{ matrix.dir }} + steps: + - uses: actions/checkout@v6 + - uses: hashicorp/setup-terraform@v4 + with: + terraform_version: "1.10.0" + terraform_wrapper: false + # The tests mock the AWS provider, so no backend or cloud credentials are needed. + - run: terraform init -backend=false + - run: terraform test -no-color diff --git a/.gitignore b/.gitignore index ae45930..d551d84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store .terraform +.terraform.lock.hcl tfplan diff --git a/modules/quilt/tests/smoke/fixtures/quilt.yaml b/modules/quilt/tests/smoke/fixtures/quilt.yaml new file mode 100644 index 0000000..9d939fa --- /dev/null +++ b/modules/quilt/tests/smoke/fixtures/quilt.yaml @@ -0,0 +1,8 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: >- + Test fixture for terraform test smoke runs. Stands in for the real Quilt + CloudFormation template so plan-time references (template_file / filemd5) + resolve. Not a deployable Quilt stack. +Resources: + Placeholder: + Type: AWS::CloudFormation::WaitConditionHandle diff --git a/modules/quilt/tests/smoke/main.tf b/modules/quilt/tests/smoke/main.tf new file mode 100644 index 0000000..f999d49 --- /dev/null +++ b/modules/quilt/tests/smoke/main.tf @@ -0,0 +1,73 @@ +# Wrapper root for the `quilt` module smoke tests. +# +# The smoke tests run against this wrapper rather than modules/quilt directly +# because the quilt module's `stack` output embeds sensitive values (DB + admin +# passwords); as a root module under test that trips a sensitive-output error. +# The wrapper re-exposes only the non-sensitive stack name. +# +# The required_providers block below is also load-bearing for the tests: +# declaring the provider requirement at the root is what lets the test's +# mock_provider engage for the whole module tree. Without a direct provider +# reference at the root, the mock never attaches and the child modules fall +# back to real AWS credentials. +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + } + } +} + +variable "create_new_vpc" { + type = bool +} + +variable "internal" { + type = bool + default = false +} + +variable "vpc_id" { + type = string + default = null +} + +variable "intra_subnets" { + type = list(string) + default = null +} + +variable "private_subnets" { + type = list(string) + default = null +} + +variable "public_subnets" { + type = list(string) + default = null +} + +variable "user_security_group" { + type = string + default = null +} + +module "quilt" { + source = "../../" + + name = "quilt-test" + parameters = {} + template_file = "${path.module}/fixtures/quilt.yaml" + + create_new_vpc = var.create_new_vpc + internal = var.internal + vpc_id = var.vpc_id + intra_subnets = var.intra_subnets + private_subnets = var.private_subnets + public_subnets = var.public_subnets + user_security_group = var.user_security_group +} + +output "stack_name" { + value = module.quilt.stack.name +} diff --git a/modules/quilt/tests/smoke/smoke.tftest.hcl b/modules/quilt/tests/smoke/smoke.tftest.hcl new file mode 100644 index 0000000..429ff1b --- /dev/null +++ b/modules/quilt/tests/smoke/smoke.tftest.hcl @@ -0,0 +1,48 @@ +# Smoke tests for the public `quilt` module, run against the wrapper root in +# this directory (see main.tf). +# +# Plan-only with the AWS provider mocked: no credentials, no infrastructure. +# Exercises the full wiring (vpc + db + search + the CloudFormation stack), so +# a change that breaks the public boundary or the vpc pass-through fails in CI. +# +# Assertions reference known inputs (the stack name), not mocked computed +# attributes, whose generated values are intentionally arbitrary. + +mock_provider "aws" { + # slice(..., 0, 2) and the cidrsubnet math in the vpc submodule need at + # least two AZ names; mocked collections are otherwise empty. + mock_data "aws_availability_zones" { + defaults = { + names = ["us-east-1a", "us-east-1b", "us-east-1c"] + } + } +} + +run "new_vpc_plans" { + command = plan + variables { + create_new_vpc = true + internal = false + } + assert { + condition = output.stack_name == "quilt-test" + error_message = "The CloudFormation stack must be named after var.name" + } +} + +run "existing_vpc_plans" { + command = plan + variables { + create_new_vpc = false + internal = false + vpc_id = "vpc-00000000000000000" + intra_subnets = ["subnet-intra-a", "subnet-intra-b"] + private_subnets = ["subnet-priv-a", "subnet-priv-b"] + public_subnets = ["subnet-pub-a", "subnet-pub-b"] + user_security_group = "sg-00000000000000000" + } + assert { + condition = output.stack_name == "quilt-test" + error_message = "The CloudFormation stack must be named after var.name" + } +} diff --git a/modules/vpc/tests/validation.tftest.hcl b/modules/vpc/tests/validation.tftest.hcl new file mode 100644 index 0000000..3eb1af8 --- /dev/null +++ b/modules/vpc/tests/validation.tftest.hcl @@ -0,0 +1,169 @@ +# Characterization tests for the new-vs-existing VPC input validation. +# +# These run `terraform plan` with the AWS provider mocked, so they need no AWS +# credentials and create no infrastructure. They pin the behavior of the +# `configuration_error` precondition (see outputs.tf): valid input combinations +# must plan cleanly, and contradictory combinations must fail fast. +# +# On a valid config the precondition passes and the `configuration_error` +# output renders the requirement checklist with every line marked ✅; an +# unmet requirement shows ❌ and a contradictory config trips the precondition +# during plan. + +mock_provider "aws" { + # slice(..., 0, 2) and the cidrsubnet math need at least two AZ names. + mock_data "aws_availability_zones" { + defaults = { + names = ["us-east-1a", "us-east-1b", "us-east-1c"] + } + } +} + +variables { + name = "quilt-test" + cidr = "10.0.0.0/16" +} + +# --- Valid: create a new VPC ------------------------------------------------- + +run "new_vpc_external_alb" { + command = plan + + variables { + create_new_vpc = true + internal = false + existing_vpc_id = null + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + assert { + condition = strcontains(output.configuration_error, "create a new VPC") + error_message = "New-VPC config must be checked against the new-network requirements" + } + + assert { + condition = !strcontains(output.configuration_error, "❌") + error_message = "A valid new-VPC config (internal = false) must satisfy every requirement" + } +} + +run "new_vpc_internal_alb" { + command = plan + + variables { + create_new_vpc = true + internal = true + existing_vpc_id = null + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + assert { + condition = !strcontains(output.configuration_error, "❌") + error_message = "A valid new-VPC config (internal = true) must satisfy every requirement" + } +} + +# --- Valid: use an existing VPC ---------------------------------------------- + +run "existing_vpc_external_alb" { + command = plan + + variables { + create_new_vpc = false + internal = false + existing_vpc_id = "vpc-00000000000000000" + existing_api_endpoint = null + existing_intra_subnets = ["subnet-intra-a", "subnet-intra-b"] + existing_private_subnets = ["subnet-priv-a", "subnet-priv-b"] + existing_public_subnets = ["subnet-pub-a", "subnet-pub-b"] + existing_user_security_group = "sg-00000000000000000" + existing_user_subnets = null + } + + assert { + condition = strcontains(output.configuration_error, "use an existing VPC") + error_message = "Existing-VPC config must be checked against the existing-network requirements" + } + + assert { + condition = !strcontains(output.configuration_error, "❌") + error_message = "A valid existing-VPC config (internal = false) must satisfy every requirement" + } + + assert { + condition = output.vpc_id == "vpc-00000000000000000" + error_message = "Existing-VPC mode must surface the supplied existing_vpc_id" + } +} + +run "existing_vpc_internal_alb" { + command = plan + + variables { + create_new_vpc = false + internal = true + existing_vpc_id = "vpc-00000000000000000" + existing_api_endpoint = "vpce-00000000000000000" + existing_intra_subnets = ["subnet-intra-a", "subnet-intra-b"] + existing_private_subnets = ["subnet-priv-a", "subnet-priv-b"] + existing_public_subnets = null + existing_user_security_group = "sg-00000000000000000" + existing_user_subnets = ["subnet-user-a", "subnet-user-b"] + } + + assert { + condition = !strcontains(output.configuration_error, "❌") + error_message = "A valid existing-VPC config (internal = true) must satisfy every requirement" + } +} + +# --- Invalid: contradictory input must fail fast ----------------------------- + +run "new_vpc_with_existing_id_is_rejected" { + command = plan + + variables { + create_new_vpc = true + internal = false + existing_vpc_id = "vpc-00000000000000000" + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + # create_new_vpc = true while supplying an existing VPC id satisfies neither + # the new-network nor the existing-network requirement set. + expect_failures = [output.configuration_error] +} + +run "existing_vpc_missing_inputs_is_rejected" { + command = plan + + variables { + create_new_vpc = false + internal = false + existing_vpc_id = "vpc-00000000000000000" + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + # create_new_vpc = false without the required existing_* inputs is incomplete. + expect_failures = [output.configuration_error] +} From 88682d7fd87b148212e08ee34bafdb0ecabcdb98 Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Fri, 5 Jun 2026 19:15:33 +0500 Subject: [PATCH 2/4] Address review: pin test AWS provider, add new-VPC assertion - Pin the quilt smoke wrapper's AWS provider to ~> 6.0 for deterministic CI (the terraform-aws-modules/vpc ~> 6.0 module requires aws >= 6.28; the ~> 5.0 Copilot suggested would fail init). - Assert new_vpc_internal_alb is checked against the new-network requirement set, matching new_vpc_external_alb, so a regression that evaluated the wrong set can't pass silently. Co-Authored-By: Claude Opus 4.8 --- modules/quilt/tests/smoke/main.tf | 5 +++++ modules/vpc/tests/validation.tftest.hcl | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/modules/quilt/tests/smoke/main.tf b/modules/quilt/tests/smoke/main.tf index f999d49..d76e572 100644 --- a/modules/quilt/tests/smoke/main.tf +++ b/modules/quilt/tests/smoke/main.tf @@ -14,6 +14,11 @@ terraform { required_providers { aws = { source = "hashicorp/aws" + # Match what the modules under test transitively require: the + # terraform-aws-modules/vpc ~> 6.0 module needs aws >= 6.28. Pinning keeps + # CI deterministic and off a future major. (Note: examples/main.tf and + # modules/cnames still pin ~> 5.0, which is incompatible with that floor.) + version = "~> 6.0" } } } diff --git a/modules/vpc/tests/validation.tftest.hcl b/modules/vpc/tests/validation.tftest.hcl index 3eb1af8..4af57d4 100644 --- a/modules/vpc/tests/validation.tftest.hcl +++ b/modules/vpc/tests/validation.tftest.hcl @@ -67,6 +67,11 @@ run "new_vpc_internal_alb" { existing_user_subnets = null } + assert { + condition = strcontains(output.configuration_error, "create a new VPC") + error_message = "New-VPC config must be checked against the new-network requirements" + } + assert { condition = !strcontains(output.configuration_error, "❌") error_message = "A valid new-VPC config (internal = true) must satisfy every requirement" From ee640b43f92af77de14e1f86afe0232ebd5b0242 Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Fri, 5 Jun 2026 19:20:18 +0500 Subject: [PATCH 3/4] Address review: README Test entry, wrapper cautions, internal smoke run - README cheat sheet: add a Test entry documenting the plan-only/mocked, credential-free workflow and the Terraform >= 1.7 requirement. - quilt smoke wrapper: comment that new quilt inputs must be threaded through (or coverage silently narrows) and that the root must re-expose only the non-sensitive stack name. - Add an internal = true new-VPC smoke run, exercising the PublicSubnets/ UserSubnets coalescing and the internal-gated api endpoint. Co-Authored-By: Claude Opus 4.8 --- README.md | 12 ++++++++++++ modules/quilt/tests/smoke/main.tf | 6 ++++++ modules/quilt/tests/smoke/smoke.tftest.hcl | 15 +++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 74245b9..6cd7d3f 100644 --- a/README.md +++ b/README.md @@ -917,6 +917,18 @@ terraform fmt terraform validate ``` +## Test + +Module tests are plan-only and mock the AWS provider, so they need no AWS +credentials and create no infrastructure. Requires Terraform >= 1.7 (for +`mock_provider`). Run from a module or test-wrapper directory, e.g. +`modules/vpc` or `modules/quilt/tests/smoke`: + +``` +terraform init -backend=false +terraform test +``` + ## Plan ``` terraform plan -out tfplan diff --git a/modules/quilt/tests/smoke/main.tf b/modules/quilt/tests/smoke/main.tf index d76e572..6c78a78 100644 --- a/modules/quilt/tests/smoke/main.tf +++ b/modules/quilt/tests/smoke/main.tf @@ -57,6 +57,8 @@ variable "user_security_group" { default = null } +# New inputs added to the quilt module must be threaded through here, or the +# smoke coverage silently narrows (the new input is never exercised). module "quilt" { source = "../../" @@ -73,6 +75,10 @@ module "quilt" { user_security_group = var.user_security_group } +# Re-expose ONLY the non-sensitive stack name. Do not output module.quilt.stack +# (it embeds the DB URL + admin password) or any *_password value — a sensitive +# root output makes `terraform test` fail, which is the whole reason this +# wrapper exists. output "stack_name" { value = module.quilt.stack.name } diff --git a/modules/quilt/tests/smoke/smoke.tftest.hcl b/modules/quilt/tests/smoke/smoke.tftest.hcl index 429ff1b..65d8a37 100644 --- a/modules/quilt/tests/smoke/smoke.tftest.hcl +++ b/modules/quilt/tests/smoke/smoke.tftest.hcl @@ -30,6 +30,21 @@ run "new_vpc_plans" { } } +run "new_vpc_internal_plans" { + command = plan + variables { + create_new_vpc = true + internal = true + } + # internal = true exercises the most conditional wiring in quilt/main.tf: + # the PublicSubnets/UserSubnets null-coalescing and the internal-gated api + # endpoint. + assert { + condition = output.stack_name == "quilt-test" + error_message = "The CloudFormation stack must be named after var.name" + } +} + run "existing_vpc_plans" { command = plan variables { From 7de946c06c6499af88840c79c93dd6671af01381 Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Mon, 8 Jun 2026 13:16:02 +0500 Subject: [PATCH 4/4] Address review: existing-VPC + internal-ALB coverage symmetry - Thread api_endpoint + user_subnets through the quilt smoke wrapper and add an existing-VPC + internal-ALB smoke run, which exercises quilt's pass-through of those inputs and the internal-gated CloudFormation wiring. - Make existing_vpc_internal_alb symmetric with existing_vpc_external_alb: assert the existing-network requirement set was evaluated and that vpc_id is surfaced unchanged. Co-Authored-By: Claude Opus 4.8 --- modules/quilt/tests/smoke/main.tf | 12 ++++++++++++ modules/quilt/tests/smoke/smoke.tftest.hcl | 21 +++++++++++++++++++++ modules/vpc/tests/validation.tftest.hcl | 10 ++++++++++ 3 files changed, 43 insertions(+) diff --git a/modules/quilt/tests/smoke/main.tf b/modules/quilt/tests/smoke/main.tf index 6c78a78..3d9ad09 100644 --- a/modules/quilt/tests/smoke/main.tf +++ b/modules/quilt/tests/smoke/main.tf @@ -37,6 +37,11 @@ variable "vpc_id" { default = null } +variable "api_endpoint" { + type = string + default = null +} + variable "intra_subnets" { type = list(string) default = null @@ -57,6 +62,11 @@ variable "user_security_group" { default = null } +variable "user_subnets" { + type = list(string) + default = null +} + # New inputs added to the quilt module must be threaded through here, or the # smoke coverage silently narrows (the new input is never exercised). module "quilt" { @@ -69,10 +79,12 @@ module "quilt" { create_new_vpc = var.create_new_vpc internal = var.internal vpc_id = var.vpc_id + api_endpoint = var.api_endpoint intra_subnets = var.intra_subnets private_subnets = var.private_subnets public_subnets = var.public_subnets user_security_group = var.user_security_group + user_subnets = var.user_subnets } # Re-expose ONLY the non-sensitive stack name. Do not output module.quilt.stack diff --git a/modules/quilt/tests/smoke/smoke.tftest.hcl b/modules/quilt/tests/smoke/smoke.tftest.hcl index 65d8a37..01f4584 100644 --- a/modules/quilt/tests/smoke/smoke.tftest.hcl +++ b/modules/quilt/tests/smoke/smoke.tftest.hcl @@ -61,3 +61,24 @@ run "existing_vpc_plans" { error_message = "The CloudFormation stack must be named after var.name" } } + +run "existing_vpc_internal_plans" { + command = plan + variables { + create_new_vpc = false + internal = true + vpc_id = "vpc-00000000000000000" + api_endpoint = "vpce-00000000000000000" + intra_subnets = ["subnet-intra-a", "subnet-intra-b"] + private_subnets = ["subnet-priv-a", "subnet-priv-b"] + public_subnets = null + user_security_group = "sg-00000000000000000" + user_subnets = ["subnet-user-a", "subnet-user-b"] + } + # internal = true on the existing-VPC path exercises quilt's pass-through of + # api_endpoint + user_subnets and the internal-gated CFN wiring. + assert { + condition = output.stack_name == "quilt-test" + error_message = "The CloudFormation stack must be named after var.name" + } +} diff --git a/modules/vpc/tests/validation.tftest.hcl b/modules/vpc/tests/validation.tftest.hcl index 4af57d4..064bf0c 100644 --- a/modules/vpc/tests/validation.tftest.hcl +++ b/modules/vpc/tests/validation.tftest.hcl @@ -126,10 +126,20 @@ run "existing_vpc_internal_alb" { existing_user_subnets = ["subnet-user-a", "subnet-user-b"] } + assert { + condition = strcontains(output.configuration_error, "use an existing VPC") + error_message = "Existing-VPC config must be checked against the existing-network requirements" + } + assert { condition = !strcontains(output.configuration_error, "❌") error_message = "A valid existing-VPC config (internal = true) must satisfy every requirement" } + + assert { + condition = output.vpc_id == "vpc-00000000000000000" + error_message = "Existing-VPC mode must surface the supplied existing_vpc_id" + } } # --- Invalid: contradictory input must fail fast -----------------------------