diff --git a/.github/workflows/terraform-test.yml b/.github/workflows/terraform-test.yml index 8fea321..f45847c 100644 --- a/.github/workflows/terraform-test.yml +++ b/.github/workflows/terraform-test.yml @@ -95,5 +95,8 @@ jobs: - name: Validate Terraform root run: terraform -chdir="${{ matrix.working_directory }}" validate + - name: Run Terraform native tests + run: terraform -chdir="${{ matrix.working_directory }}" test + - name: Lint Terraform root run: tflint --chdir="${{ matrix.working_directory }}" diff --git a/README.md b/README.md index 18087ba..8d4b8d4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The MVP application source is present: a FastAPI daily-entry API and a warm Next - [Backend MVP](backend/README.md): email/password MVP auth, user-scoped daily-entry API, operational endpoints, PostgreSQL persistence, Alembic migration, tests, linting, and local configuration example. - [Frontend MVP](frontend/README.md): sign-in/sign-up UI, three-small-happy-things bedtime UI, private history calendar, backend API integration, operational endpoints, tests, linting, and local configuration example. - [Terraform foundation](infra/terraform/README.md): version constraints, remote state, and applied sandbox network, RDS data, ECR/ECS platform, edge, and service roots. -- [Terraform workflow controls](.github/workflows/terraform-plan.yml): backend-free PR validation plus manual target-selected plan, apply, and destroy controls. +- [Terraform workflow controls](.github/workflows/terraform-test.yml): backend-free PR format, validate, native test, and TFLint checks plus manual target-selected plan, apply, and destroy controls. - [Application CI](.github/workflows/application-ci.yml): changed-component backend and frontend linting, unit tests, and frontend build checks. - [Image publication](.github/workflows/image-publish.yml): changed-component `main` publication plus a main-only manual initial publication, Trivy gate, immutable ECR publication, and digest handoff artifact. - [Service bootstrap](.github/workflows/service-bootstrap.yml): manually creates one selected initial service only after repository-specific digest and immutable source-commit-tag verification. diff --git a/docs/cicd.md b/docs/cicd.md index 4217ce4..3a6ef82 100644 --- a/docs/cicd.md +++ b/docs/cicd.md @@ -10,11 +10,11 @@ Terraform apply is workflow-dispatch only: at dispatch it resolves and records t The implemented Terraform workflow set is intentionally separate: `terraform-test.yml`, `terraform-plan.yml`, `terraform-apply.yml`, and `terraform-destroy.yml`. They have different triggers and IAM permissions; no reusable `_terraform-operation.yml` workflow or composite Terraform-init action is part of the baseline. The applied roots are `foundations/network`, `foundations/platform`, `foundations/edge`, `stacks/data`, `stacks/backend-service`, and `stacks/frontend-service`. -`terraform-test.yml` runs backend-free format, validate and TFLint checks for pull requests that change Terraform or Terraform workflow files. It detects the affected canonical roots and validates only implemented roots that changed. A shared module or Terraform workflow change conservatively validates every implemented root. `terraform-plan.yml`, `terraform-apply.yml`, and `terraform-destroy.yml` are separate `workflow_dispatch` controls. Each accepts a canonical `target`, resolves and logs the immutable `origin/main` SHA at dispatch, then resolves the target to its fixed root directory. Plan uses `environment: sandbox` and the read-only `happy-post-sandbox-terraform-plan` role, creates a plan, and uploads its rendered artifact. Apply uses the separate `happy-post-sandbox-terraform-apply` role, creates a fresh exact plan, and applies it. Apply confirmation is `apply-`; destroy confirmation is `destroy-`. A target that is defined by the workflow but absent from the resolved commit fails before credentials are configured. Pull requests, including forks, never receive AWS credentials. The current implemented targets are `network`, `data`, `platform`, `edge`, `backend-service`, and `frontend-service`. +`terraform-test.yml` runs backend-free format, validate, native Terraform tests, and TFLint checks for pull requests that change Terraform or Terraform workflow files. It detects the affected canonical roots and checks only implemented roots that changed. A shared module or Terraform workflow change conservatively checks every implemented root. Native Terraform tests use mock providers and remote-state overrides where needed, so they validate static configuration, naming, variable validation, digest constraints, and security assumptions without creating AWS resources. Terraform tests do not report coverage to SonarQube; SonarQube coverage is limited to backend and frontend application tests. `terraform-plan.yml`, `terraform-apply.yml`, and `terraform-destroy.yml` are separate `workflow_dispatch` controls. Each accepts a canonical `target`, resolves and logs the immutable `origin/main` SHA at dispatch, then resolves the target to its fixed root directory. Plan uses `environment: sandbox` and the read-only `happy-post-sandbox-terraform-plan` role, creates a plan, and uploads its rendered artifact. Apply uses the separate `happy-post-sandbox-terraform-apply` role, creates a fresh exact plan, and applies it. Apply confirmation is `apply-`; destroy confirmation is `destroy-`. A target that is defined by the workflow but absent from the resolved commit fails before credentials are configured. Pull requests, including forks, never receive AWS credentials. The current implemented targets are `network`, `data`, `platform`, `edge`, `backend-service`, and `frontend-service`. `application-ci.yml` is separate from Terraform controls and uses no AWS credentials. It detects changed backend and frontend paths in every pull request. A backend change runs locked dependency installation, Ruff, and Pytest; a frontend change runs locked dependency installation, ESLint, Vitest, and the Next.js production build. A Compose or application-CI workflow change runs both component jobs. -`security-ci.yml` is independent of application tests and deployment. Trivy scans every pull request and `main` push for secrets, HIGH/CRITICAL dependency vulnerabilities, and HIGH/CRITICAL IaC misconfigurations. Snyk and SonarQube Cloud run only for same-repository pull requests and `main` pushes because they require repository secrets; fork pull requests never receive those secrets. Snyk blocks HIGH/CRITICAL dependency and IaC findings. SonarQube Cloud scans the combined backend/frontend project and waits for its quality gate. +`security-ci.yml` is independent of application tests and deployment. Trivy scans every pull request and `main` push for secrets, HIGH/CRITICAL dependency vulnerabilities, and HIGH/CRITICAL IaC misconfigurations. Snyk and SonarQube Cloud run only for same-repository pull requests and `main` pushes because they require repository secrets; fork pull requests never receive those secrets. Snyk blocks HIGH/CRITICAL dependency and IaC findings. SonarQube Cloud scans the combined backend/frontend project, consumes only backend/frontend application coverage reports, and waits for its quality gate. `image-publish.yml` runs after a `main` push that changes `backend/**` or `frontend/**`, and can be manually dispatched from `main` to publish `backend`, `frontend`, or `all` for initial publication. It builds each selected image locally, blocks HIGH/CRITICAL Trivy image findings before AWS credentials are assumed, pushes only that component's immutable ECR repository, then uploads a seven-day artifact containing ECR's confirmed SHA-256 digest and source commit. `service-bootstrap.yml` is a separate `workflow_dispatch` control. It accepts `component`, `image_digest`, `source_commit`, and `bootstrap-` confirmation, resolves immutable `main`, rejects malformed/all-zero digests and malformed source SHAs, verifies the digest exists in the selected repository with its matching immutable `sha-` tag, and applies only the selected service root through the sandbox Terraform-apply role. It is initial-service-only. diff --git a/docs/implementation-backlog.md b/docs/implementation-backlog.md index 3661735..0918599 100644 --- a/docs/implementation-backlog.md +++ b/docs/implementation-backlog.md @@ -44,7 +44,7 @@ The documentation baseline and application MVPs are complete. The remaining work ## P5 — CI/CD and operational validation -- [x] Implement changed-root backend-free Terraform format/validate/TFLint on pull requests, plus workflow-dispatch-only plan/apply/destroy controls with a fixed canonical-root target allow-list. +- [x] Implement changed-root backend-free Terraform format, validate, native test, and TFLint checks on pull requests, plus workflow-dispatch-only plan/apply/destroy controls with a fixed canonical-root target allow-list. - [x] Implement changed-component backend and frontend CI checks: locked dependencies, linting, unit tests, and frontend production build, without AWS credentials. - [x] Implement Trivy, Snyk, and SonarQube Cloud security scanning with explicit scanner gates and fork-safe secret boundaries. - [x] Remediate the Starlette HIGH findings and record the two approved, time-bound Trivy infrastructure exceptions for baseline sandbox trade-offs. @@ -64,8 +64,8 @@ The documentation baseline and application MVPs are complete. The remaining work - [x] Update local Compose to run PostgreSQL and apply migrations before backend startup. - [x] Add frontend loading, validation, success, error, and simple monthly history states. - [x] Publish new immutable frontend and backend images from merged P6 `main`. -- [ ] Merge the controlled database migration workflow, update the CloudFormation bootstrap stack, then run `alembic upgrade head` as a one-off private ECS task from the verified backend image digest. -- [ ] Deploy the P6 backend, deploy the P6 frontend, then verify entry persistence through RDS. +- [x] Merge the controlled database migration workflow, update the CloudFormation bootstrap stack, then run `alembic upgrade head` as a one-off private ECS task from the verified backend image digest. +- [x] Deploy the P6 backend, deploy the P6 frontend, then verify entry persistence through RDS. - [ ] Re-run the restart persistence demo: create an entry, restart backend, and confirm the happy things remain. - [ ] For real users, replace MVP app-level auth with managed identity such as Amazon Cognito, Auth0, or Clerk. diff --git a/docs/security-decisions.md b/docs/security-decisions.md index 839b6fd..e0f5626 100644 --- a/docs/security-decisions.md +++ b/docs/security-decisions.md @@ -10,7 +10,7 @@ Security CI has no AWS credentials or GitHub environment access. Trivy scans sec One GitHub environment does not mean one AWS role. Separate IAM roles preserve operational boundaries for Terraform planning, Terraform apply/destroy, image publishing, and ECS deployment. The plan role is restricted to the approved repository's `pull_request` claim; Terraform apply/destroy and ECS deployment roles are restricted to its `environment:sandbox` claim; the ECR publish role is restricted to its `ref:refs/heads/main` claim. The Terraform apply role has `ecr:DescribeImages` only for the two Happy Post repositories so the manual service-bootstrap workflow can confirm a supplied digest belongs to the selected repository; it has no ECR image-push permission. The ECS deployment role can read image metadata only from those two repositories, pass only the four named Happy Post task/execution roles to `ecs-tasks.amazonaws.com`, register task definitions, update only the two named Happy Post services, and run only the backend migration task-definition family for controlled database migrations. `ecs:DescribeTaskDefinition` has its own `Resource: "*"` statement because AWS does not support resource-level scope for that read action; task-definition tag reads remain limited to the Happy Post task-definition ARN prefix. -The Terraform test workflow maps changed files to a fixed allow-list of canonical roots and runs backend-free validation only; a shared module or Terraform workflow change validates every implemented root. The manual plan workflow uses `happy-post-sandbox-terraform-plan` only from `environment:sandbox`, resolves and logs immutable `origin/main`, and accepts a fixed canonical-root `target` mapped to a fixed directory. Apply and destroy use the separate `happy-post-sandbox-terraform-apply` role from the same environment; apply creates and applies an exact fresh plan in one job. A target missing from the resolved commit fails before credentials are configured. The workflow source contains role ARNs, which are non-secret account configuration; it contains no AWS access keys or secret values. +The Terraform test workflow maps changed files to a fixed allow-list of canonical roots and runs backend-free validation only; a shared module or Terraform workflow change validates every implemented root. Backend-free validation includes native Terraform tests that use mock providers and remote-state overrides where needed, so pull requests can check static naming, variable validation, digest constraints, private-network assumptions, and least-privilege boundaries without AWS credentials or resource creation. The manual plan workflow uses `happy-post-sandbox-terraform-plan` only from `environment:sandbox`, resolves and logs immutable `origin/main`, and accepts a fixed canonical-root `target` mapped to a fixed directory. Apply and destroy use the separate `happy-post-sandbox-terraform-apply` role from the same environment; apply creates and applies an exact fresh plan in one job. A target missing from the resolved commit fails before credentials are configured. The workflow source contains role ARNs, which are non-secret account configuration; it contains no AWS access keys or secret values. ## Terraform state-backend protection diff --git a/infra/terraform/README.md b/infra/terraform/README.md index c70d652..58725e6 100644 --- a/infra/terraform/README.md +++ b/infra/terraform/README.md @@ -91,18 +91,31 @@ From the repository root, run backend-free validation first: terraform -chdir=infra/terraform/foundations/network init -backend=false terraform fmt -check -recursive infra/terraform terraform -chdir=infra/terraform/foundations/network validate +terraform -chdir=infra/terraform/foundations/network test terraform -chdir=infra/terraform/stacks/data init -backend=false terraform -chdir=infra/terraform/stacks/data validate +terraform -chdir=infra/terraform/stacks/data test terraform -chdir=infra/terraform/foundations/platform init -backend=false terraform -chdir=infra/terraform/foundations/platform validate +terraform -chdir=infra/terraform/foundations/platform test terraform -chdir=infra/terraform/foundations/edge init -backend=false terraform -chdir=infra/terraform/foundations/edge validate +terraform -chdir=infra/terraform/foundations/edge test terraform -chdir=infra/terraform/stacks/backend-service init -backend=false terraform -chdir=infra/terraform/stacks/backend-service validate +terraform -chdir=infra/terraform/stacks/backend-service test terraform -chdir=infra/terraform/stacks/frontend-service init -backend=false terraform -chdir=infra/terraform/stacks/frontend-service validate +terraform -chdir=infra/terraform/stacks/frontend-service test ``` +Native Terraform tests are backend-free and use provider mocks plus +remote-state overrides where needed. They check static configuration, variable +validation, naming, digest constraints, private-network assumptions, and +least-privilege boundaries without creating AWS resources. They are not +application coverage inputs; SonarQube coverage applies only to backend and +frontend application tests. + For an authorised read-only review of the real state and AWS configuration, use the approved short-lived AWS profile and initialise the declared backend: diff --git a/infra/terraform/foundations/edge/edge.tftest.hcl b/infra/terraform/foundations/edge/edge.tftest.hcl new file mode 100644 index 0000000..0047643 --- /dev/null +++ b/infra/terraform/foundations/edge/edge.tftest.hcl @@ -0,0 +1,54 @@ +mock_provider "aws" {} + +override_data { + target = data.terraform_remote_state.network + + values = { + outputs = { + vpc_id = "vpc-0123456789abcdef0" + public_subnet_ids = { + "0" = "subnet-0123456789abcdef0" + "1" = "subnet-0123456789abcdef1" + } + security_group_ids = { + alb = "sg-0123456789abcdef0" + } + } + } +} + +run "edge_rejects_non_sandbox_environment" { + command = plan + + variables { + environment = "production" + } + + expect_failures = [ + var.environment, + ] +} + +run "edge_rejects_unapproved_application_domain" { + command = plan + + variables { + application_domain = "example.com" + } + + expect_failures = [ + var.application_domain, + ] +} + +run "edge_rejects_unapproved_hosted_zone_id" { + command = plan + + variables { + route53_hosted_zone_id = "Z11111111111111111111" + } + + expect_failures = [ + var.route53_hosted_zone_id, + ] +} diff --git a/infra/terraform/foundations/network/network.tftest.hcl b/infra/terraform/foundations/network/network.tftest.hcl new file mode 100644 index 0000000..baf9549 --- /dev/null +++ b/infra/terraform/foundations/network/network.tftest.hcl @@ -0,0 +1,77 @@ +mock_provider "aws" { + mock_data "aws_availability_zones" { + defaults = { + names = [ + "ap-southeast-2a", + "ap-southeast-2b", + "ap-southeast-2c", + ] + } + } +} + +run "network_static_security_boundaries" { + command = apply + + assert { + condition = aws_vpc.this.cidr_block == "10.42.0.0/16" + error_message = "The sandbox VPC must keep the approved 10.42.0.0/16 CIDR." + } + + assert { + condition = aws_vpc.this.enable_dns_hostnames && aws_vpc.this.enable_dns_support + error_message = "The sandbox VPC must support DNS for ECS, ALB, and RDS integration." + } + + assert { + condition = length(aws_subnet.public) == 2 && length(aws_subnet.application) == 2 && length(aws_subnet.database) == 2 + error_message = "Public, private application, and private database subnet tiers must each span two Availability Zones." + } + + assert { + condition = aws_subnet.public["0"].map_public_ip_on_launch == false && aws_subnet.public["1"].map_public_ip_on_launch == false + error_message = "Public subnets must not auto-assign public IPs to launched resources." + } + + assert { + condition = length([for route in aws_route_table.application.route : route if route.cidr_block == "0.0.0.0/0" && route.nat_gateway_id == aws_nat_gateway.this.id]) == 1 + error_message = "Private application subnets must use the single sandbox NAT Gateway for outbound access." + } + + assert { + condition = length(aws_route_table.database.route) == 0 + error_message = "Private database subnets must not have internet or NAT routes." + } + + assert { + condition = aws_vpc_security_group_ingress_rule.alb_https.from_port == 443 && aws_vpc_security_group_ingress_rule.alb_https.cidr_ipv4 == "0.0.0.0/0" + error_message = "The ALB security group must allow public HTTPS ingress." + } + + assert { + condition = aws_vpc_security_group_ingress_rule.alb_http.from_port == 80 && aws_vpc_security_group_ingress_rule.alb_http.cidr_ipv4 == "0.0.0.0/0" + error_message = "The ALB security group must allow public HTTP ingress only for HTTP-to-HTTPS redirect." + } + + assert { + condition = aws_vpc_security_group_ingress_rule.backend_from_alb.referenced_security_group_id == aws_security_group.alb.id && aws_vpc_security_group_ingress_rule.backend_from_alb.from_port == 8000 + error_message = "Backend service ingress must come only from the ALB security group on the backend container port." + } + + assert { + condition = aws_vpc_security_group_egress_rule.backend_database.referenced_security_group_id == aws_security_group.database.id && aws_vpc_security_group_egress_rule.backend_database.from_port == 5432 + error_message = "Backend egress to PostgreSQL must target only the database security group on TCP 5432." + } +} + +run "network_rejects_non_sandbox_environment" { + command = plan + + variables { + environment = "production" + } + + expect_failures = [ + var.environment, + ] +} diff --git a/infra/terraform/foundations/platform/platform.tftest.hcl b/infra/terraform/foundations/platform/platform.tftest.hcl new file mode 100644 index 0000000..11c9218 --- /dev/null +++ b/infra/terraform/foundations/platform/platform.tftest.hcl @@ -0,0 +1,74 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "893794041695" + } + } + + mock_data "aws_partition" { + defaults = { + partition = "aws" + } + } +} + +run "platform_static_runtime_controls" { + command = apply + + assert { + condition = aws_ecr_repository.frontend.name == "happy-post-sandbox-frontend" && aws_ecr_repository.backend.name == "happy-post-sandbox-backend" + error_message = "Frontend and backend must use the approved sandbox ECR repository names." + } + + assert { + condition = aws_ecr_repository.frontend.image_tag_mutability == "IMMUTABLE" && aws_ecr_repository.backend.image_tag_mutability == "IMMUTABLE" + error_message = "Both ECR repositories must enforce immutable image tags." + } + + assert { + condition = aws_ecr_repository.frontend.image_scanning_configuration[0].scan_on_push && aws_ecr_repository.backend.image_scanning_configuration[0].scan_on_push + error_message = "Both ECR repositories must keep scan-on-push enabled." + } + + assert { + condition = aws_ecs_cluster.this.name == "happy-post-sandbox-cluster" && length([for setting in aws_ecs_cluster.this.setting : setting if setting.name == "containerInsights" && setting.value == "enabled"]) == 1 + error_message = "The platform foundation must create the approved ECS cluster with Container Insights enabled." + } + + assert { + condition = aws_cloudwatch_log_group.frontend.retention_in_days == 14 && aws_cloudwatch_log_group.backend.retention_in_days == 14 + error_message = "Frontend and backend log groups must retain logs for fourteen days in sandbox." + } + + assert { + condition = aws_iam_role.frontend_task.permissions_boundary == "arn:aws:iam::893794041695:policy/happy-post-sandbox-permissions-boundary" && aws_iam_role.backend_task.permissions_boundary == "arn:aws:iam::893794041695:policy/happy-post-sandbox-permissions-boundary" + error_message = "Runtime task roles must attach the bootstrap-managed permissions boundary." + } + + assert { + condition = aws_iam_role.frontend_execution.permissions_boundary == "arn:aws:iam::893794041695:policy/happy-post-sandbox-permissions-boundary" && aws_iam_role.backend_execution.permissions_boundary == "arn:aws:iam::893794041695:policy/happy-post-sandbox-permissions-boundary" + error_message = "Runtime execution roles must attach the bootstrap-managed permissions boundary." + } + + assert { + condition = strcontains(aws_iam_role_policy.frontend_execution.policy, "PullFrontendImage") && strcontains(aws_iam_role_policy.frontend_execution.policy, aws_ecr_repository.frontend.arn) && !strcontains(aws_iam_role_policy.frontend_execution.policy, aws_ecr_repository.backend.arn) + error_message = "The frontend execution policy must pull only the frontend ECR repository." + } + + assert { + condition = strcontains(aws_iam_role_policy.backend_execution.policy, "PullBackendImage") && strcontains(aws_iam_role_policy.backend_execution.policy, aws_ecr_repository.backend.arn) && !strcontains(aws_iam_role_policy.backend_execution.policy, aws_ecr_repository.frontend.arn) + error_message = "The backend execution policy must pull only the backend ECR repository." + } +} + +run "platform_rejects_non_sandbox_environment" { + command = plan + + variables { + environment = "production" + } + + expect_failures = [ + var.environment, + ] +} diff --git a/infra/terraform/stacks/backend-service/backend-service.tftest.hcl b/infra/terraform/stacks/backend-service/backend-service.tftest.hcl new file mode 100644 index 0000000..6a0edb9 --- /dev/null +++ b/infra/terraform/stacks/backend-service/backend-service.tftest.hcl @@ -0,0 +1,106 @@ +mock_provider "aws" { + mock_data "aws_iam_role" { + defaults = { + name = "happy-post-sandbox-backend-execution-role" + arn = "arn:aws:iam::893794041695:role/happy-post-sandbox-backend-execution-role" + } + } +} + +override_data { + target = data.terraform_remote_state.network + + values = { + outputs = { + application_subnet_ids = { + "0" = "subnet-0123456789abcdef0" + "1" = "subnet-0123456789abcdef1" + } + security_group_ids = { + backend = "sg-0123456789abcdef0" + } + } + } +} + +override_data { + target = data.terraform_remote_state.platform + + values = { + outputs = { + backend_repository = { + repository_url = "893794041695.dkr.ecr.ap-southeast-2.amazonaws.com/happy-post-sandbox-backend" + } + backend_execution_role_arn = "arn:aws:iam::893794041695:role/happy-post-sandbox-backend-execution-role" + backend_task_role_arn = "arn:aws:iam::893794041695:role/happy-post-sandbox-backend-task-role" + backend_log_group_name = "/ecs/happy-post/backend" + ecs_cluster_arn = "arn:aws:ecs:ap-southeast-2:893794041695:cluster/happy-post-sandbox-cluster" + ecs_cluster_name = "happy-post-sandbox-cluster" + } + } +} + +override_data { + target = data.terraform_remote_state.data + + values = { + outputs = { + database_secret_arn = "arn:aws:secretsmanager:ap-southeast-2:893794041695:secret:happy-post-sandbox-database-credentials" + } + } +} + +override_data { + target = data.terraform_remote_state.edge + + values = { + outputs = { + backend_target_group_arn = "arn:aws:elasticloadbalancing:ap-southeast-2:893794041695:targetgroup/happy-post-sandbox-backend/0123456789abcdef" + } + } +} + +run "backend_service_static_deployment_controls" { + command = apply + + variables { + backend_image_digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + + assert { + condition = aws_ecs_task_definition.backend.family == "happy-post-sandbox-backend" && aws_ecs_task_definition.backend.network_mode == "awsvpc" && contains(aws_ecs_task_definition.backend.requires_compatibilities, "FARGATE") + error_message = "The backend task definition must remain a sandbox Fargate awsvpc task." + } + + assert { + condition = jsondecode(aws_ecs_task_definition.backend.container_definitions)[0].image == "893794041695.dkr.ecr.ap-southeast-2.amazonaws.com/happy-post-sandbox-backend@sha256:1111111111111111111111111111111111111111111111111111111111111111" + error_message = "The backend task definition must use a digest-pinned ECR image, never a mutable tag." + } + + assert { + condition = jsondecode(aws_ecs_task_definition.backend.container_definitions)[0].secrets[0].name == "DATABASE_URL" && endswith(jsondecode(aws_ecs_task_definition.backend.container_definitions)[0].secrets[0].valueFrom, ":database_url::") + error_message = "The backend task definition must inject only the database_url key from Secrets Manager." + } + + assert { + condition = aws_ecs_service.backend.desired_count == 1 && aws_ecs_service.backend.network_configuration[0].assign_public_ip == false + error_message = "The backend ECS service must run privately with one desired sandbox task." + } + + assert { + condition = aws_appautoscaling_target.backend.min_capacity == 1 && aws_appautoscaling_target.backend.max_capacity == 2 && aws_appautoscaling_policy.backend_cpu.target_tracking_scaling_policy_configuration[0].target_value == 65 + error_message = "The backend service must keep the approved one-to-two task CPU target-tracking policy." + } +} + +run "backend_service_rejects_tagged_images" { + command = plan + + variables { + backend_image_digest = "latest" + } + + expect_failures = [ + var.backend_image_digest, + ] +} diff --git a/infra/terraform/stacks/data/data.tftest.hcl b/infra/terraform/stacks/data/data.tftest.hcl new file mode 100644 index 0000000..1fdd655 --- /dev/null +++ b/infra/terraform/stacks/data/data.tftest.hcl @@ -0,0 +1,68 @@ +mock_provider "aws" {} + +override_data { + target = data.terraform_remote_state.network + + values = { + outputs = { + database_subnet_ids = { + "0" = "subnet-0123456789abcdef0" + "1" = "subnet-0123456789abcdef1" + } + security_group_ids = { + database = "sg-0123456789abcdef0" + } + } + } +} + +run "data_stack_static_database_controls" { + command = apply + + assert { + condition = aws_db_subnet_group.this.name == "happy-post-sandbox-database" && length(aws_db_subnet_group.this.subnet_ids) == 2 + error_message = "The data stack must create the approved private database subnet group across two database subnets." + } + + assert { + condition = aws_db_instance.this.engine == "postgres" && aws_db_instance.this.engine_version == "16.14" && aws_db_instance.this.instance_class == "db.t4g.micro" + error_message = "The sandbox database must stay on the approved PostgreSQL 16.14 db.t4g.micro configuration." + } + + assert { + condition = aws_db_instance.this.publicly_accessible == false && aws_db_instance.this.multi_az == false + error_message = "The sandbox database must remain private and Single-AZ." + } + + assert { + condition = aws_db_instance.this.storage_type == "gp3" && aws_db_instance.this.allocated_storage == 20 && aws_db_instance.this.max_allocated_storage == 40 && aws_db_instance.this.storage_encrypted + error_message = "The sandbox database must keep encrypted gp3 storage with the approved 20-40 GiB range." + } + + assert { + condition = aws_db_instance.this.backup_retention_period == 1 && aws_db_instance.this.backup_window == "15:30-16:00" && aws_db_instance.this.maintenance_window == "sun:16:30-sun:17:00" + error_message = "The sandbox database must keep the approved one-day retention and documented windows." + } + + assert { + condition = aws_db_instance.this.skip_final_snapshot == false && aws_db_instance.this.deletion_protection == false && aws_db_instance.this.copy_tags_to_snapshot + error_message = "Destroy safety must keep final snapshots and copied tags while retaining sandbox deletion protection off." + } + + assert { + condition = aws_secretsmanager_secret.database_credentials.name == "happy-post-sandbox-database-credentials" && aws_secretsmanager_secret.database_credentials.recovery_window_in_days == 7 + error_message = "The database secret must keep the approved fixed name and seven-day recovery window." + } +} + +run "data_stack_rejects_non_sandbox_environment" { + command = plan + + variables { + environment = "production" + } + + expect_failures = [ + var.environment, + ] +} diff --git a/infra/terraform/stacks/frontend-service/frontend-service.tftest.hcl b/infra/terraform/stacks/frontend-service/frontend-service.tftest.hcl new file mode 100644 index 0000000..9b2dfe3 --- /dev/null +++ b/infra/terraform/stacks/frontend-service/frontend-service.tftest.hcl @@ -0,0 +1,89 @@ +mock_provider "aws" {} + +override_data { + target = data.terraform_remote_state.network + + values = { + outputs = { + application_subnet_ids = { + "0" = "subnet-0123456789abcdef0" + "1" = "subnet-0123456789abcdef1" + } + security_group_ids = { + frontend = "sg-0123456789abcdef0" + } + } + } +} + +override_data { + target = data.terraform_remote_state.platform + + values = { + outputs = { + frontend_repository = { + repository_url = "893794041695.dkr.ecr.ap-southeast-2.amazonaws.com/happy-post-sandbox-frontend" + } + frontend_execution_role_arn = "arn:aws:iam::893794041695:role/happy-post-sandbox-frontend-execution-role" + frontend_task_role_arn = "arn:aws:iam::893794041695:role/happy-post-sandbox-frontend-task-role" + frontend_log_group_name = "/ecs/happy-post/frontend" + ecs_cluster_arn = "arn:aws:ecs:ap-southeast-2:893794041695:cluster/happy-post-sandbox-cluster" + ecs_cluster_name = "happy-post-sandbox-cluster" + } + } +} + +override_data { + target = data.terraform_remote_state.edge + + values = { + outputs = { + frontend_target_group_arn = "arn:aws:elasticloadbalancing:ap-southeast-2:893794041695:targetgroup/happy-post-sandbox-frontend/0123456789abcdef" + } + } +} + +run "frontend_service_static_deployment_controls" { + command = apply + + variables { + frontend_image_digest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" + } + + assert { + condition = aws_ecs_task_definition.frontend.family == "happy-post-sandbox-frontend" && aws_ecs_task_definition.frontend.network_mode == "awsvpc" && contains(aws_ecs_task_definition.frontend.requires_compatibilities, "FARGATE") + error_message = "The frontend task definition must remain a sandbox Fargate awsvpc task." + } + + assert { + condition = jsondecode(aws_ecs_task_definition.frontend.container_definitions)[0].image == "893794041695.dkr.ecr.ap-southeast-2.amazonaws.com/happy-post-sandbox-frontend@sha256:2222222222222222222222222222222222222222222222222222222222222222" + error_message = "The frontend task definition must use a digest-pinned ECR image, never a mutable tag." + } + + assert { + condition = !can(jsondecode(aws_ecs_task_definition.frontend.container_definitions)[0].secrets) + error_message = "The frontend task definition must not receive Secrets Manager values." + } + + assert { + condition = aws_ecs_service.frontend.desired_count == 1 && aws_ecs_service.frontend.network_configuration[0].assign_public_ip == false + error_message = "The frontend ECS service must run privately with one desired sandbox task." + } + + assert { + condition = aws_appautoscaling_target.frontend.min_capacity == 1 && aws_appautoscaling_target.frontend.max_capacity == 2 && aws_appautoscaling_policy.frontend_cpu.target_tracking_scaling_policy_configuration[0].target_value == 65 + error_message = "The frontend service must keep the approved one-to-two task CPU target-tracking policy." + } +} + +run "frontend_service_rejects_tagged_images" { + command = plan + + variables { + frontend_image_digest = "latest" + } + + expect_failures = [ + var.frontend_image_digest, + ] +}