diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 1f5fe5b..537362c 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -1,14 +1,16 @@ -name: Github Action - Code Quality +name: Code Quality + on: pull_request: branches: [main] types: [opened, reopened, synchronize] workflow_dispatch: + jobs: quality: name: Quality - # The quality pipeline should not take more that 2 minutes. - timeout-minutes: 2 + # The quality pipeline should not take more than 10 minutes. + timeout-minutes: 10 runs-on: ubuntu-latest steps: - name: Checkout code @@ -18,15 +20,51 @@ jobs: - name: Get changed markdown files id: changed-markdown - uses: tj-actions/changed-files@v41 + uses: tj-actions/changed-files@v45 with: files: | **/*.md - .github/workflows/github-action-code-quality.yml + .github/workflows/code-quality.yml + + - name: Get changed action and test paths + id: changed-actions + uses: tj-actions/changed-files@v45 + with: + files: | + .github/actions/** + tests/** - - name: Lint all Documentation + - name: Lint all documentation if: steps.changed-markdown.outputs.any_modified == 'true' uses: DavidAnson/markdownlint-cli2-action@v14 with: globs: | **/*.md + + - name: Install bats + if: steps.changed-actions.outputs.any_changed == 'true' + run: sudo npm install -g bats + + - name: Test - promote-ecr-image + if: > + contains(steps.changed-actions.outputs.all_changed_files, '.github/actions/promote-ecr-image') || + contains(steps.changed-actions.outputs.all_changed_files, 'tests/unit/promote-ecr-image') + run: bats --verbose-run tests/unit/promote-ecr-image/ + + - name: Test - test-python + if: > + contains(steps.changed-actions.outputs.all_changed_files, '.github/actions/test-python') || + contains(steps.changed-actions.outputs.all_changed_files, 'tests/unit/test-python') + run: bats --verbose-run tests/unit/test-python/ + + - name: Test - update-aws-ecs + if: > + contains(steps.changed-actions.outputs.all_changed_files, '.github/actions/update-aws-ecs') || + contains(steps.changed-actions.outputs.all_changed_files, 'tests/unit/update-aws-ecs') + run: bats --verbose-run tests/unit/update-aws-ecs/ + + - name: Test - update-aws-lambda + if: > + contains(steps.changed-actions.outputs.all_changed_files, '.github/actions/update-aws-lambda') || + contains(steps.changed-actions.outputs.all_changed_files, 'tests/unit/update-aws-lambda') + run: bats --verbose-run tests/unit/update-aws-lambda/ diff --git a/README.md b/README.md index a01a8b7..64a31d2 100644 --- a/README.md +++ b/README.md @@ -25,4 +25,8 @@ This repo hold various accelerators for Github workflows (Actions) as well as re ### Linting and Formatting -See [LINTING.md](./LINTING.md) for more information. +See [documentation/LINTING.md](./documentation/LINTING.md) for more information. + +### Testing + +See [documentation/TESTING.md](./documentation/TESTING.md) for more information. diff --git a/LINTING.md b/documentation/LINTING.md similarity index 100% rename from LINTING.md rename to documentation/LINTING.md diff --git a/documentation/TESTING.md b/documentation/TESTING.md new file mode 100644 index 0000000..e4af132 --- /dev/null +++ b/documentation/TESTING.md @@ -0,0 +1,105 @@ +# Testing + +Unit tests for the shell scripts that power the GitHub Actions in this repository. + +## Framework + +Tests are written using [bats-core](https://github.com/bats-core/bats-core) — the Bash Automated Testing System. + +## Structure + +Tests are organised per-action, mirroring the mono-repo structure of the actions themselves. +Each action that contains testable shell scripts has a corresponding directory under `tests/unit/`. + +```text +tests/ +├── unit/ +│ ├── promote-ecr-image/ +│ │ ├── test_aws_unset.bats # Tests for the shared aws_unset.sh utility +│ │ ├── test_options_helpers.bats # Tests for the shared options_helpers.sh utility +│ │ └── test_promote_image.bats # Tests for promote_image.sh +│ ├── test-python/ +│ │ └── test_configure_pip.bats # Tests for configure_pip.sh +│ ├── update-aws-ecs/ +│ │ └── test_update_ecs.bats # Tests for update_ecs.sh +│ └── update-aws-lambda/ +│ └── test_update_lambda.bats # Tests for update_lambda.sh +└── helpers/ + └── mock_helpers.bash # Shared mock creation and assertion utilities +``` + +## What Is Tested + +| Action | Script | Tests | What's covered | +|--------|--------|-------|----------------| +| `promote-ecr-image` | `options_helpers.sh` | 15 | `has_argument()` and `extract_argument()` parsing logic | +| `promote-ecr-image` | `aws_unset.sh` | 7 | All 4 AWS credential env vars are cleared; no-op when already unset | +| `promote-ecr-image` | `promote_image.sh` | 13 | Every required env var validation (exits 1 for each missing var); `--help` | +| `test-python` | `configure_pip.sh` | 10 | Correct `pip config set` calls per env var; no-op when unset; `--help` | +| `update-aws-ecs` | `update_ecs.sh` | 8 | `--help`, `aws ecs update-service` invocation, `--force-new-deployment`, failure path | +| `update-aws-lambda` | `update_lambda.sh` | 7 | `--help`, `aws lambda update-function-code` invocation, failure path | + +### What Is NOT Tested Here + +- **Composite action YAML** — action `.yml` files use GitHub Actions expression syntax + (`${{ inputs.xxx }}`) that cannot run outside of a GitHub Actions runner. +- **Live AWS calls** — tests that require actual AWS credentials are integration tests + and must run in a real CI environment with OIDC or stored secrets. + +## Mocking Strategy + +External commands (`aws`, `pip`, `tput`) are replaced with lightweight mock binaries +that record every invocation to a log file (`$MOCK_DIR/_calls.log`). +Tests assert the correct arguments were passed without hitting real cloud APIs. + +`tests/helpers/mock_helpers.bash` provides shared utilities for creating mocks and +making assertions against them. + +## Running Locally + +### Install bats + +```sh +# via npm (recommended — matches the CI install) +npm install -g bats + +# via Homebrew +brew install bats-core +``` + +### Run all tests for a specific action + +```sh +bats tests/unit/update-aws-ecs/ +``` + +### Run tests for all actions + +```sh +for dir in tests/unit/*/; do bats --verbose-run "$dir"; done +``` + +### Run with verbose output + +```sh +bats --verbose-run tests/unit/promote-ecr-image/ +``` + +## CI + +The `code-quality.yml` workflow runs automatically on every PR to `main`. +It uses `tj-actions/changed-files` to detect which action directories have changed +and runs tests only for those actions — each in its own isolated job. + +## Writing New Tests + +1. Create `tests/unit//test_.bats`. +2. Set `REPO_ROOT` relative to `BATS_TEST_DIRNAME` — tests are three levels deep, + so use: `REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)"` +3. In `setup()`, create a `MOCK_DIR`, add mocks for any external commands, and prepend + `$MOCK_DIR` to `PATH` — subshells spawned by `run bash -c "..."` inherit the PATH + automatically, so do not re-export `PATH` inside the subshell. +4. Use `run bash -c "source '...script.sh'"` for tests that need to capture a non-zero + exit code from the script under test. + +See existing test files for patterns. diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..604ff32 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,88 @@ +# Tests + +Unit tests for the shell scripts that power the GitHub Actions in this repository. + +## Framework + +Tests are written using [bats-core](https://github.com/bats-core/bats-core) — the Bash Automated Testing System. + +## Structure + +Tests are organised per-action, mirroring the mono-repo structure of the actions themselves. +Each action that contains testable shell scripts has a corresponding directory under `tests/unit/`. + +```text +tests/ +├── unit/ +│ ├── promote-ecr-image/ +│ │ ├── test_aws_unset.bats # Tests for the shared aws_unset.sh utility +│ │ ├── test_options_helpers.bats # Tests for the shared options_helpers.sh utility +│ │ └── test_promote_image.bats # Tests for promote_image.sh +│ ├── test-python/ +│ │ └── test_configure_pip.bats # Tests for configure_pip.sh +│ ├── update-aws-ecs/ +│ │ └── test_update_ecs.bats # Tests for update_ecs.sh +│ └── update-aws-lambda/ +│ └── test_update_lambda.bats # Tests for update_lambda.sh +└── helpers/ + └── mock_helpers.bash # Shared mock creation and assertion utilities +``` + +## What Is Tested + +| Action | Script | Tests | +|--------|--------|-------| +| `promote-ecr-image` | `options_helpers.sh` | `has_argument()` and `extract_argument()` parsing logic | +| `promote-ecr-image` | `aws_unset.sh` | All four AWS credential env vars are cleared | +| `promote-ecr-image` | `promote_image.sh` | Env var validation (exits 1 for each missing required var) | +| `test-python` | `configure_pip.sh` | Correct `pip config set` calls for each env var; no-op when unset | +| `update-aws-ecs` | `update_ecs.sh` | AWS CLI invocation, `--force-new-deployment`, empty-response failure | +| `update-aws-lambda` | `update_lambda.sh` | AWS CLI invocation, function name + image URL propagation, failure | + +### What Is NOT Tested Here + +- **Composite action YAML** — action `.yml` files use GitHub Actions expression syntax + (`${{ inputs.xxx }}`) that cannot run outside of a GitHub Actions runner. +- **Live AWS calls** — tests that require actual AWS credentials are integration tests + and must run in a real CI environment with OIDC or stored secrets. + +## Running Locally + +### Install bats + +```bash +# via npm (recommended) +npm install -g bats + +# via Homebrew +brew install bats-core +``` + +### Run all tests for a specific action + +```bash +# From the repo root +bats tests/unit/update-aws-ecs/ +``` + +### Run tests for all actions + +```bash +for dir in tests/unit/*/; do bats --verbose-run "$dir"; done +``` + +### Run with verbose output + +```bash +bats --verbose-run tests/unit/promote-ecr-image/ +``` + +## Writing New Tests + +1. Create `tests/unit//test_.bats` +2. Set `REPO_ROOT` relative to `BATS_TEST_DIRNAME` — tests are three levels deep, + so use: `REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)"` +3. Mock external commands (`aws`, `docker`, `pip`) using `MOCK_DIR` in PATH +4. Use `run bash -c "..."` for tests that expect `exit 1` from the script under test + +See existing test files and `tests/helpers/mock_helpers.bash` for patterns. diff --git a/tests/helpers/mock_helpers.bash b/tests/helpers/mock_helpers.bash new file mode 100644 index 0000000..3f431fa --- /dev/null +++ b/tests/helpers/mock_helpers.bash @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# ============================================================================= +# mock_helpers.bash +# Shared helper utilities for bats unit tests. +# Provides mock command creation and assertion utilities. +# ============================================================================= + +# Set up a temporary directory and create mock executables for the given +# commands. Each mock records every call (arguments) to +# "$MOCK_DIR/_calls.log" and exits 0 by default. +# +# Usage: +# source tests/helpers/mock_helpers.bash +# setup_mocks pip tput aws docker +# +# After setup_mocks, $MOCK_DIR is in PATH, so the fakes shadow real binaries. +setup_mocks() { + MOCK_DIR="$(mktemp -d)" + export MOCK_DIR + + for cmd in "$@"; do + # Write the mock script + cat > "$MOCK_DIR/$cmd" << 'MOCK_SCRIPT' +#!/usr/bin/env bash +# Record the call +echo "$@" >> "${MOCK_DIR}/${0##*/}_calls.log" +exit 0 +MOCK_SCRIPT + + # Substitute the actual command name (heredoc can't expand $cmd) + sed -i.bak "s|\${0##\*/}|${cmd}|g" "$MOCK_DIR/$cmd" + rm -f "$MOCK_DIR/$cmd.bak" + chmod +x "$MOCK_DIR/$cmd" + done + + # Prepend mock dir to PATH so mocks shadow real binaries + export PATH="$MOCK_DIR:$PATH" +} + +# Remove the mock directory created by setup_mocks. +teardown_mocks() { + if [[ -n "${MOCK_DIR:-}" && -d "$MOCK_DIR" ]]; then + rm -rf "$MOCK_DIR" + fi +} + +# Assert that a mock was called with the given argument string. +# +# Usage: +# assert_mock_called_with "pip" "config set global.index-url https://example.com" +assert_mock_called_with() { + local cmd="$1" + local expected_args="$2" + local log_file="$MOCK_DIR/${cmd}_calls.log" + + if [[ ! -f "$log_file" ]]; then + echo "FAIL: mock '$cmd' was never called (no call log found)" >&2 + return 1 + fi + + if ! grep -qF "$expected_args" "$log_file"; then + echo "FAIL: mock '$cmd' was not called with args: $expected_args" >&2 + echo "Actual calls recorded in $log_file:" >&2 + cat "$log_file" >&2 + return 1 + fi + + return 0 +} + +# Assert that a mock was NOT called at all. +# +# Usage: +# assert_mock_not_called "pip" +assert_mock_not_called() { + local cmd="$1" + local log_file="$MOCK_DIR/${cmd}_calls.log" + + if [[ -f "$log_file" ]]; then + echo "FAIL: mock '$cmd' was called but should not have been" >&2 + echo "Calls recorded:" >&2 + cat "$log_file" >&2 + return 1 + fi + + return 0 +} + +# Return the call count for a given mock command. +mock_call_count() { + local cmd="$1" + local log_file="$MOCK_DIR/${cmd}_calls.log" + if [[ -f "$log_file" ]]; then + wc -l < "$log_file" | tr -d ' ' + else + echo "0" + fi +} diff --git a/tests/unit/promote-ecr-image/test_aws_unset.bats b/tests/unit/promote-ecr-image/test_aws_unset.bats new file mode 100644 index 0000000..3910526 --- /dev/null +++ b/tests/unit/promote-ecr-image/test_aws_unset.bats @@ -0,0 +1,87 @@ +#!/usr/bin/env bats +# ============================================================================= +# aws_unset.bats +# Unit tests for the aws_unset.sh helper script. +# +# This script is duplicated across three actions — all copies are identical. +# We test the promote-ecr-image version as the canonical copy. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" +AWS_UNSET_SCRIPT="$REPO_ROOT/.github/actions/promote-ecr-image/scripts/general/aws_unset.sh" + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +@test "aws_unset: unsets AWS_ACCESS_KEY_ID" { + export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE" + # shellcheck source=/dev/null + source "$AWS_UNSET_SCRIPT" + [ -z "${AWS_ACCESS_KEY_ID:-}" ] +} + +@test "aws_unset: unsets AWS_SECRET_ACCESS_KEY" { + export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + # shellcheck source=/dev/null + source "$AWS_UNSET_SCRIPT" + [ -z "${AWS_SECRET_ACCESS_KEY:-}" ] +} + +@test "aws_unset: unsets AWS_SESSION_TOKEN" { + export AWS_SESSION_TOKEN="AQoDYXdzEJr//example//session//token" + # shellcheck source=/dev/null + source "$AWS_UNSET_SCRIPT" + [ -z "${AWS_SESSION_TOKEN:-}" ] +} + +@test "aws_unset: unsets AWS_DEFAULT_REGION" { + export AWS_DEFAULT_REGION="us-east-1" + # shellcheck source=/dev/null + source "$AWS_UNSET_SCRIPT" + [ -z "${AWS_DEFAULT_REGION:-}" ] +} + +@test "aws_unset: is a no-op when vars are already unset" { + unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_DEFAULT_REGION + # Should not error + # shellcheck source=/dev/null + source "$AWS_UNSET_SCRIPT" + [ -z "${AWS_ACCESS_KEY_ID:-}" ] + [ -z "${AWS_SECRET_ACCESS_KEY:-}" ] + [ -z "${AWS_SESSION_TOKEN:-}" ] + [ -z "${AWS_DEFAULT_REGION:-}" ] +} + +@test "aws_unset: clears all four vars in one pass" { + export AWS_ACCESS_KEY_ID="key" + export AWS_SECRET_ACCESS_KEY="secret" + export AWS_SESSION_TOKEN="token" + export AWS_DEFAULT_REGION="eu-west-1" + + # shellcheck source=/dev/null + source "$AWS_UNSET_SCRIPT" + + [ -z "${AWS_ACCESS_KEY_ID:-}" ] + [ -z "${AWS_SECRET_ACCESS_KEY:-}" ] + [ -z "${AWS_SESSION_TOKEN:-}" ] + [ -z "${AWS_DEFAULT_REGION:-}" ] +} + +@test "aws_unset: identical copies across actions produce the same result" { + # Verify all three copies of aws_unset.sh produce equivalent results. + local scripts=( + "$REPO_ROOT/.github/actions/promote-ecr-image/scripts/general/aws_unset.sh" + "$REPO_ROOT/.github/actions/update-aws-ecs/scripts/general/aws_unset.sh" + "$REPO_ROOT/.github/actions/update-aws-lambda/scripts/general/aws_unset.sh" + ) + + for script in "${scripts[@]}"; do + export AWS_ACCESS_KEY_ID="key" + export AWS_SESSION_TOKEN="token" + # shellcheck source=/dev/null + source "$script" + [ -z "${AWS_ACCESS_KEY_ID:-}" ] + [ -z "${AWS_SESSION_TOKEN:-}" ] + done +} diff --git a/tests/unit/promote-ecr-image/test_options_helpers.bats b/tests/unit/promote-ecr-image/test_options_helpers.bats new file mode 100644 index 0000000..e3940a8 --- /dev/null +++ b/tests/unit/promote-ecr-image/test_options_helpers.bats @@ -0,0 +1,94 @@ +#!/usr/bin/env bats +# ============================================================================= +# options_helpers.bats +# Unit tests for the options_helpers.sh utility functions. +# +# These helpers are shared across multiple actions: +# - promote-ecr-image/scripts/general/options_helpers.sh +# - update-aws-ecs/scripts/general/options_helpers.sh +# - update-aws-lambda/scripts/general/options_helpers.sh +# +# All three copies are functionally identical; we test one authoritative copy. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" +HELPERS_SCRIPT="$REPO_ROOT/.github/actions/promote-ecr-image/scripts/general/options_helpers.sh" + +setup() { + # shellcheck source=/dev/null + source "$HELPERS_SCRIPT" +} + +# --------------------------------------------------------------------------- +# has_argument +# --------------------------------------------------------------------------- + +@test "has_argument: returns true for --flag=value format" { + has_argument "--flag=value" +} + +@test "has_argument: returns true for --flag=value with extra args" { + has_argument "--flag=value" "--other" +} + +@test "has_argument: returns false for --flag= (empty value after =)" { + ! has_argument "--flag=" +} + +@test "has_argument: returns true when second arg is a plain value" { + has_argument "--flag" "myvalue" +} + +@test "has_argument: returns false when second arg starts with a dash" { + ! has_argument "--flag" "--other-flag" +} + +@test "has_argument: returns false when no second arg and first has no =" { + ! has_argument "--flag" +} + +@test "has_argument: returns false with only a bare flag and empty second arg" { + ! has_argument "--flag" "" +} + +@test "has_argument: returns true for single-char -r=value" { + has_argument "-r=arn:aws:iam::123:role/my-role" +} + +@test "has_argument: returns true for -r followed by a value" { + has_argument "-r" "arn:aws:iam::123:role/my-role" +} + +# --------------------------------------------------------------------------- +# extract_argument +# --------------------------------------------------------------------------- + +@test "extract_argument: extracts value after = in first arg" { + result=$(extract_argument "--flag=myvalue") + [ "$result" = "myvalue" ] +} + +@test "extract_argument: returns second arg when both formats provided (second wins)" { + result=$(extract_argument "--flag=fromflag" "fromsecond") + [ "$result" = "fromsecond" ] +} + +@test "extract_argument: returns second arg when only second arg given" { + result=$(extract_argument "--flag" "onlysecond") + [ "$result" = "onlysecond" ] +} + +@test "extract_argument: handles ARN values with colons and slashes" { + result=$(extract_argument "--roleArn=arn:aws:iam::123456789012:role/my-role") + [ "$result" = "arn:aws:iam::123456789012:role/my-role" ] +} + +@test "extract_argument: handles ARN value as second argument" { + result=$(extract_argument "--roleArn" "arn:aws:iam::123456789012:role/my-role") + [ "$result" = "arn:aws:iam::123456789012:role/my-role" ] +} + +@test "extract_argument: handles value with spaces when quoted" { + result=$(extract_argument "--desc" "a value with spaces") + [ "$result" = "a value with spaces" ] +} diff --git a/tests/unit/promote-ecr-image/test_promote_image.bats b/tests/unit/promote-ecr-image/test_promote_image.bats new file mode 100644 index 0000000..4382452 --- /dev/null +++ b/tests/unit/promote-ecr-image/test_promote_image.bats @@ -0,0 +1,255 @@ +#!/usr/bin/env bats +# ============================================================================= +# promote_image.bats +# Unit tests for promote-ecr-image/scripts/promote_image.sh +# +# Strategy: test the env var validation logic and --help behaviour. +# The core AWS+Docker operations require live credentials and are integration +# tests; those are NOT covered here. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" +PROMOTE_IMAGE_SCRIPT="$REPO_ROOT/.github/actions/promote-ecr-image/scripts/promote_image.sh" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# All required env vars for the promote_image function +_set_all_required_vars() { + export AWS_ACCOUNT="123456789012" + export AWS_DEFAULT_REGION="us-east-1" + export ENVIRONMENT="staging" + export ECR="my-ecr-repo" + export ECR_ACCESS_ROLE_NAME="ecr-write-access" + export ECR_TAG_NAME="my-app" + export LOWER_AWS_ACCOUNT="098765432109" + export LOWER_AWS_DEFAULT_REGION="us-east-1" + export LOWER_BRANCH="dev" + export LOWER_ECR="my-ecr-repo" + export LOWER_ECR_ACCESS_ROLE_NAME="ecr-write-access" +} + +_unset_all_required_vars() { + unset AWS_ACCOUNT AWS_DEFAULT_REGION ENVIRONMENT ECR ECR_ACCESS_ROLE_NAME \ + ECR_TAG_NAME LOWER_AWS_ACCOUNT LOWER_AWS_DEFAULT_REGION LOWER_BRANCH \ + LOWER_ECR LOWER_ECR_ACCESS_ROLE_NAME +} + +setup() { + _unset_all_required_vars +} + +teardown() { + _unset_all_required_vars +} + +# --------------------------------------------------------------------------- +# Required environment variable validation +# --------------------------------------------------------------------------- + +@test "promote_image: exits 1 when AWS_ACCOUNT is missing" { + run bash -c " + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when AWS_DEFAULT_REGION is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when ENVIRONMENT is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when ECR is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when ECR_ACCESS_ROLE_NAME is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when ECR_TAG_NAME is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when LOWER_AWS_ACCOUNT is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when LOWER_BRANCH is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when LOWER_ECR is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when LOWER_ECR_ACCESS_ROLE_NAME is missing" { + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "promote_image: exits 1 when no env vars are set" { + run bash -c "source '$PROMOTE_IMAGE_SCRIPT'" + [ "$status" -eq 1 ] +} + +@test "promote_image: error output mentions the missing variable name" { + run bash -c "source '$PROMOTE_IMAGE_SCRIPT'" 2>&1 + # At minimum one of the required var names should appear in stderr + [[ "$output" =~ "AWS_ACCOUNT" ]] || [[ "$output" =~ "are empty" ]] +} + +# --------------------------------------------------------------------------- +# --help flag +# --------------------------------------------------------------------------- + +@test "promote_image: --help exits 0" { + # Pass --help via set -- so the script's bottom-level `promote_image "$@"` call + # receives it. All required env vars are set so the validation block passes first. + run bash -c " + export AWS_ACCOUNT='123456789012' + export AWS_DEFAULT_REGION='us-east-1' + export ENVIRONMENT='staging' + export ECR='my-ecr-repo' + export ECR_ACCESS_ROLE_NAME='ecr-write-access' + export ECR_TAG_NAME='my-app' + export LOWER_AWS_ACCOUNT='098765432109' + export LOWER_AWS_DEFAULT_REGION='us-east-1' + export LOWER_BRANCH='dev' + export LOWER_ECR='my-ecr-repo' + export LOWER_ECR_ACCESS_ROLE_NAME='ecr-write-access' + set -- --help + source '$PROMOTE_IMAGE_SCRIPT' + " + [ "$status" -eq 0 ] +} diff --git a/tests/unit/test-python/test_configure_pip.bats b/tests/unit/test-python/test_configure_pip.bats new file mode 100644 index 0000000..64f2489 --- /dev/null +++ b/tests/unit/test-python/test_configure_pip.bats @@ -0,0 +1,186 @@ +#!/usr/bin/env bats +# ============================================================================= +# configure_pip.bats +# Unit tests for test-python/scripts/configure_pip.sh +# +# Strategy: replace `pip` and `tput` with lightweight mocks that record +# every call, then assert that configure_pip() invokes pip with the right +# arguments based on which environment variables are set. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" +CONFIGURE_PIP_SCRIPT="$REPO_ROOT/.github/actions/test-python/scripts/configure_pip.sh" + +# --------------------------------------------------------------------------- +# Setup / Teardown +# --------------------------------------------------------------------------- + +setup() { + # Create a temp dir for mock binaries + MOCK_DIR="$(mktemp -d)" + export MOCK_DIR + + # Mock pip: records args to a log file, exits 0 + cat > "$MOCK_DIR/pip" << 'EOF' +#!/usr/bin/env bash +echo "$@" >> "$MOCK_DIR/pip_calls.log" +exit 0 +EOF + chmod +x "$MOCK_DIR/pip" + # Inject the MOCK_DIR variable into the mock at runtime + sed -i.bak 's|\$MOCK_DIR|'"$MOCK_DIR"'|g' "$MOCK_DIR/pip" + rm -f "$MOCK_DIR/pip.bak" + + # Mock tput: silently succeed (avoids "no terminal" errors in CI) + cat > "$MOCK_DIR/tput" << 'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$MOCK_DIR/tput" + + # Prepend mock dir to PATH + export PATH="$MOCK_DIR:$PATH" + + # Clear all pip-related env vars before each test + unset GLOBAL_INDEX_URL GLOBAL_TRUSTED_HOST SEARCH_URL + + # Source the script — this defines configure_pip() and immediately calls it + # with no args (since $@ is empty in the test context). Because all env vars + # are unset, the initial call is a no-op. + # shellcheck source=/dev/null + source "$CONFIGURE_PIP_SCRIPT" + + # Remove any pip calls logged during the initial source-time invocation + rm -f "$MOCK_DIR/pip_calls.log" +} + +teardown() { + [ -n "${MOCK_DIR:-}" ] && rm -rf "$MOCK_DIR" + unset GLOBAL_INDEX_URL GLOBAL_TRUSTED_HOST SEARCH_URL +} + +# Helper: assert pip was called with a given argument string +assert_pip_called_with() { + local expected="$1" + if ! grep -qF "$expected" "$MOCK_DIR/pip_calls.log" 2>/dev/null; then + echo "FAIL: pip was not called with: $expected" >&2 + echo "Actual pip calls:" >&2 + cat "$MOCK_DIR/pip_calls.log" 2>/dev/null || echo "(no calls)" >&2 + return 1 + fi +} + +# Helper: assert pip was never called +assert_pip_not_called() { + if [[ -f "$MOCK_DIR/pip_calls.log" ]]; then + echo "FAIL: pip was called but should not have been" >&2 + cat "$MOCK_DIR/pip_calls.log" >&2 + return 1 + fi +} + +# --------------------------------------------------------------------------- +# No-op tests (no env vars set) +# --------------------------------------------------------------------------- + +@test "configure_pip: does nothing when no env vars are set" { + configure_pip + assert_pip_not_called +} + +# --------------------------------------------------------------------------- +# GLOBAL_INDEX_URL +# --------------------------------------------------------------------------- + +@test "configure_pip: sets global.index-url when GLOBAL_INDEX_URL is set" { + export GLOBAL_INDEX_URL="https://pypi.example.com/simple" + configure_pip + assert_pip_called_with "config set global.index-url https://pypi.example.com/simple" +} + +@test "configure_pip: does NOT set global.index-url when GLOBAL_INDEX_URL is empty" { + export GLOBAL_INDEX_URL="" + configure_pip + assert_pip_not_called +} + +# --------------------------------------------------------------------------- +# GLOBAL_TRUSTED_HOST +# --------------------------------------------------------------------------- + +@test "configure_pip: sets global.trusted-host when GLOBAL_TRUSTED_HOST is set" { + export GLOBAL_TRUSTED_HOST="pypi.example.com" + configure_pip + assert_pip_called_with "config set global.trusted-host pypi.example.com" +} + +@test "configure_pip: does NOT set global.trusted-host when GLOBAL_TRUSTED_HOST is empty" { + export GLOBAL_TRUSTED_HOST="" + configure_pip + assert_pip_not_called +} + +# --------------------------------------------------------------------------- +# SEARCH_URL +# --------------------------------------------------------------------------- + +@test "configure_pip: sets search.index when SEARCH_URL is set" { + export SEARCH_URL="https://pypi.example.com/pypi" + configure_pip + assert_pip_called_with "config set search.index https://pypi.example.com/pypi" +} + +@test "configure_pip: does NOT set search.index when SEARCH_URL is empty" { + export SEARCH_URL="" + configure_pip + assert_pip_not_called +} + +# --------------------------------------------------------------------------- +# Combined vars +# --------------------------------------------------------------------------- + +@test "configure_pip: sets all three when all env vars are provided" { + export GLOBAL_INDEX_URL="https://pypi.example.com/simple" + export GLOBAL_TRUSTED_HOST="pypi.example.com" + export SEARCH_URL="https://pypi.example.com/pypi" + + configure_pip + + assert_pip_called_with "config set global.index-url https://pypi.example.com/simple" + assert_pip_called_with "config set global.trusted-host pypi.example.com" + assert_pip_called_with "config set search.index https://pypi.example.com/pypi" +} + +@test "configure_pip: sets only the provided vars when only two are given" { + export GLOBAL_INDEX_URL="https://pypi.example.com/simple" + export SEARCH_URL="https://pypi.example.com/pypi" + # GLOBAL_TRUSTED_HOST intentionally NOT set + + configure_pip + + assert_pip_called_with "config set global.index-url https://pypi.example.com/simple" + assert_pip_called_with "config set search.index https://pypi.example.com/pypi" + + # trusted-host should NOT have been set + if grep -qF "global.trusted-host" "$MOCK_DIR/pip_calls.log" 2>/dev/null; then + echo "FAIL: global.trusted-host was set but should not have been" >&2 + return 1 + fi +} + +# --------------------------------------------------------------------------- +# --help flag +# --------------------------------------------------------------------------- + +@test "configure_pip: --help exits 0 and does not call pip" { + run bash -c " + export PATH='$MOCK_DIR:\$PATH' + export MOCK_DIR='$MOCK_DIR' + source '$CONFIGURE_PIP_SCRIPT' + configure_pip --help + " + [ "$status" -eq 0 ] + # pip should not have been called + [ ! -f "$MOCK_DIR/pip_calls.log" ] || ! grep -q "config set" "$MOCK_DIR/pip_calls.log" +} diff --git a/tests/unit/update-aws-ecs/test_update_ecs.bats b/tests/unit/update-aws-ecs/test_update_ecs.bats new file mode 100644 index 0000000..a319bcf --- /dev/null +++ b/tests/unit/update-aws-ecs/test_update_ecs.bats @@ -0,0 +1,166 @@ +#!/usr/bin/env bats +# ============================================================================= +# update_ecs.bats +# Unit tests for update-aws-ecs/scripts/update_ecs.sh +# +# Tests cover: +# - --help flag (exits 0, no AWS calls) +# - AWS CLI invocation with correct arguments (using a mock aws binary) +# - Failure when aws ecs update-service returns empty response +# +# Strategy: setup() prepends a MOCK_DIR to PATH and exports it. All run +# bash -c "..." subshells inherit this PATH automatically — do NOT override +# PATH inside the subshell, as that breaks system tools like dirname. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" +UPDATE_ECS_SCRIPT="$REPO_ROOT/.github/actions/update-aws-ecs/scripts/update_ecs.sh" + +setup() { + MOCK_DIR="$(mktemp -d)" + export MOCK_DIR + + # Mock aws: records all calls; returns real-looking JSON for known sub-commands + cat > "$MOCK_DIR/aws" << MOCK +#!/bin/bash +echo "\$@" >> "${MOCK_DIR}/aws_calls.log" +if [[ "\$*" == *"assume-role"* ]]; then + echo '{"Credentials":{"AccessKeyId":"FAKEKEY","SecretAccessKey":"FAKESECRET","SessionToken":"FAKETOKEN","Expiration":"2099-01-01T00:00:00Z"}}' +elif [[ "\$*" == *"update-service"* ]]; then + echo '{"service":{"serviceName":"test-service","clusterArn":"arn:aws:ecs:us-east-1:123:cluster/test"}}' +fi +exit 0 +MOCK + chmod +x "$MOCK_DIR/aws" + + # Mock tput (avoids "no terminal" errors in CI) + printf '#!/bin/sh\nexit 0\n' > "$MOCK_DIR/tput" + chmod +x "$MOCK_DIR/tput" + + # Prepend mock dir — subshells inherit this PATH + export PATH="$MOCK_DIR:$PATH" + + # Required env vars (used by the script's function body) + export CLUSTER_NAME="my-cluster" + export SERVICE_NAME="my-service" + export ASSUME_ECS_ACCESS_ROLE_ARN="arn:aws:iam::123456789012:role/ecs-access" + export AWS_DEFAULT_REGION="us-east-1" +} + +teardown() { + [ -n "${MOCK_DIR:-}" ] && rm -rf "$MOCK_DIR" + unset CLUSTER_NAME SERVICE_NAME ASSUME_ECS_ACCESS_ROLE_ARN AWS_DEFAULT_REGION +} + +# --------------------------------------------------------------------------- +# --help flag +# --------------------------------------------------------------------------- + +@test "update_ecs: --help exits 0 without running the main body" { + # Pass --help as positional arg via set -- so update_ecs "$@" receives it + run bash -c " + export CLUSTER_NAME='my-cluster' + export SERVICE_NAME='my-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + set -- --help + source '$UPDATE_ECS_SCRIPT' + " + [ "$status" -eq 0 ] +} + +@test "update_ecs: --help does not call aws ecs update-service" { + run bash -c " + export CLUSTER_NAME='my-cluster' + export SERVICE_NAME='my-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + set -- --help + source '$UPDATE_ECS_SCRIPT' + " + [ ! -f "$MOCK_DIR/aws_calls.log" ] || ! grep -q "update-service" "$MOCK_DIR/aws_calls.log" +} + +# --------------------------------------------------------------------------- +# Successful execution +# --------------------------------------------------------------------------- + +@test "update_ecs: calls aws ecs update-service" { + bash -c " + export CLUSTER_NAME='my-cluster' + export SERVICE_NAME='my-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_ECS_SCRIPT' + " + grep -q "update-service" "$MOCK_DIR/aws_calls.log" +} + +@test "update_ecs: passes --force-new-deployment to aws ecs update-service" { + bash -c " + export CLUSTER_NAME='my-cluster' + export SERVICE_NAME='my-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_ECS_SCRIPT' + " + grep -q "force-new-deployment" "$MOCK_DIR/aws_calls.log" +} + +@test "update_ecs: passes CLUSTER_NAME to aws ecs update-service" { + bash -c " + export CLUSTER_NAME='production-cluster' + export SERVICE_NAME='api-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_ECS_SCRIPT' + " + grep -q "production-cluster" "$MOCK_DIR/aws_calls.log" +} + +@test "update_ecs: passes SERVICE_NAME to aws ecs update-service" { + bash -c " + export CLUSTER_NAME='production-cluster' + export SERVICE_NAME='api-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_ECS_SCRIPT' + " + grep -q "api-service" "$MOCK_DIR/aws_calls.log" +} + +@test "update_ecs: calls aws sts assume-role before update-service" { + bash -c " + export CLUSTER_NAME='my-cluster' + export SERVICE_NAME='my-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_ECS_SCRIPT' + " + grep -q "assume-role" "$MOCK_DIR/aws_calls.log" +} + +# --------------------------------------------------------------------------- +# Failure: empty response from aws ecs update-service +# --------------------------------------------------------------------------- + +@test "update_ecs: exits 1 when aws ecs update-service returns empty response" { + # Override mock to return empty for update-service + cat > "$MOCK_DIR/aws" << MOCK +#!/bin/bash +echo "\$@" >> "${MOCK_DIR}/aws_calls.log" +if [[ "\$*" == *"assume-role"* ]]; then + echo '{"Credentials":{"AccessKeyId":"KEY","SecretAccessKey":"SECRET","SessionToken":"TOKEN","Expiration":"2099-01-01T00:00:00Z"}}' +fi +# Intentionally return nothing for update-service +exit 0 +MOCK + chmod +x "$MOCK_DIR/aws" + + run bash -c " + export CLUSTER_NAME='my-cluster' + export SERVICE_NAME='my-service' + export ASSUME_ECS_ACCESS_ROLE_ARN='arn:aws:iam::123:role/ecs-access' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_ECS_SCRIPT' + " + [ "$status" -eq 1 ] +} diff --git a/tests/unit/update-aws-lambda/test_update_lambda.bats b/tests/unit/update-aws-lambda/test_update_lambda.bats new file mode 100644 index 0000000..8501fa9 --- /dev/null +++ b/tests/unit/update-aws-lambda/test_update_lambda.bats @@ -0,0 +1,155 @@ +#!/usr/bin/env bats +# ============================================================================= +# update_lambda.bats +# Unit tests for update-aws-lambda/scripts/update_lambda.sh +# +# Tests cover: +# - --help flag (exits 0, no AWS calls) +# - AWS CLI invocation with correct arguments (using a mock aws binary) +# - Failure when aws lambda update-function-code returns empty response +# +# Strategy: setup() prepends a MOCK_DIR to PATH and exports it. All run +# bash -c "..." subshells inherit this PATH automatically — do NOT override +# PATH inside the subshell, as that breaks system tools like dirname. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" +UPDATE_LAMBDA_SCRIPT="$REPO_ROOT/.github/actions/update-aws-lambda/scripts/update_lambda.sh" + +setup() { + MOCK_DIR="$(mktemp -d)" + export MOCK_DIR + + # Mock aws: records all calls; returns real-looking JSON for known sub-commands + cat > "$MOCK_DIR/aws" << MOCK +#!/bin/bash +echo "\$@" >> "${MOCK_DIR}/aws_calls.log" +if [[ "\$*" == *"assume-role"* ]]; then + echo '{"Credentials":{"AccessKeyId":"FAKEKEY","SecretAccessKey":"FAKESECRET","SessionToken":"FAKETOKEN","Expiration":"2099-01-01T00:00:00Z"}}' +elif [[ "\$*" == *"update-function-code"* ]]; then + echo '{"FunctionName":"my-function","FunctionArn":"arn:aws:lambda:us-east-1:123:function:my-function"}' +fi +exit 0 +MOCK + chmod +x "$MOCK_DIR/aws" + + # Mock tput (avoids "no terminal" errors in CI) + printf '#!/bin/sh\nexit 0\n' > "$MOCK_DIR/tput" + chmod +x "$MOCK_DIR/tput" + + # Prepend mock dir — subshells inherit this PATH + export PATH="$MOCK_DIR:$PATH" + + # Required env vars + export FUNCTION_NAME="my-lambda-function" + export IMAGE_URL="123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:prod-latest" + export ASSUME_LAMBDA_UPDATE_ROLE_ARN="arn:aws:iam::123456789012:role/lambda-update" + export AWS_DEFAULT_REGION="us-east-1" +} + +teardown() { + [ -n "${MOCK_DIR:-}" ] && rm -rf "$MOCK_DIR" + unset FUNCTION_NAME IMAGE_URL ASSUME_LAMBDA_UPDATE_ROLE_ARN AWS_DEFAULT_REGION +} + +# --------------------------------------------------------------------------- +# --help flag +# --------------------------------------------------------------------------- + +@test "update_lambda: --help exits 0 without running the main body" { + # Pass --help as positional arg via set -- so update_lambda "$@" receives it + run bash -c " + export FUNCTION_NAME='my-function' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/repo:tag' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + set -- --help + source '$UPDATE_LAMBDA_SCRIPT' + " + [ "$status" -eq 0 ] +} + +@test "update_lambda: --help does not call aws lambda update-function-code" { + run bash -c " + export FUNCTION_NAME='my-function' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/repo:tag' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + set -- --help + source '$UPDATE_LAMBDA_SCRIPT' + " + [ ! -f "$MOCK_DIR/aws_calls.log" ] || ! grep -q "update-function-code" "$MOCK_DIR/aws_calls.log" +} + +# --------------------------------------------------------------------------- +# Successful execution +# --------------------------------------------------------------------------- + +@test "update_lambda: calls aws lambda update-function-code" { + bash -c " + export FUNCTION_NAME='my-function' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/repo:prod-latest' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_LAMBDA_SCRIPT' + " + grep -q "update-function-code" "$MOCK_DIR/aws_calls.log" +} + +@test "update_lambda: passes FUNCTION_NAME to aws lambda update-function-code" { + bash -c " + export FUNCTION_NAME='payments-processor' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/repo:prod-latest' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_LAMBDA_SCRIPT' + " + grep -q "payments-processor" "$MOCK_DIR/aws_calls.log" +} + +@test "update_lambda: passes IMAGE_URL to aws lambda update-function-code" { + bash -c " + export FUNCTION_NAME='my-function' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/payments:prod-20240101000000' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_LAMBDA_SCRIPT' + " + grep -q "prod-20240101000000" "$MOCK_DIR/aws_calls.log" +} + +@test "update_lambda: calls aws sts assume-role before update-function-code" { + bash -c " + export FUNCTION_NAME='my-function' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/repo:prod-latest' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_LAMBDA_SCRIPT' + " + grep -q "assume-role" "$MOCK_DIR/aws_calls.log" +} + +# --------------------------------------------------------------------------- +# Failure: empty response from aws lambda update-function-code +# --------------------------------------------------------------------------- + +@test "update_lambda: exits 1 when aws lambda update-function-code returns empty response" { + # Override mock to return empty for update-function-code + cat > "$MOCK_DIR/aws" << MOCK +#!/bin/bash +echo "\$@" >> "${MOCK_DIR}/aws_calls.log" +if [[ "\$*" == *"assume-role"* ]]; then + echo '{"Credentials":{"AccessKeyId":"KEY","SecretAccessKey":"SECRET","SessionToken":"TOKEN","Expiration":"2099-01-01T00:00:00Z"}}' +fi +# Intentionally return nothing for update-function-code +exit 0 +MOCK + chmod +x "$MOCK_DIR/aws" + + run bash -c " + export FUNCTION_NAME='my-function' + export IMAGE_URL='123.dkr.ecr.us-east-1.amazonaws.com/repo:prod-latest' + export ASSUME_LAMBDA_UPDATE_ROLE_ARN='arn:aws:iam::123:role/lambda-update' + export AWS_DEFAULT_REGION='us-east-1' + source '$UPDATE_LAMBDA_SCRIPT' + " + [ "$status" -eq 1 ] +}