diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 45b688d7..390c7e6d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -71,14 +71,29 @@ jobs: role-to-assume: ${{ secrets[format('AWS_ROLE_{0}', matrix.environment)] }} aws-region: ${{ env.AWS_REGION }} + - name: Resolve backend config + id: backend + run: | + case "${{ matrix.environment }}" in + staging) echo "file=environments/staging/backend.hcl" >> $GITHUB_OUTPUT ;; + prod) echo "file=environments/production/backend.hcl" >> $GITHUB_OUTPUT ;; + *) echo "file=backend-config.hcl" >> $GITHUB_OUTPUT ;; + esac + - name: Terraform Init working-directory: infrastructure/terraform - run: terraform init + run: terraform init -backend-config="${{ steps.backend.outputs.file }}" + + - name: Terraform Validate + working-directory: infrastructure/terraform + run: terraform validate -var-file="environments/ci-validate.tfvars" - name: Terraform Plan working-directory: infrastructure/terraform run: | terraform plan \ + -lock=true \ + -lock-timeout=5m \ -var-file="environments/${{ matrix.environment }}.tfvars" \ -out=tfplan-${{ matrix.environment }} @@ -118,6 +133,15 @@ jobs: env: PROMETHEUS_URL: ${{ secrets.PROMETHEUS_URL }} + - name: Resolve backend config + id: backend + run: | + case "${{ matrix.environment }}" in + staging) echo "file=environments/staging/backend.hcl" >> $GITHUB_OUTPUT ;; + prod) echo "file=environments/production/backend.hcl" >> $GITHUB_OUTPUT ;; + *) echo "file=backend-config.hcl" >> $GITHUB_OUTPUT ;; + esac + - name: Download plan uses: actions/download-artifact@v4 with: @@ -126,11 +150,11 @@ jobs: - name: Terraform Init working-directory: infrastructure/terraform - run: terraform init + run: terraform init -backend-config="${{ steps.backend.outputs.file }}" - name: Terraform Apply working-directory: infrastructure/terraform - run: terraform apply -auto-approve tfplan-${{ matrix.environment }} + run: terraform apply -lock=true -lock-timeout=5m -auto-approve tfplan-${{ matrix.environment }} - name: Output infrastructure details working-directory: infrastructure/terraform diff --git a/frontend/src/components/LandingPage.tsx b/frontend/src/components/LandingPage.tsx index ffb46c12..ae40153c 100644 --- a/frontend/src/components/LandingPage.tsx +++ b/frontend/src/components/LandingPage.tsx @@ -40,10 +40,9 @@ export const LandingPage: React.FC = ({ className }) => { } }; - const handleKeyDown = (e: React.KeyboardEvent) => { - // Allow form submission with Enter key - if (e.key === 'Enter' && e.currentTarget.tagName === 'FORM') { - handleSubmit(e as unknown as React.FormEvent); + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.currentTarget.requestSubmit(); } }; diff --git a/frontend/src/components/__tests__/LandingPage.keyboard.test.tsx b/frontend/src/components/__tests__/LandingPage.keyboard.test.tsx new file mode 100644 index 00000000..e2826242 --- /dev/null +++ b/frontend/src/components/__tests__/LandingPage.keyboard.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import LandingPage from '../LandingPage'; + +describe('LandingPage handleKeyDown', () => { + it('submits the form when Enter is pressed on the form with a valid email', async () => { + render(); + const emailInput = screen.getByLabelText(/email address/i); + await userEvent.type(emailInput, 'test@example.com'); + + const form = emailInput.closest('form')!; + fireEvent.keyDown(form, { key: 'Enter', code: 'Enter' }); + + expect( + screen.getByRole('button', { name: /already subscribed/i }), + ).toBeInTheDocument(); + }); + + it('triggers validation when Enter is pressed on the form with empty email', () => { + render(); + const form = screen.getByLabelText(/email address/i).closest('form')!; + + fireEvent.keyDown(form, { key: 'Enter', code: 'Enter' }); + + expect(screen.getByRole('alert')).toHaveTextContent(/email is required/i); + }); + + it('does not trigger submission for non-Enter keys', async () => { + render(); + const emailInput = screen.getByLabelText(/email address/i); + await userEvent.type(emailInput, 'test@example.com'); + + const form = emailInput.closest('form')!; + fireEvent.keyDown(form, { key: 'a', code: 'KeyA' }); + + expect( + screen.queryByRole('button', { name: /already subscribed/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/infrastructure/terraform/environments/ci-validate.tfvars b/infrastructure/terraform/environments/ci-validate.tfvars new file mode 100644 index 00000000..65969cd5 --- /dev/null +++ b/infrastructure/terraform/environments/ci-validate.tfvars @@ -0,0 +1,21 @@ +# CI-only variable file used exclusively for `terraform validate`. +# Values are syntactically valid placeholders — never deployed. + +environment = "dev" +aws_region = "us-east-1" +vpc_cidr_block = "10.0.0.0/16" +db_name = "predictiq" +db_username = "predictiqadmin" +db_password = "CiValidate!Test#Placeholder2024XYZ" +db_instance_class = "db.t3.micro" +allocated_storage = 20 +backup_retention_days = 7 +redis_node_type = "cache.t3.micro" +redis_num_nodes = 1 +redis_engine_version = "7.0" +redis_auth_token = "CiValidate!Redis#Placeholder2024XYZ" +api_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/predictiq:ci-validate" +api_container_port = 8080 +api_desired_count = 1 +api_cpu = 256 +api_memory = 512 diff --git a/infrastructure/terraform/modules/redis/main.tf b/infrastructure/terraform/modules/redis/main.tf index b8518c71..62c753b9 100644 --- a/infrastructure/terraform/modules/redis/main.tf +++ b/infrastructure/terraform/modules/redis/main.tf @@ -96,9 +96,11 @@ resource "aws_elasticache_cluster" "main" { } output "endpoint" { - value = aws_elasticache_cluster.main.cache_nodes[0].address + value = aws_elasticache_cluster.main.cache_nodes[0].address + sensitive = true } output "redis_url" { - value = "redis://${aws_elasticache_cluster.main.cache_nodes[0].address}:6379" + value = "redis://${aws_elasticache_cluster.main.cache_nodes[0].address}:6379" + sensitive = true } diff --git a/infrastructure/terraform/outputs.tf b/infrastructure/terraform/outputs.tf index d3067211..f5a0db48 100644 --- a/infrastructure/terraform/outputs.tf +++ b/infrastructure/terraform/outputs.tf @@ -12,6 +12,7 @@ output "rds_endpoint" { output "redis_endpoint" { description = "Redis endpoint" value = module.redis.endpoint + sensitive = true } output "ecs_cluster_name" { diff --git a/infrastructure/terraform/variables.tf b/infrastructure/terraform/variables.tf index a568035c..a8e0ca8b 100644 --- a/infrastructure/terraform/variables.tf +++ b/infrastructure/terraform/variables.tf @@ -55,10 +55,16 @@ variable "db_password" { description = "Database master password" type = string sensitive = true - + validation { - condition = length(var.db_password) >= 8 - error_message = "Database password must be at least 8 characters long." + condition = ( + length(var.db_password) >= 24 && + can(regex("[A-Z]", var.db_password)) && + can(regex("[a-z]", var.db_password)) && + can(regex("[0-9]", var.db_password)) && + can(regex("[^a-zA-Z0-9]", var.db_password)) + ) + error_message = "Database password must be at least 24 characters and contain uppercase letters, lowercase letters, numbers, and special characters." } } @@ -128,6 +134,23 @@ variable "redis_engine_version" { } } +variable "redis_auth_token" { + description = "Auth token for Redis in-transit encryption" + type = string + sensitive = true + + validation { + condition = ( + length(var.redis_auth_token) >= 24 && + can(regex("[A-Z]", var.redis_auth_token)) && + can(regex("[a-z]", var.redis_auth_token)) && + can(regex("[0-9]", var.redis_auth_token)) && + can(regex("[^a-zA-Z0-9]", var.redis_auth_token)) + ) + error_message = "Redis auth token must be at least 24 characters and contain uppercase letters, lowercase letters, numbers, and special characters." + } +} + variable "api_image_uri" { description = "ECR image URI for API" type = string